فهرست منبع

Initial commit for porting oref meal module to Swift WIP

Deniz Cengiz 1 سال پیش
والد
کامیت
74fc2de8ea

+ 21 - 0
Trio.xcodeproj/project.pbxproj

@@ -657,6 +657,9 @@
 		DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */; };
 		DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */; };
 		DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD98ACBF2D71013200C0778F /* StatChartUtils.swift */; };
+		DD9E6DA22D59A12700514CEC /* MealHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9E6DA12D59A12200514CEC /* MealHistory.swift */; };
+		DD9E6DA52D5A66BA00514CEC /* MealGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9E6DA42D5A66B500514CEC /* MealGenerator.swift */; };
+		DD9E6DA72D5A695500514CEC /* MealTotal.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9E6DA62D5A694900514CEC /* MealTotal.swift */; };
 		DD9ECB682CA99F4500AA7C45 /* TrioRemoteControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */; };
 		DD9ECB6A2CA99F6C00AA7C45 /* PushMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */; };
 		DD9ECB702CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */; };
@@ -1531,6 +1534,9 @@
 		DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseColorScheme.swift; sourceTree = "<group>"; };
 		DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicGlucoseColor.swift; sourceTree = "<group>"; };
 		DD98ACBF2D71013200C0778F /* StatChartUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatChartUtils.swift; sourceTree = "<group>"; };
+		DD9E6DA12D59A12200514CEC /* MealHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealHistory.swift; sourceTree = "<group>"; };
+		DD9E6DA42D5A66B500514CEC /* MealGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealGenerator.swift; sourceTree = "<group>"; };
+		DD9E6DA62D5A694900514CEC /* MealTotal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealTotal.swift; sourceTree = "<group>"; };
 		DD9ECB672CA99F4500AA7C45 /* TrioRemoteControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioRemoteControl.swift; sourceTree = "<group>"; };
 		DD9ECB692CA99F6C00AA7C45 /* PushMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessage.swift; sourceTree = "<group>"; };
 		DD9ECB6D2CA9A0BA00AA7C45 /* RemoteControlConfigStateModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigStateModel.swift; sourceTree = "<group>"; };
@@ -2734,6 +2740,7 @@
 		3B5CD1EB2D4912A600CE213C /* OpenAPSSwift */ = {
 			isa = PBXGroup;
 			children = (
+				DD9E6DA02D59A11200514CEC /* Meal */,
 				3B5CD2A42D4AEA5D00CE213C /* Extensions */,
 				3B1C5C282D68E1E3004E9273 /* Iob */,
 				3BEA3ADF2D58F79700A67A1D /* Logging */,
@@ -3660,6 +3667,16 @@
 			path = StartupGuide;
 			sourceTree = "<group>";
 		};
+		DD9E6DA02D59A11200514CEC /* Meal */ = {
+			isa = PBXGroup;
+			children = (
+				DD9E6DA62D5A694900514CEC /* MealTotal.swift */,
+				DD9E6DA42D5A66B500514CEC /* MealGenerator.swift */,
+				DD9E6DA12D59A12200514CEC /* MealHistory.swift */,
+			);
+			path = Meal;
+			sourceTree = "<group>";
+		};
 		DD9ECB662CA99EFE00AA7C45 /* RemoteControl */ = {
 			isa = PBXGroup;
 			children = (
@@ -4416,6 +4433,7 @@
 				CEE9A6552BBB418300EB5194 /* CalibrationsProvider.swift in Sources */,
 				19F95FF529F10FCF00314DDC /* StatProvider.swift in Sources */,
 				38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */,
+				DD9E6DA72D5A695500514CEC /* MealTotal.swift in Sources */,
 				5A2325542BFCBF66003518CA /* NightscoutFetchView.swift in Sources */,
 				19B0EF2128F6D66200069496 /* Statistics.swift in Sources */,
 				DDF847E82C5DABA30049BB3B /* WatchConfigAppleWatchView.swift in Sources */,
@@ -4705,6 +4723,7 @@
 				CEF1ED6B2D58FB5800FAF41E /* CGMOptions.swift in Sources */,
 				E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */,
 				BD8E6B212D9036CA00ABF8FA /* OnboardingProvider.swift in Sources */,
+				DD9E6DA52D5A66BA00514CEC /* MealGenerator.swift in Sources */,
 				DD1745502C55CA5500211FAC /* UnitsLimitsSettingsProvider.swift in Sources */,
 				581AC4392BE22ED10038760C /* JSONConverter.swift in Sources */,
 				BD4064D12C4ED26900582F43 /* CoreDataObserver.swift in Sources */,
@@ -4901,6 +4920,8 @@
 				71D44AAB2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift in Sources */,
 				DDF691032DA2CA1E008BF16C /* AppDiagnosticsProvider.swift in Sources */,
 				320D030F724170A637F06D50 /* (null) in Sources */,
+				CE94598729E9E4110047C9C6 /* WatchConfigRootView.swift in Sources */,
+				DD9E6DA22D59A12700514CEC /* MealHistory.swift in Sources */,
 				19E1F7E829D082D0005C8D20 /* IconConfigDataFlow.swift in Sources */,
 				5A2325522BFCBF55003518CA /* NightscoutUploadView.swift in Sources */,
 				E3A08AAE59538BC8A8ABE477 /* GlucoseNotificationSettingsDataFlow.swift in Sources */,

+ 18 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealGenerator.swift

@@ -0,0 +1,18 @@
+import Foundation
+
+enum MealGeneratorError {
+    static func generate(
+        pumpHistory: [PumpHistoryEvent],
+        profile: Profile,
+        basalProfile: [BasalProfileEntry],
+        clock: Date,
+        carbHistory: [CarbsEntry],
+        glucoseHistory: [BloodGlucose]
+    ) -> ComputedCarbs? {
+        var treatments: [MealInput] = MealHistory.findMealInputs(pumpHistory: pumpHistory, carbHistory: carbHistory)
+        
+        // TODO: do we need to handle the clock timezone handling? We'll parse in a proper Swift Date anyhow
+        
+        return MealTotal.recentCarbs(treatments: treatments, pumpHistory: pumpHistory, profile: profile, basalProfile: basalProfile, glucose: glucoseHistory, time: clock)
+    }
+}

+ 127 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealHistory.swift

@@ -0,0 +1,127 @@
+import Foundation
+
+/// Represents the "temp" object built in JS meal/history.js
+struct MealInput {
+    let timestamp: Date
+    var carbs: Decimal? /// `current.carbs`
+    var nsCarbs: Decimal? /// `current.carbs` in NS
+    var bolus: Decimal? /// from `current.amount` in Bolus events
+    var journalCarbs: Decimal?
+    var bwCarbs: Decimal?
+    // TODO: we probably do not need nsCarbs, journalCarbs, and bwCarbs
+}
+
+enum MealHistory {
+    /// Checks if `array` contains a MealInput with an entry that is ± 2 seconds around `t`.
+    /// and a non-nil property given by propName ("carbs", "bolus", etc.).
+    static func arrayHasElementWithSameTimestampAndProperty(
+        mealInputs: [MealInput],
+        t: Date,
+        propName: String
+    ) -> Bool {
+        // Create upper and lower bound, i.e. ± 2 seconds around t
+        let tMin = t.addingTimeInterval(-2)
+        let tMax = t.addingTimeInterval(2)
+
+        return mealInputs.contains { input in
+            // Timestamp close enough?
+            guard input.timestamp >= tMin, input.timestamp <= tMax else {
+                return false
+            }
+
+            // Check the property name
+            switch propName {
+            case "carbs":
+                return input.carbs != nil
+            case "bolus":
+                return input.bolus != nil
+            default:
+                return false
+            }
+        }
+    }
+
+    // the overall function signature (from oref) should be this one:
+    //    static func findMealInputs(
+    //        pumpHistory: [PumpHistoryEvent],
+    //        profile _: Profile,
+    //        basalProfile _: [BasalProfileEntry],
+    //        clock _: Date,
+    //        carbHistory: [CarbsEntry],
+    //        glucoseHistory _: [BloodGlucose]
+    //    ) -> [MealInput] {
+    // however, we only require pumpHistory and carbHistory, so omiting the unused parameters
+    static func findMealInputs(
+        pumpHistory: [PumpHistoryEvent],
+        carbHistory: [CarbsEntry]
+    ) -> [MealInput] {
+        var mealInputs: [MealInput] = []
+        var duplicates = 0
+
+        // Process carbHistory
+        for current in carbHistory {
+            // The JS code checks `if (current.carbs && current.created_at)`
+            // In Swift, that's basically "non-nil carbs" and we rely on the type's Date.
+            if current.carbs > 0 {
+                let temp = MealInput(
+                    timestamp: current.createdAt,
+                    carbs: current.carbs,
+                    nsCarbs: current.carbs,
+                    bolus: nil,
+                    journalCarbs: nil,
+                    bwCarbs: nil
+                )
+
+                if !arrayHasElementWithSameTimestampAndProperty(
+                    mealInputs: mealInputs,
+                    t: current.createdAt,
+                    propName: "carbs"
+                ) {
+                    mealInputs.append(temp)
+                } else {
+                    duplicates += 1
+                }
+            }
+        }
+
+        // Process pumpHistory
+        for current in pumpHistory {
+            // bolus event handling
+            if current.type == .bolus, let amt = current.amount {
+                let temp = MealInput(
+                    timestamp: current.timestamp,
+                    carbs: nil,
+                    nsCarbs: nil,
+                    bolus: amt,
+                    journalCarbs: nil,
+                    bwCarbs: nil
+                )
+
+                if !arrayHasElementWithSameTimestampAndProperty(
+                    mealInputs: mealInputs,
+                    t: current.timestamp,
+                    propName: "bolus"
+                ) {
+                    mealInputs.append(temp)
+                } else {
+                    duplicates += 1
+                }
+            }
+            
+            // Trio will never send any pump history contents of the following types to oref
+            // Ignoring for JavaScript -> Swift port.
+            // .bolusWizard
+            // .mealBolus
+            // .correctionBolus
+            // .snackBolus
+            // .nsCarbCorrection
+            // .journalCarbs
+            // and the `carbsVal = current.carbInput` handling
+        }
+
+        // We can also omit the deferred bolus wizard input processing
+        // TODO: log duplicates?
+
+        return mealInputs
+    }
+}

+ 155 - 0
Trio/Sources/APS/OpenAPSSwift/Meal/MealTotal.swift

@@ -0,0 +1,155 @@
+import Foundation
+
+struct ComputedCarbs {
+    var carbs: Decimal
+    var nsCarbs: Decimal
+    var bwCarbs: Decimal
+    var journalCarbs: Decimal
+    var mealCOB: Decimal
+    var currentDeviation: Decimal
+    var maxDeviation: Decimal
+    var minDeviation: Decimal
+    var slopeFromMaxDeviation: Decimal
+    var slopeFromMinDeviation: Decimal
+    var allDeviations: [Decimal]
+    var lastCarbTime: TimeInterval
+    var bwFound: Bool
+}
+
+struct IOBInput {
+    let profile: Profile
+    let history: [PumpHistoryEvent]
+}
+
+struct COBInputs {
+    let glucoseData: [BloodGlucose]
+    let iobInputs: IOBInput
+    let basalProfile: [BasalProfileEntry]
+    var mealTime: TimeInterval?
+    var ciTime: TimeInterval?
+}
+
+enum MealTotal {
+    static func recentCarbs(treatments: [MealInput], pumpHistory: [PumpHistoryEvent], profile: Profile, basalProfile: [BasalProfileEntry], glucose: [BloodGlucose], time: Date) -> ComputedCarbs? {
+        guard treatments.isNotEmpty else { return nil }
+        
+        var _treatments = treatments
+        var carbs: Decimal = Decimal(0)
+        var nsCarbs: Decimal = Decimal(0)
+        var bwCarbs: Decimal = Decimal(0)
+        var journalCarbs: Decimal = Decimal(0)
+        let mealCarbTime: TimeInterval = time.timeIntervalSince1970
+        var lastCarbTime: TimeInterval = 0
+        var bwFound: Bool = false
+        
+        let iobInputs = IOBInput(profile: profile, history: pumpHistory)
+        var cobInputs = COBInputs(glucoseData: glucose, iobInputs: iobInputs, basalProfile: basalProfile, mealTime: mealCarbTime)
+        var mealCOB: Decimal = Decimal(0)
+
+        
+        _treatments.sort(by: {
+            $0.timestamp > $1.timestamp
+        })
+        
+        var carbsToRemove: Decimal = Decimal(0)
+           var nsCarbsToRemove: Decimal = Decimal(0)
+           var bwCarbsToRemove: Decimal = Decimal(0)
+           var journalCarbsToRemove: Decimal = Decimal(0)
+        
+        for treatment in _treatments {
+            let now = time.timeIntervalSince1970
+    
+           // Use new maxMealAbsorptionTime setting here instead of default 6 hrs
+            var carbWindow = now - TimeInterval(hours: Double(truncating: profile.maxMealAbsorptionTime as NSNumber))
+            
+            let treatmentDate = treatment.timestamp
+            let treatmentTime = treatmentDate.timeIntervalSince1970
+            
+            if (treatmentTime > carbWindow && treatmentTime <= now) {
+                
+                if var _carbs = treatment.carbs, carbs >= 1 {
+                    if var _nsCarbs = treatment.nsCarbs, nsCarbs >= 1 {
+                        nsCarbs += _nsCarbs
+                    } else if var _bwCarbs = treatment.bwCarbs, bwCarbs >= 1 {
+                        bwCarbs += _bwCarbs
+                        bwFound = true
+                    } else if var _journalCarbs = treatment.journalCarbs, journalCarbs >= 1 {
+                        journalCarbs += _journalCarbs
+                    } else {
+                        print("Treatment carbs unclassified: \(treatment)")
+                    }
+                    
+                    carbs += _carbs
+                    
+                    cobInputs.mealTime = treatmentTime
+                    lastCarbTime = max(lastCarbTime,treatmentTime)
+                    
+                    let myCarbsAbsorbed = Decimal(0) // TODO: call perted cob method here
+                    
+                    // TODO: add logging?
+                    let myMealCOB = max(0, carbs - myCarbsAbsorbed)
+                    mealCOB = max(mealCOB, myMealCOB)
+                    
+                    if myMealCOB < mealCOB {
+                        carbsToRemove += treatment.carbs ?? 0
+                        if var _nsCarbs = treatment.nsCarbs, nsCarbs >= 1 {
+                            nsCarbsToRemove += _nsCarbs
+                        } else if var _bwCarbs = treatment.bwCarbs, bwCarbs >= 1 {
+                            bwCarbsToRemove += _bwCarbs
+                        } else if var _journalCarbs = treatment.journalCarbs, journalCarbs >= 1 {
+                            journalCarbsToRemove += _journalCarbs
+                        }
+                    } else {
+                        carbsToRemove = 0;
+                        nsCarbsToRemove = 0;
+                        bwCarbsToRemove = 0;
+                    }
+                }
+            }
+        }
+        
+        // only include carbs actually used in calculating COB
+        carbs -= carbsToRemove
+        nsCarbs -= nsCarbsToRemove
+        bwCarbs -= bwCarbsToRemove
+        journalCarbs -= journalCarbsToRemove
+        
+        // calculate the current deviation and steepest deviation downslope over the last hour
+        cobInputs.ciTime = time.timeIntervalSince1970
+        cobInputs.mealTime = TimeInterval(hours: Double(truncating: profile.maxMealAbsorptionTime as NSNumber))
+        
+        // set a hard upper limit on COB to mitigate impact of erroneous or malicious carb entry
+        mealCOB = min(profile.maxCOB, mealCOB)
+        /// omiting maxCOB check here, the setting is not Optional in Swift and must be part of profile
+    
+        // if currentDeviation is null or maxDeviation is 0, set mealCOB to 0 for zombie-carb safety
+        // TODO: make these adjustments once we have cob.js ported
+//        if (typeof(c.currentDeviation) === 'undefined' || c.currentDeviation === null) {
+//            console.error("");
+//            console.error("Warning: setting mealCOB to 0 because currentDeviation is null/undefined");
+//            mealCOB = 0;
+//        }
+//        if (typeof(c.maxDeviation) === 'undefined' || c.maxDeviation === null) {
+//            console.error("");
+//            console.error("Warning: setting mealCOB to 0 because maxDeviation is 0 or undefined");
+//            mealCOB = 0;
+//        }
+        
+        return ComputedCarbs(
+            carbs: carbs,
+            nsCarbs: nsCarbs,
+            bwCarbs: bwCarbs,
+            journalCarbs: journalCarbs,
+            mealCOB: mealCOB,
+            currentDeviation: 0,
+            maxDeviation: 0,
+            minDeviation: 0,
+            slopeFromMaxDeviation: 0,
+            slopeFromMinDeviation: 0,
+            allDeviations: [],
+            lastCarbTime: lastCarbTime,
+            bwFound: bwFound
+        )
+    }
+}
+