Просмотр исходного кода

add a optional filter of BG EXPERIMENTAL + correction of mininmalDose (#616)

* Allow to delete Carbs when connexion with NS is impossible - Alert the user of the issue.

* synchronise upload readings toggle in dexcom settings with FAX settings

* Update the calculation of basal insulin for Apple Health with the same formula as TDD

* add a optional filter of BG - Based on the https://en.wikipedia.org/wiki/Savitzky–Golay_filter developed by xDrip4IOS.

* add unsmoothed BG visualisation in the graph (for testing the smooth algorithm).

* add storage of BG in SVG for GlucoseSimulatorSource

* For AH, same minimalDose than TDD (0.05)
Pierre L 3 лет назад
Родитель
Сommit
84bf77eb05

+ 4 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -308,6 +308,7 @@
 		CE79502F29980E5800FA576E /* ShareClientUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE79502D29980E4D00FA576E /* ShareClientUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02428E867BA00473A9C /* AlertStorage.swift */; };
 		CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82E02628E869DF00473A9C /* AlertEntry.swift */; };
+		CEA4F62329BE10F70011ADF7 /* SavitzkyGolayFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */; };
 		CEB434DC28B8F5B900B70274 /* MKRingProgressView.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEB434DB28B8F5B900B70274 /* MKRingProgressView.framework */; };
 		CEB434DD28B8F5B900B70274 /* MKRingProgressView.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CEB434DB28B8F5B900B70274 /* MKRingProgressView.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		CEB434DF28B8F5C400B70274 /* OmniBLE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEB434DE28B8F5C400B70274 /* OmniBLE.framework */; };
@@ -766,6 +767,7 @@
 		CE79502D29980E4D00FA576E /* ShareClientUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ShareClientUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CE82E02428E867BA00473A9C /* AlertStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStorage.swift; sourceTree = "<group>"; };
 		CE82E02628E869DF00473A9C /* AlertEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertEntry.swift; sourceTree = "<group>"; };
+		CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavitzkyGolayFilter.swift; sourceTree = "<group>"; };
 		CEB434DB28B8F5B900B70274 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434DE28B8F5C400B70274 /* OmniBLE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmniBLE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = "<group>"; };
@@ -1451,6 +1453,7 @@
 				CEB434E428B8FF5D00B70274 /* UIColor.swift */,
 				FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */,
 				FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */,
+				CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */,
 			);
 			path = Helpers;
 			sourceTree = "<group>";
@@ -2271,6 +2274,7 @@
 				38C4D33725E9A1A300D30B77 /* DispatchQueue+Extensions.swift in Sources */,
 				F90692CF274B999A0037068D /* HealthKitDataFlow.swift in Sources */,
 				3862CC2E2743F9F700BF832C /* CalendarManager.swift in Sources */,
+				CEA4F62329BE10F70011ADF7 /* SavitzkyGolayFilter.swift in Sources */,
 				38B4F3C325E2A20B00E76A18 /* PumpSetupView.swift in Sources */,
 				38E4453C274E411700EC9A94 /* Disk+Codable.swift in Sources */,
 				382C134B25F14E3700715CE1 /* BGTargets.swift in Sources */,

+ 2 - 2
FreeAPS/Sources/APS/CGM/GlucoseSimulatorSource.swift

@@ -136,7 +136,7 @@ class IntelligentGenerator: BloodGlucoseGenerator {
         trandsStepDirection = getDirection(fromGlucose: previousGlucose, toGlucose: currentGlucose).rawValue
         let glucose = BloodGlucose(
             _id: UUID().uuidString,
-            sgv: nil,
+            sgv: currentGlucose,
             direction: BloodGlucose.Direction(rawValue: trandsStepDirection),
             date: Decimal(Int(date.timeIntervalSince1970) * 1000),
             dateString: date,
@@ -183,7 +183,7 @@ class IntelligentGenerator: BloodGlucoseGenerator {
 
     private func makeStepInTrend() {
         currentGlucose +=
-            Int(Double((trendTargetValue - currentGlucose) / trendStepsLeft) * [0.3, 0.6, 1, 1.3, 1.6].randomElement()!)
+        Int(Double((trendTargetValue - currentGlucose) / trendStepsLeft) * [0.3, 0.6, 1, 1.3, 1.6, 2.0].randomElement()!)
         trendStepsLeft -= 1
         if trendStepsLeft == 0 {
             generateNewTrend()

+ 15 - 0
FreeAPS/Sources/APS/FetchGlucoseManager.swift

@@ -130,6 +130,21 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         }
         debug(.deviceManager, "New glucose found")
 
+        // filter the data if it is the case
+        if settingsManager.settings.smoothGlucose {
+            // limit to 30 minutes of previous BG Data
+            let oldGlucoses = glucoseStorage.recent().filter {
+                $0.dateString.addingTimeInterval(31 * 60) > Date()
+            }
+            var smoothedValues = oldGlucoses + filtered
+            // smooth with 3 repeats
+            for _ in 1 ... 3 {
+                smoothedValues.smoothSavitzkyGolayQuaDratic(withFilterWidth: 3)
+            }
+            // find the new values only
+            filtered = smoothedValues.filter { $0.dateString > syncDate }
+        }
+
         glucoseStorage.storeGlucose(filtered)
 
         deviceDataManager.heartbeat(date: Date())

+ 172 - 0
FreeAPS/Sources/Helpers/SavitzkyGolayFilter.swift

@@ -0,0 +1,172 @@
+import Foundation
+
+/// allowed values are 0, 1, 2 or 3. It's the index in coefficients
+private var coefficientsRowToUse = 3
+
+/// Savitzky Golay coefficients
+private let coefficients = [
+    [-3.0, 12.0, 17.0, 12.0, -3.0],
+    [-2.0, 3.0, 6.0, 7.0, 6.0, 3.0, -2.0],
+    [-21.0, 14.0, 39.0, 54.0, 59.0, 54.0, 39.0, 14.0, -21.0],
+    [-36.0, 9.0, 44.0, 69.0, 84.0, 89.0, 84.0, 69.0, 44.0, 9.0, -36.0]
+]
+
+/// an array with elements of a type that conforms to Smoothable, can be filtered using  the Savitzky Golay algorithm
+protocol SavitzkyGolaySmoothable {
+    /// value to be smoothed
+    var value: Double { get set }
+}
+
+/// local help class
+private class IsSmoothable: SavitzkyGolaySmoothable {
+    var value: Double = 0.0
+
+    init(withValue value: Double = 0.0) {
+        self.value = value
+    }
+}
+
+extension Array where Element: SavitzkyGolaySmoothable {
+    /// - apply Savitzky Golay filter
+    /// - before applying the filter, the array will be prepended and append with a number of elements equal to the filterwidth, filterWidth default 5. Allowed values are 5, 4, 3, 2. If any other value is assigned, then 5 will be used
+    /// - ...continue with 5 here in the explanation ...
+    /// - for the 5 last elements and 5 first elements, a regression is done. This regression is done used to give values to the 5 prepended and appended values. Which means it's as if we draw a line through the first 5 and 5 last original values, and use this line to give values to the 5 prepended and appended values
+    /// - the 5 prepended and appended values are then used in the filter algorithm, which means we can also filter the original 5 first and last elements
+    /// see also example https://github.com/JohanDegraeve/xdripswift/wiki/Libre-value-smoothing
+    mutating func smoothSavitzkyGolayQuaDratic(withFilterWidth filterWidth: Int = 5) {
+        // filterWidthToUse is the value of filterWidth to use in the algorithm. By default filterWidthToUse = parameter value filterWidth
+        var filterWidthToUse = filterWidth
+
+        // calculate coefficientsRowToUse based on filterWdith
+        switch filterWidth {
+        case 5:
+            coefficientsRowToUse = 3
+
+        case 4:
+            coefficientsRowToUse = 2
+
+        case 3:
+            coefficientsRowToUse = 1
+
+        case 2:
+            coefficientsRowToUse = 0
+
+        default:
+            // invalid filterWidth was given in parameterList, use default value
+            coefficientsRowToUse = 3
+
+            filterWidthToUse = 5
+        }
+
+        // using 5 here in the comments as value for filterWidthToUse
+
+        // the amount of elements must be at least 5. If that's not the case then don't apply any smoothing
+        guard count >= filterWidthToUse else { return }
+
+        // create a new array, to which we will prepend and append 5 elements so that we can do also smoothing for the 5 last and 5 first values of the input array (which is self)
+        // the 5 elements will be estimated by doing linear regression of the first 5 and last 5 elements of the original input array respectively
+        // this is only a temporary array, but it will hold the elements of the original array, those elements will get a new value when doing the smoothing
+        var tempArray = [SavitzkyGolaySmoothable]()
+        for element in self {
+            tempArray.append(element)
+        }
+
+        // now prepend and append with 5 elements, each with a default value 0.0
+        for _ in 0 ..< filterWidthToUse {
+            tempArray.insert(IsSmoothable(), at: 0)
+            tempArray.append(IsSmoothable())
+        }
+
+        // so now we have tempArray, of length size of original array + 2 * 5
+        // the first 5 and the last 5 elements are of type IsSmoothable with value 0
+
+        // - indicesArray is a help array needed for the function linearRegressionCreator
+        // - this will be the first parameter in the call to the linearRegression function, in fact it's an array of IsSmoothable with length = length of tempArray
+        // - we give each IsSmoothable the value of the index, meaning from 0 up to (length of tempArray) - 1
+        // - in fact it's not really smoothable, it's just because we use isSmoothable in function linearRegressionCreator
+        var indicesArray = [SavitzkyGolaySmoothable]()
+        for index in 0 ..< (count + (filterWidthToUse * 2)) {
+            indicesArray.append(IsSmoothable(withValue: Double(index)))
+        }
+
+        /// - this is a piece of code that we will execute two times, once for the firs 5 elements, then for the last 5, so we put it in a closure variable
+        /// - it calculates the regression function (which is nothing else but doing y = intercept + slope*x) for range defined by predictorRange in tempArray. It will be used for the 5 first and 5 last real values, ie the 5 first and 5 last real glucose values
+        /// - then executes the regression for every element in the range defined by targetRange, again in tempArray
+        let doRegression = { (predictorRange: Range<Int>, targetRange: Range<Int>) in
+
+            // calculate the linearRegression function
+            let linearRegression = linearRegressionCreator(indicesArray[predictorRange], tempArray[predictorRange])
+
+            // ready to do the linear regression for the targetRange in tempArray
+            for index in targetRange {
+                tempArray[index].value = linearRegression(indicesArray[index].value)
+            }
+        }
+
+        // now do the regression for the 5 first elements
+        doRegression(filterWidthToUse ..< (filterWidthToUse * 2), 0 ..< filterWidthToUse)
+
+        // now do the regression for the 5 last elements
+        doRegression(
+            (tempArray.count - filterWidthToUse * 2) ..< (tempArray.count - filterWidthToUse),
+            (tempArray.count - filterWidthToUse) ..< tempArray.count
+        )
+
+        // now start filtering
+
+        // initialize array that will hold the resulting filtered values
+        var filteredValues = [Double]()
+
+        // calculate divider
+        let divider = coefficients[coefficientsRowToUse].reduce(0, { x, y in
+            x + y
+        })
+
+        // filter each original value
+        for _ in 0 ..< count {
+            // add a new element to filteredValues, start value is 0.0
+            // this new value will be the last element, so we access it with index filteredValues.count - 1
+            filteredValues.append(0.0)
+
+            // iterate through the coefficients
+            for (index, coefficient) in coefficients[coefficientsRowToUse].enumerated() {
+                filteredValues[filteredValues.count - 1] = filteredValues[filteredValues.count - 1] + coefficient *
+                    tempArray[index + filteredValues.count - 1].value
+            }
+
+            filteredValues[filteredValues.count - 1] = filteredValues[filteredValues.count - 1] / divider
+        }
+
+        // now assign the new values to the original objects
+        for (index, _) in enumerated() {
+            self[index].value = filteredValues[index]
+        }
+    }
+}
+
+/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
+private func multiply(
+    _ a: ArraySlice<SavitzkyGolaySmoothable>,
+    _ b: ArraySlice<SavitzkyGolaySmoothable>
+) -> ArraySlice<SavitzkyGolaySmoothable> {
+    zip(a, b).map({ IsSmoothable(withValue: $0.value * $1.value) })[0 ..< a.count]
+}
+
+/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
+private func average(_ input: ArraySlice<SavitzkyGolaySmoothable>) -> Double {
+    (input.reduce(IsSmoothable(), { (x: SavitzkyGolaySmoothable, y: SavitzkyGolaySmoothable) in
+        IsSmoothable(withValue: x.value + y.value) })).value / Double(input.count)
+}
+
+/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
+private func linearRegressionCreator(
+    _ xs: ArraySlice<SavitzkyGolaySmoothable>,
+    _ ys: ArraySlice<SavitzkyGolaySmoothable>
+) -> (Double) -> Double {
+    let sum1 = average(multiply(ys, xs)) - average(xs) * average(ys)
+    let sum2 = average(multiply(xs, xs)) - pow(average(xs), 2)
+    let slope = sum1 / sum2
+    let intercept = average(ys) - slope * average(xs)
+
+    return { x in intercept + slope * x }
+}

+ 11 - 0
FreeAPS/Sources/Models/BloodGlucose.swift

@@ -79,3 +79,14 @@ extension Double {
         Decimal(self) / GlucoseUnits.exchangeRate
     }
 }
+
+extension BloodGlucose: SavitzkyGolaySmoothable {
+    var value: Double {
+        get {
+            Double(glucose ?? 0)
+        }
+        set {
+            glucose = Int(newValue)
+        }
+    }
+}

+ 5 - 0
FreeAPS/Sources/Models/FreeAPSSettings.swift

@@ -30,6 +30,7 @@ struct FreeAPSSettings: JSON, Equatable {
     var timeCap: Int = 8
     var minuteInterval: Int = 30
     var delay: Int = 60
+    var smoothGlucose: Bool = false
 }
 
 extension FreeAPSSettings: Decodable {
@@ -157,6 +158,10 @@ extension FreeAPSSettings: Decodable {
             settings.displayStatistics = displayStatistics
         }
 
+        if let smoothGlucose = try? container.decode(Bool.self, forKey: .smoothGlucose) {
+            settings.smoothGlucose = smoothGlucose
+        }
+
         self = settings
     }
 }

+ 3 - 0
FreeAPS/Sources/Modules/CGM/CGMStateModel.swift

@@ -14,6 +14,7 @@ extension CGM {
         @Published var cgm: CGMType = .nightscout
         // @Published var transmitterID = ""
         @Published var uploadGlucose = false
+        @Published var smoothGlucose = false
         @Published var createCalendarEvents = false
         @Published var calendarIDs: [String] = []
         @Published var currentCalendarID: String = ""
@@ -39,6 +40,8 @@ extension CGM {
                 }
             })
 
+            subscribeSetting(\.smoothGlucose, on: $smoothGlucose, initial: { smoothGlucose = $0 })
+
             $cgm
                 .removeDuplicates()
                 .sink { [weak self] value in

+ 4 - 0
FreeAPS/Sources/Modules/CGM/View/CGMRootView.swift

@@ -67,6 +67,10 @@ extension CGM {
                     Section(header: Text("Other")) {
                         Toggle("Upload glucose to Nightscout", isOn: $state.uploadGlucose)
                     }
+
+                    Section(header: Text("Experimental")) {
+                        Toggle("Smooth Glucose Value", isOn: $state.smoothGlucose)
+                    }
                 }
 
                 .onAppear(perform: configureView)

+ 3 - 0
FreeAPS/Sources/Modules/Home/HomeStateModel.swift

@@ -53,6 +53,7 @@ extension Home {
         @Published var alarm: GlucoseAlarm?
         @Published var animatedBackground = false
         @Published var manualTempBasal = false
+        @Published var smooth = false
 
         override func subscribe() {
             setupGlucose()
@@ -83,6 +84,7 @@ extension Home {
             manualTempBasal = apsManager.isManualTempBasal
             setStatusTitle()
             setupCurrentTempTarget()
+            smooth = settingsManager.settings.smoothGlucose
 
             broadcaster.register(GlucoseObserver.self, observer: self)
             broadcaster.register(SuggestionObserver.self, observer: self)
@@ -386,6 +388,7 @@ extension Home.StateModel:
         units = settingsManager.settings.units
         animatedBackground = settingsManager.settings.animatedBackground
         manualTempBasal = apsManager.isManualTempBasal
+        smooth = settingsManager.settings.smoothGlucose
         setupGlucose()
         setupStatistics()
     }

+ 48 - 0
FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -50,9 +50,11 @@ struct MainChartView: View {
     @Binding var carbs: [CarbsEntry]
     @Binding var timerDate: Date
     @Binding var units: GlucoseUnits
+    @Binding var smooth: Bool
 
     @State var didAppearTrigger = false
     @State private var glucoseDots: [CGRect] = []
+    @State private var unSmoothedGlucoseDots: [CGRect] = []
     @State private var predictionDots: [PredictionType: [CGRect]] = [:]
     @State private var bolusDots: [DotInfo] = []
     @State private var bolusPath = Path()
@@ -249,6 +251,7 @@ struct MainChartView: View {
                     carbsView(fullSize: fullSize)
                     fpuView(fullSize: fullSize)
                     bolusView(fullSize: fullSize)
+                    if smooth { unSmoothedGlucoseView(fullSize: fullSize) }
                     glucoseView(fullSize: fullSize)
                     predictionsView(fullSize: fullSize)
                 }
@@ -320,6 +323,27 @@ struct MainChartView: View {
         }
     }
 
+    private func unSmoothedGlucoseView(fullSize: CGSize) -> some View {
+        Path { path in
+            var lines: [CGPoint] = []
+            for rect in unSmoothedGlucoseDots {
+                lines.append(CGPoint(x: rect.midX, y: rect.midY))
+                path.addEllipse(in: rect)
+            }
+            path.addLines(lines)
+        }
+        .stroke(Color.loopGray, lineWidth: 0.5)
+        .onChange(of: glucose) { _ in
+            update(fullSize: fullSize)
+        }
+        .onChange(of: didAppearTrigger) { _ in
+            update(fullSize: fullSize)
+        }
+        .onReceive(Foundation.NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
+            update(fullSize: fullSize)
+        }
+    }
+
     private func bolusView(fullSize: CGSize) -> some View {
         ZStack {
             bolusPath
@@ -445,6 +469,7 @@ extension MainChartView {
         calculatePredictionDots(fullSize: fullSize, type: .zt)
         calculatePredictionDots(fullSize: fullSize, type: .uam)
         calculateGlucoseDots(fullSize: fullSize)
+        calculateUnSmoothedGlucoseDots(fullSize: fullSize)
         calculateBolusDots(fullSize: fullSize)
         calculateCarbsDots(fullSize: fullSize)
         calculateFPUsDots(fullSize: fullSize)
@@ -469,6 +494,22 @@ extension MainChartView {
         }
     }
 
+    private func calculateUnSmoothedGlucoseDots(fullSize: CGSize) {
+        calculationQueue.async {
+            let dots = glucose.concurrentMap { value -> CGRect in
+                let position = UnSmoothedGlucoseToCoordinate(value, fullSize: fullSize)
+                return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
+            }
+
+            let range = self.getGlucoseYRange(fullSize: fullSize)
+
+            DispatchQueue.main.async {
+                glucoseYGange = range
+                unSmoothedGlucoseDots = dots
+            }
+        }
+    }
+
     private func calculateBolusDots(fullSize: CGSize) {
         calculationQueue.async {
             let dots = boluses.map { value -> DotInfo in
@@ -879,6 +920,13 @@ extension MainChartView {
         return CGPoint(x: x, y: y)
     }
 
+    private func UnSmoothedGlucoseToCoordinate(_ glucoseEntry: BloodGlucose, fullSize: CGSize) -> CGPoint {
+        let x = timeToXCoordinate(glucoseEntry.dateString.timeIntervalSince1970, fullSize: fullSize)
+        let y = glucoseToYCoordinate(glucoseEntry.sgv ?? glucoseEntry.glucose ?? 0, fullSize: fullSize)
+
+        return CGPoint(x: x, y: y)
+    }
+
     private func predictionToCoordinate(_ pred: Int, fullSize: CGSize, index: Int) -> CGPoint {
         guard let deliveredAt = suggestion?.deliverAt else {
             return .zero

+ 2 - 1
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -566,7 +566,8 @@ extension Home {
                     tempTargets: $state.tempTargets,
                     carbs: $state.carbs,
                     timerDate: $state.timerDate,
-                    units: $state.units
+                    units: $state.units,
+                    smooth: $state.smooth
                 )
             }
             .padding(.bottom)

+ 1 - 1
FreeAPS/Sources/Services/HealthKit/HealthKitManager.swift

@@ -283,7 +283,7 @@ final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver {
 
                         var minimalDose = self.settingsManager.preferences.bolusIncrement
                         if (minimalDose != 0.05) || (minimalDose != 0.025) {
-                            minimalDose = Decimal(0.1)
+                            minimalDose = Decimal(0.05)
                         }
 
                         let nextBasalEvent = basalEvents[nextElementEventIndex]