Skip to content

Commit 96c8481

Browse files
authored
Track automation history (#708)
1 parent f417eaf commit 96c8481

File tree

7 files changed

+216
-9
lines changed

7 files changed

+216
-9
lines changed

Loop.xcodeproj/project.pbxproj

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,8 @@
415415
C138C6F82C1B8A2C00F08F1A /* GlucoseCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = C138C6F72C1B8A2C00F08F1A /* GlucoseCondition.swift */; };
416416
C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13DA2AF24F6C7690098BB29 /* UIViewController.swift */; };
417417
C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */; };
418+
C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */; };
419+
C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */; };
418420
C159C82F286787EF00A86EC0 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C82E286787EF00A86EC0 /* LoopKit.framework */; };
419421
C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; };
420422
C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; };
@@ -1347,6 +1349,8 @@
13471349
C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = "<group>"; };
13481350
C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryUncertaintyAlertManager.swift; sourceTree = "<group>"; };
13491351
C14952142995822A0095AA84 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = "<group>"; };
1352+
C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntry.swift; sourceTree = "<group>"; };
1353+
C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationHistoryEntryTests.swift; sourceTree = "<group>"; };
13501354
C155A8F32986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = "<group>"; };
13511355
C155A8F42986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
13521356
C155A8F52986396E009BD257 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/ckcomplication.strings; sourceTree = "<group>"; };
@@ -1860,6 +1864,7 @@
18601864
A987CD4824A58A0100439ADC /* ZipArchive.swift */,
18611865
C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */,
18621866
C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */,
1867+
C152B9F42C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift */,
18631868
);
18641869
path = Models;
18651870
sourceTree = "<group>";
@@ -2603,13 +2608,14 @@
26032608
A9E6DFED246A0460005B1A1C /* Models */ = {
26042609
isa = PBXGroup;
26052610
children = (
2611+
C152B9F62C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift */,
26062612
A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */,
26072613
A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */,
2608-
A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */,
26092614
C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */,
2610-
A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */,
2611-
A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */,
26122615
C129D3BE2B8697F100FEA6A9 /* TempBasalRecommendationTests.swift */,
2616+
A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */,
2617+
A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */,
2618+
A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */,
26132619
);
26142620
path = Models;
26152621
sourceTree = "<group>";
@@ -3438,6 +3444,7 @@
34383444
4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */,
34393445
89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */,
34403446
43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */,
3447+
C152B9F52C9C7D4A00ACBC06 /* AutomationHistoryEntry.swift in Sources */,
34413448
1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */,
34423449
43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */,
34433450
142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */,
@@ -3744,6 +3751,7 @@
37443751
C1777A6625A125F100595963 /* ManualEntryDoseViewModelTests.swift in Sources */,
37453752
C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */,
37463753
A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */,
3754+
C152B9F72C9C7EF100ACBC06 /* AutomationHistoryEntryTests.swift in Sources */,
37473755
A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */,
37483756
A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */,
37493757
A9DFAFB524F048A000950D1E /* WatchHistoricalCarbsTests.swift in Sources */,

Loop/Extensions/UserDefaults+Loop.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ extension UserDefaults {
1818
case loopNotRunningNotifications = "com.loopkit.Loop.loopNotRunningNotifications"
1919
case inFlightAutomaticDose = "com.loopkit.Loop.inFlightAutomaticDose"
2020
case favoriteFoods = "com.loopkit.Loop.favoriteFoods"
21+
case automationHistory = "com.loopkit.Loop.automationHistory"
2122
}
2223

2324
var legacyPumpManagerRawValue: PumpManager.RawValue? {
@@ -110,4 +111,23 @@ extension UserDefaults {
110111
}
111112
}
112113
}
114+
115+
var automationHistory: [AutomationHistoryEntry] {
116+
get {
117+
let decoder = JSONDecoder()
118+
guard let data = object(forKey: Key.automationHistory.rawValue) as? Data else {
119+
return []
120+
}
121+
return (try? decoder.decode([AutomationHistoryEntry].self, from: data)) ?? []
122+
}
123+
set {
124+
do {
125+
let encoder = JSONEncoder()
126+
let data = try encoder.encode(newValue)
127+
set(data, forKey: Key.automationHistory.rawValue)
128+
} catch {
129+
assertionFailure("Unable to encode automation history")
130+
}
131+
}
132+
}
113133
}

Loop/Managers/DeviceDataManager.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ protocol LoopControl {
1919
var lastLoopCompleted: Date? { get }
2020
func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) async throws
2121
func loop() async
22+
func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue<Bool>]
2223
}
2324

2425
protocol ActiveServicesProvider {
@@ -1140,6 +1141,11 @@ extension DeviceDataManager: DoseStoreDelegate {
11401141
func doseStoreHasUpdatedPumpEventData(_ doseStore: DoseStore) {
11411142
uploadEventListener.triggerUpload(for: .pumpEvent)
11421143
}
1144+
1145+
func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue<Bool>] {
1146+
return try await loopControl.automationHistory(from: start, to: end)
1147+
}
1148+
11431149
}
11441150

11451151
// MARK: - DosingDecisionStoreDelegate

Loop/Managers/LoopDataManager.swift

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ final class LoopDataManager: ObservableObject {
146146

147147
var usePositiveMomentumAndRCForManualBoluses: Bool
148148

149+
var automationHistory: [AutomationHistoryEntry] {
150+
didSet {
151+
UserDefaults.standard.automationHistory = automationHistory
152+
}
153+
}
154+
149155
lazy private var cancellables = Set<AnyCancellable>()
150156

151157
init(
@@ -177,10 +183,10 @@ final class LoopDataManager: ObservableObject {
177183
self.analyticsServicesManager = analyticsServicesManager
178184
self.carbAbsorptionModel = carbAbsorptionModel
179185
self.usePositiveMomentumAndRCForManualBoluses = usePositiveMomentumAndRCForManualBoluses
180-
181186
self.publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate
182187
self.publishedMostRecentPumpDataDate = mostRecentPumpDataDate
183-
188+
self.automationHistory = UserDefaults.standard.automationHistory
189+
184190
// Required for device settings in stored dosing decisions
185191
UIDevice.current.isBatteryMonitoringEnabled = true
186192

@@ -228,8 +234,20 @@ final class LoopDataManager: ObservableObject {
228234
automaticDosingStatus.$automaticDosingEnabled
229235
.removeDuplicates()
230236
.dropFirst()
231-
.sink {
232-
if !$0 {
237+
.sink { [weak self] enabled in
238+
guard let self else {
239+
return
240+
}
241+
if self.automationHistory.last?.enabled != enabled {
242+
self.automationHistory.append(AutomationHistoryEntry(startDate: Date(), enabled: enabled))
243+
244+
// Clean up entries older than 36 hours; we should not be interpolating basal data before then.
245+
let now = Date()
246+
self.automationHistory = self.automationHistory.filter({ entry in
247+
now.timeIntervalSince(entry.startDate) < .hours(36)
248+
})
249+
}
250+
if !enabled {
233251
self.temporaryPresetsManager.clearOverride(matching: .preMeal)
234252
Task {
235253
try? await self.cancelActiveTempBasal(for: .automaticDosingDisabled)
@@ -436,7 +454,7 @@ final class LoopDataManager: ObservableObject {
436454
logger.error("Error updating Loop state: %{public}@", String(describing: loopError))
437455
}
438456
displayState = newState
439-
publishedMostRecentGlucoseDataDate = mostRecentGlucoseDataDate
457+
publishedMostRecentGlucoseDataDate = glucoseStore.latestGlucose?.startDate
440458
publishedMostRecentPumpDataDate = mostRecentPumpDataDate
441459
await updateRemoteRecommendation()
442460
}
@@ -1444,7 +1462,11 @@ extension LoopDataManager: DiagnosticReportGenerator {
14441462
}
14451463
}
14461464

1447-
extension LoopDataManager: LoopControl { }
1465+
extension LoopDataManager: LoopControl {
1466+
func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue<Bool>] {
1467+
return automationHistory.toTimeline(from: start, to: end)
1468+
}
1469+
}
14481470

14491471
extension CarbMath {
14501472
public static let dateAdjustmentPast: TimeInterval = .hours(-12)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//
2+
// AutomationHistoryEntry.swift
3+
// Loop
4+
//
5+
// Created by Pete Schwamb on 9/19/24.
6+
// Copyright © 2024 LoopKit Authors. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import LoopAlgorithm
11+
12+
struct AutomationHistoryEntry: Codable {
13+
var startDate: Date
14+
var enabled: Bool
15+
}
16+
17+
extension Array where Element == AutomationHistoryEntry {
18+
func toTimeline(from start: Date, to end: Date) -> [AbsoluteScheduleValue<Bool>] {
19+
guard !isEmpty else {
20+
return []
21+
}
22+
23+
var out = [AbsoluteScheduleValue<Bool>]()
24+
25+
var iter = makeIterator()
26+
27+
var prev = iter.next()!
28+
29+
func addItem(start: Date, end: Date, enabled: Bool) {
30+
out.append(AbsoluteScheduleValue(startDate: start, endDate: end, value: enabled))
31+
}
32+
33+
while let cur = iter.next() {
34+
guard cur.enabled != prev.enabled else {
35+
continue
36+
}
37+
if cur.startDate > start {
38+
addItem(start: Swift.max(prev.startDate, start), end: Swift.min(cur.startDate, end), enabled: prev.enabled)
39+
}
40+
prev = cur
41+
}
42+
43+
if prev.startDate < end {
44+
addItem(start: prev.startDate, end: end, enabled: prev.enabled)
45+
}
46+
47+
return out
48+
}
49+
}

LoopTests/Mocks/LoopControlMock.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import XCTest
1010
import Foundation
11+
import LoopAlgorithm
1112
@testable import Loop
1213

1314

@@ -25,4 +26,8 @@ class LoopControlMock: LoopControl {
2526

2627
func loop() async {
2728
}
29+
30+
func automationHistory(from start: Date, to end: Date) async throws -> [AbsoluteScheduleValue<Bool>] {
31+
return []
32+
}
2833
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//
2+
// AutomationHistoryEntryTests.swift
3+
// LoopTests
4+
//
5+
// Created by Pete Schwamb on 9/19/24.
6+
// Copyright © 2024 LoopKit Authors. All rights reserved.
7+
//
8+
9+
import XCTest
10+
11+
@testable import Loop
12+
13+
class TimelineTests: XCTestCase {
14+
15+
func testEmptyArray() {
16+
let entries: [AutomationHistoryEntry] = []
17+
let start = Date()
18+
let end = start.addingTimeInterval(3600) // 1 hour later
19+
20+
let timeline = entries.toTimeline(from: start, to: end)
21+
22+
XCTAssertTrue(timeline.isEmpty, "Timeline should be empty for an empty array of entries")
23+
}
24+
25+
func testSingleEntry() {
26+
let start = Date()
27+
let end = start.addingTimeInterval(3600) // 1 hour later
28+
let entries = [AutomationHistoryEntry(startDate: start, enabled: true)]
29+
30+
let timeline = entries.toTimeline(from: start, to: end)
31+
32+
XCTAssertEqual(timeline.count, 1, "Timeline should have one entry")
33+
XCTAssertEqual(timeline[0].startDate, start)
34+
XCTAssertEqual(timeline[0].endDate, end)
35+
XCTAssertEqual(timeline[0].value, true)
36+
}
37+
38+
func testMultipleEntries() {
39+
let start = Date()
40+
let middleDate = start.addingTimeInterval(1800) // 30 minutes later
41+
let end = start.addingTimeInterval(3600) // 1 hour later
42+
let entries = [
43+
AutomationHistoryEntry(startDate: start, enabled: true),
44+
AutomationHistoryEntry(startDate: middleDate, enabled: false)
45+
]
46+
47+
let timeline = entries.toTimeline(from: start, to: end)
48+
49+
XCTAssertEqual(timeline.count, 2, "Timeline should have two entries")
50+
XCTAssertEqual(timeline[0].startDate, start)
51+
XCTAssertEqual(timeline[0].endDate, middleDate)
52+
XCTAssertEqual(timeline[0].value, true)
53+
XCTAssertEqual(timeline[1].startDate, middleDate)
54+
XCTAssertEqual(timeline[1].endDate, end)
55+
XCTAssertEqual(timeline[1].value, false)
56+
}
57+
58+
func testEntriesOutsideRange() {
59+
let start = Date()
60+
let end = start.addingTimeInterval(3600) // 1 hour later
61+
let beforeStart = start.addingTimeInterval(-1800) // 30 minutes before start
62+
let afterEnd = end.addingTimeInterval(1800) // 30 minutes after end
63+
let entries = [
64+
AutomationHistoryEntry(startDate: beforeStart, enabled: true),
65+
AutomationHistoryEntry(startDate: afterEnd, enabled: false)
66+
]
67+
68+
let timeline = entries.toTimeline(from: start, to: end)
69+
70+
XCTAssertEqual(timeline.count, 1, "Timeline should have one entry")
71+
XCTAssertEqual(timeline[0].startDate, start)
72+
XCTAssertEqual(timeline[0].endDate, end)
73+
XCTAssertEqual(timeline[0].value, true)
74+
}
75+
76+
func testConsecutiveEntriesWithSameValue() {
77+
let start = Date()
78+
let middle1 = start.addingTimeInterval(1200) // 20 minutes later
79+
let middle2 = start.addingTimeInterval(2400) // 40 minutes later
80+
let end = start.addingTimeInterval(3600) // 1 hour later
81+
let entries = [
82+
AutomationHistoryEntry(startDate: start, enabled: true),
83+
AutomationHistoryEntry(startDate: middle1, enabled: true),
84+
AutomationHistoryEntry(startDate: middle2, enabled: false)
85+
]
86+
87+
let timeline = entries.toTimeline(from: start, to: end)
88+
89+
XCTAssertEqual(timeline.count, 2, "Timeline should have two entries")
90+
XCTAssertEqual(timeline[0].startDate, start)
91+
XCTAssertEqual(timeline[0].endDate, middle2)
92+
XCTAssertEqual(timeline[0].value, true)
93+
XCTAssertEqual(timeline[1].startDate, middle2)
94+
XCTAssertEqual(timeline[1].endDate, end)
95+
XCTAssertEqual(timeline[1].value, false)
96+
}
97+
}

0 commit comments

Comments
 (0)