Przeglądaj źródła

add calibration of Libre CGM

Add calibration functions allowing to transform a BG to a corrected BG with a linear function.
Add interface to manage calibrations points
add calibration to calculate a new value of BG
add notification to remove calibration when change CGM or sensors

This functionality is activated only for Libre CGM even could be use for all CGM.

(cherry picked from commit 6bdd3a21729b992ebbdc6709e3f62738659ac8ff)
Pierre L 2 lat temu
rodzic
commit
491ddf29c4

+ 52 - 6
FreeAPS.xcodeproj/project.pbxproj

@@ -248,8 +248,6 @@
 		69B9A368029F7EB39F525422 /* CREditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA5E04A2761F6EEA6568E1 /* CREditorStateModel.swift */; };
 		6B1F539F9FF75646D1606066 /* SnoozeDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36A708CDB546692C2230B385 /* SnoozeDataFlow.swift */; };
 		6B9625766B697D1C98E455A2 /* PumpSettingsEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72778B68C3004F71F6E79BDC /* PumpSettingsEditorStateModel.swift */; };
-		6BCF84DD2B16843A003AD46E /* LiveActitiyShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF84DC2B16843A003AD46E /* LiveActitiyShared.swift */; };
-		6BCF84DE2B16843A003AD46E /* LiveActitiyShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF84DC2B16843A003AD46E /* LiveActitiyShared.swift */; };
 		6FFAE524D1D9C262F2407CAE /* SnoozeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAE81192B118804DCD23034 /* SnoozeProvider.swift */; };
 		711C0CB42CAABE788916BC9D /* ManualTempBasalDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96653287EDB276A111288305 /* ManualTempBasalDataFlow.swift */; };
 		72F1BD388F42FCA6C52E4500 /* ConfigEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44080E4709E3AE4B73054563 /* ConfigEditorProvider.swift */; };
@@ -273,8 +271,6 @@
 		B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */ = {isa = PBXBuildFile; productRef = B958F1B62BA0711600484851 /* MKRingProgressView */; };
 		B9CAAEFC2AE70836000F68BC /* branch.txt in Resources */ = {isa = PBXBuildFile; fileRef = B9CAAEFB2AE70836000F68BC /* branch.txt */; };
 		BA00D96F7B2FF169A06FB530 /* CGMStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C018D1680307A31C9ED7120 /* CGMStateModel.swift */; };
-		BD188BEC2B1B805B00B183BF /* WidgetBobble.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD188BEB2B1B805A00B183BF /* WidgetBobble.swift */; };
-		BD188BED2B1B805B00B183BF /* WidgetBobble.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD188BEB2B1B805A00B183BF /* WidgetBobble.swift */; };
 		BD2B464E0745FBE7B79913F4 /* NightscoutConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */; };
 		BF1667ADE69E4B5B111CECAE /* ManualTempBasalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680C4420C9A345D46D90D06C /* ManualTempBasalProvider.swift */; };
 		C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */; };
@@ -328,6 +324,13 @@
 		CEB434E728B9053300B70274 /* LoopUIColorPalette+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E628B9053300B70274 /* LoopUIColorPalette+Default.swift */; };
 		CEB434FD28B90B7C00B70274 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = CEB434FC28B90B7C00B70274 /* SwiftCharts */; };
 		CEB434FE28B90B8C00B70274 /* SwiftCharts in Embed Frameworks */ = {isa = PBXBuildFile; productRef = CEB434FC28B90B7C00B70274 /* SwiftCharts */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
+		CEE9A6552BBB418300EB5194 /* CalibrationsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A64F2BBB418300EB5194 /* CalibrationsProvider.swift */; };
+		CEE9A6562BBB418300EB5194 /* CalibrationsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A6512BBB418300EB5194 /* CalibrationsRootView.swift */; };
+		CEE9A6572BBB418300EB5194 /* CalibrationsChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A6522BBB418300EB5194 /* CalibrationsChart.swift */; };
+		CEE9A6582BBB418300EB5194 /* CalibrationsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A6532BBB418300EB5194 /* CalibrationsStateModel.swift */; };
+		CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A6542BBB418300EB5194 /* CalibrationsDataFlow.swift */; };
+		CEE9A65C2BBB41C800EB5194 /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A65B2BBB41C800EB5194 /* CalibrationService.swift */; };
+		CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */; };
 		D2165E9D78EFF692C1DED1C6 /* AddTempTargetDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8A42073A2D03A278914448 /* AddTempTargetDataFlow.swift */; };
 		D6D02515BBFBE64FEBE89856 /* DataTableRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 881E04BA5E0A003DE8E0A9C6 /* DataTableRootView.swift */; };
 		D6DEC113821A7F1056C4AA1E /* NightscoutConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2A13DF0EDEEEDC4106AA2A /* NightscoutConfigDataFlow.swift */; };
@@ -763,8 +766,6 @@
 		B9B5C0607505A38F256BF99A /* CGMDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CGMDataFlow.swift; sourceTree = "<group>"; };
 		B9CAAEFB2AE70836000F68BC /* branch.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = branch.txt; sourceTree = SOURCE_ROOT; };
 		BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorDataFlow.swift; sourceTree = "<group>"; };
-		BD188BEB2B1B805A00B183BF /* WidgetBobble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBobble.swift; sourceTree = "<group>"; };
-		BDF530D72B40F8AC002CAF43 /* LockScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenView.swift; sourceTree = "<group>"; };
 		BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorRootView.swift; sourceTree = "<group>"; };
 		C19984D62EFC0035A9E9644D /* BolusProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusProvider.swift; sourceTree = "<group>"; };
 		C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalRootView.swift; sourceTree = "<group>"; };
@@ -819,6 +820,13 @@
 		CEC751D329D88257006E9D24 /* OmniKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmniKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEC751D529D88262006E9D24 /* MinimedKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEC751D729D88262006E9D24 /* MinimedKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		CEE9A64F2BBB418300EB5194 /* CalibrationsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsProvider.swift; sourceTree = "<group>"; };
+		CEE9A6512BBB418300EB5194 /* CalibrationsRootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsRootView.swift; sourceTree = "<group>"; };
+		CEE9A6522BBB418300EB5194 /* CalibrationsChart.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsChart.swift; sourceTree = "<group>"; };
+		CEE9A6532BBB418300EB5194 /* CalibrationsStateModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsStateModel.swift; sourceTree = "<group>"; };
+		CEE9A6542BBB418300EB5194 /* CalibrationsDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsDataFlow.swift; sourceTree = "<group>"; };
+		CEE9A65B2BBB41C800EB5194 /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = "<group>"; };
+		CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationsTests.swift; sourceTree = "<group>"; };
 		CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalStateModel.swift; sourceTree = "<group>"; };
 		D0BDC6993C1087310EDFC428 /* CREditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CREditorRootView.swift; sourceTree = "<group>"; };
 		D295A3F870E826BE371C0BB5 /* AutotuneConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AutotuneConfigStateModel.swift; sourceTree = "<group>"; };
@@ -1086,6 +1094,7 @@
 		3811DE0325C9D31700A708ED /* Modules */ = {
 			isa = PBXGroup;
 			children = (
+				CEE9A64D2BBB411C00EB5194 /* Calibrations */,
 				190EBCC229FF134900BA767D /* StatConfig */,
 				CE94597C29E9E1CD0047C9C6 /* WatchConfig */,
 				19F95FF129F10F9C00314DDC /* Stat */,
@@ -1418,6 +1427,7 @@
 		3856933F270B57A00002C50D /* CGM */ = {
 			isa = PBXGroup;
 			children = (
+				CEE9A65A2BBB41AD00EB5194 /* Calibrations */,
 				F816825F28DB441800054060 /* BluetoothTransmitter.swift */,
 				F816825D28DB441200054060 /* HeartBeatManager.swift */,
 				38569346270B5DFB0002C50D /* AppGroupSource.swift */,
@@ -1758,6 +1768,7 @@
 				38FCF3F125E9028E0078B0D1 /* Info.plist */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
+				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
 			);
 			path = FreeAPSTests;
 			sourceTree = "<group>";
@@ -2022,6 +2033,34 @@
 			path = Bluetooth;
 			sourceTree = "<group>";
 		};
+		CEE9A64D2BBB411C00EB5194 /* Calibrations */ = {
+			isa = PBXGroup;
+			children = (
+				CEE9A6542BBB418300EB5194 /* CalibrationsDataFlow.swift */,
+				CEE9A64F2BBB418300EB5194 /* CalibrationsProvider.swift */,
+				CEE9A6532BBB418300EB5194 /* CalibrationsStateModel.swift */,
+				CEE9A6502BBB418300EB5194 /* View */,
+			);
+			path = Calibrations;
+			sourceTree = "<group>";
+		};
+		CEE9A6502BBB418300EB5194 /* View */ = {
+			isa = PBXGroup;
+			children = (
+				CEE9A6512BBB418300EB5194 /* CalibrationsRootView.swift */,
+				CEE9A6522BBB418300EB5194 /* CalibrationsChart.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
+		CEE9A65A2BBB41AD00EB5194 /* Calibrations */ = {
+			isa = PBXGroup;
+			children = (
+				CEE9A65B2BBB41C800EB5194 /* CalibrationService.swift */,
+			);
+			path = Calibrations;
+			sourceTree = "<group>";
+		};
 		D533BF261CDC1C3F871E7BFD /* NightscoutConfig */ = {
 			isa = PBXGroup;
 			children = (
@@ -2449,6 +2488,7 @@
 				383420D925FFEB3F002D46C1 /* Popup.swift in Sources */,
 				3811DE3025C9D49500A708ED /* HomeStateModel.swift in Sources */,
 				38BF021725E7CBBC00579895 /* PumpManagerExtensions.swift in Sources */,
+				CEE9A6552BBB418300EB5194 /* CalibrationsProvider.swift in Sources */,
 				19F95FF529F10FCF00314DDC /* StatProvider.swift in Sources */,
 				38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */,
 				19B0EF2128F6D66200069496 /* Statistics.swift in Sources */,
@@ -2469,14 +2509,17 @@
 				382C133725F13A1E00715CE1 /* InsulinSensitivities.swift in Sources */,
 				19D466A529AA2BD4004D5F33 /* FPUConfigProvider.swift in Sources */,
 				383948D625CD4D8900E91849 /* FileStorage.swift in Sources */,
+				CEE9A6572BBB418300EB5194 /* CalibrationsChart.swift in Sources */,
 				3811DE4125C9D4A100A708ED /* SettingsRootView.swift in Sources */,
 				38192E04261B82FA0094D973 /* ReachabilityManager.swift in Sources */,
 				38E44539274E411700EC9A94 /* Disk+UIImage.swift in Sources */,
 				388E595C25AD948C0019842D /* FreeAPSApp.swift in Sources */,
 				38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */,
 				38569348270B5DFB0002C50D /* GlucoseSource.swift in Sources */,
+				CEE9A6582BBB418300EB5194 /* CalibrationsStateModel.swift in Sources */,
 				CEB434E328B8F9DB00B70274 /* BluetoothStateManager.swift in Sources */,
 				3811DE4225C9D4A100A708ED /* SettingsDataFlow.swift in Sources */,
+				CEE9A6562BBB418300EB5194 /* CalibrationsRootView.swift in Sources */,
 				3811DE2525C9D48300A708ED /* MainRootView.swift in Sources */,
 				CE94598229E9E3D30047C9C6 /* WatchConfigProvider.swift in Sources */,
 				38E44535274E411700EC9A94 /* Disk+Data.swift in Sources */,
@@ -2525,6 +2568,7 @@
 				CE95BF5A2BA62E4A00DC3DE3 /* PluginSource.swift in Sources */,
 				3811DE5C25C9D4D500A708ED /* Formatters.swift in Sources */,
 				3871F39F25ED895A0013ECB5 /* Decimal+Extensions.swift in Sources */,
+				CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */,
 				3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */,
 				38E98A2925F52C9300C0CED0 /* Error+Extensions.swift in Sources */,
 				38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */,
@@ -2636,6 +2680,7 @@
 				69B9A368029F7EB39F525422 /* CREditorStateModel.swift in Sources */,
 				38E44538274E411700EC9A94 /* Disk+[Data].swift in Sources */,
 				98641AF4F92123DA668AB931 /* CREditorRootView.swift in Sources */,
+				CEE9A65C2BBB41C800EB5194 /* CalibrationService.swift in Sources */,
 				38E4453D274E411700EC9A94 /* Disk+Errors.swift in Sources */,
 				38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */,
 				CE7CA3542A064973004BE681 /* TempPresetsIntentRequest.swift in Sources */,
@@ -2743,6 +2788,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 			);

+ 119 - 0
FreeAPS/Sources/APS/CGM/Calibrations/CalibrationService.swift

@@ -0,0 +1,119 @@
+import Foundation
+import LibreTransmitter
+import Swinject
+
+struct Calibration: JSON, Hashable, Identifiable {
+    let x: Double
+    let y: Double
+    var date = Date()
+
+    static let zero = Calibration(x: 0, y: 0)
+
+    var id = UUID()
+}
+
+protocol CalibrationService {
+    var slope: Double { get }
+    var intercept: Double { get }
+    var calibrations: [Calibration] { get }
+
+    func addCalibration(_ calibration: Calibration)
+    func removeCalibration(_ calibration: Calibration)
+    func removeAllCalibrations()
+    func removeLast()
+
+    func calibrate(value: Int) -> Double
+}
+
+final class BaseCalibrationService: CalibrationService, Injectable {
+    private enum Config {
+        static let minSlope = 0.8
+        static let maxSlope = 1.25
+        static let minIntercept = -100.0
+        static let maxIntercept = 100.0
+        static let maxValue = 500.0
+        static let minValue = 0.0
+    }
+
+    @Injected() var storage: FileStorage!
+    @Injected() var notificationCenter: NotificationCenter!
+    private var lifetime = Lifetime()
+
+    private(set) var calibrations: [Calibration] = [] {
+        didSet {
+            storage.save(calibrations, as: OpenAPS.FreeAPS.calibrations)
+        }
+    }
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        calibrations = storage.retrieve(OpenAPS.FreeAPS.calibrations, as: [Calibration].self) ?? []
+        subscribe()
+    }
+
+    private func subscribe() {
+//        notificationCenter.publisher(for: .newSensorDetected)
+//            .sink { [weak self] _ in
+//                self?.removeAllCalibrations()
+//            }
+//            .store(in: &lifetime)
+    }
+
+    var slope: Double {
+        guard calibrations.count >= 2 else {
+            return 1
+        }
+
+        let xs = calibrations.map(\.x)
+        let ys = calibrations.map(\.y)
+        let sum1 = average(multiply(xs, ys)) - average(xs) * average(ys)
+        let sum2 = average(multiply(xs, xs)) - pow(average(xs), 2)
+        let slope = sum1 / sum2
+
+        return min(max(slope, Config.minSlope), Config.maxSlope)
+    }
+
+    var intercept: Double {
+        guard calibrations.count >= 1 else {
+            return 0
+        }
+        let xs = calibrations.map(\.x)
+        let ys = calibrations.map(\.y)
+
+        let intercept = average(ys) - slope * average(xs)
+
+        return min(max(intercept, Config.minIntercept), Config.maxIntercept)
+    }
+
+    func calibrate(value: Int) -> Double {
+        linearRegression(value)
+    }
+
+    func addCalibration(_ calibration: Calibration) {
+        calibrations.append(calibration)
+    }
+
+    func removeCalibration(_ calibration: Calibration) {
+        calibrations.removeAll { $0 == calibration }
+    }
+
+    func removeAllCalibrations() {
+        calibrations.removeAll()
+    }
+
+    func removeLast() {
+        calibrations.removeLast()
+    }
+
+    private func average(_ input: [Double]) -> Double {
+        input.reduce(0, +) / Double(input.count)
+    }
+
+    private func multiply(_ a: [Double], _ b: [Double]) -> [Double] {
+        zip(a, b).map(*)
+    }
+
+    private func linearRegression(_ x: Int) -> Double {
+        (intercept + slope * Double(x)).clamped(Config.minValue ... Config.maxValue)
+    }
+}

+ 8 - 1
FreeAPS/Sources/APS/CGM/PluginSource.swift

@@ -102,7 +102,13 @@ extension PluginSource: CGMManagerDelegate {
         dispatchPrecondition(condition: .onQueue(processQueue))
         // TODO: Events in APS ?
         // currently only display in log the date of the event
-        events.forEach { debug(.deviceManager, "events from CGM at \($0.date)") }
+        events.forEach { event in
+            debug(.deviceManager, "events from CGM at \(event.date)")
+
+            if event.type == .sensorStart {
+                self.glucoseManager?.removeCalibrations()
+            }
+        }
     }
 
     func startDateToFilterNewData(for _: CGMManager) -> Date? {
@@ -125,6 +131,7 @@ extension PluginSource: CGMManagerDelegate {
     }
 
     func cgmManager(_: CGMManager, didUpdate status: CGMManagerStatus) {
+        debug(.deviceManager, "DEBUG DID UPDATE STATE")
         processQueue.async {
             if self.cgmHasValidSensorSession != status.hasValidSensorSession {
                 self.cgmHasValidSensorSession = status.hasValidSensorSession

+ 33 - 4
FreeAPS/Sources/APS/FetchGlucoseManager.swift

@@ -11,6 +11,7 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     func refreshCGM()
     func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?)
     func deleteGlucoseSource()
+    func removeCalibrations()
     var glucoseSource: GlucoseSource! { get }
     var cgmManager: CGMManagerUI? { get }
     var cgmGlucoseSourceType: CGMType? { get set }
@@ -35,6 +36,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     @Injected() var healthKitManager: HealthKitManager!
     @Injected() var deviceDataManager: DeviceDataManager!
     @Injected() var pluginCGMManager: PluginManager!
+    @Injected() var calibrationService: CalibrationService!
 
     private var lifetime = Lifetime()
     private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval)
@@ -68,6 +70,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
 
     var glucoseSource: GlucoseSource!
 
+    func removeCalibrations() {
+        calibrationService.removeAllCalibrations()
+    }
+
     func deleteGlucoseSource() {
         cgmManager = nil
         updateGlucoseSource(
@@ -77,6 +83,11 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     }
 
     func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?) {
+        // if changed, remove all calibrations
+        if self.cgmGlucoseSourceType != cgmGlucoseSourceType || self.cgmGlucosePluginId != cgmGlucosePluginId {
+            removeCalibrations()
+        }
+
         self.cgmGlucoseSourceType = cgmGlucoseSourceType
         self.cgmGlucosePluginId = cgmGlucosePluginId
 
@@ -88,12 +99,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         if let manager = newManager
         {
             cgmManager = manager
+            removeCalibrations()
         } else if self.cgmGlucoseSourceType == .plugin, cgmManager == nil, let rawCGMManager = rawCGMManager {
             cgmManager = cgmManagerFromRawValue(rawCGMManager)
         }
-//        } else if self.cgmGlucoseSourceType == .plugin, self.cgmGlucosePluginId != , self.cgmGlucosePluginId != cgmManager?.pluginIdentifier  {
-//            cgmManager = nil
-//        }
 
         switch self.cgmGlucoseSourceType {
         case nil,
@@ -154,7 +163,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     }
 
     private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose], glucoseFromHealth: [BloodGlucose] = []) {
-        let allGlucose = glucose + glucoseFromHealth
+        // calibration add if required only for sensor
+        let newGlucose = overcalibrate(entries: glucose)
+
+        let allGlucose = newGlucose + glucoseFromHealth
         var filteredByDate: [BloodGlucose] = []
         var filtered: [BloodGlucose] = []
 
@@ -266,6 +278,23 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     func sourceInfo() -> [String: Any]? {
         glucoseSource.sourceInfo()
     }
+
+    private func overcalibrate(entries: [BloodGlucose]) -> [BloodGlucose] {
+        // overcalibrate
+        var overcalibration: ((Int) -> (Double))?
+        processQueue.sync { overcalibration = calibrationService.calibrate }
+
+        if let overcalibration = overcalibration {
+            return entries.map { entry in
+                var entry = entry
+                entry.glucose = Int(overcalibration(entry.glucose!))
+                entry.sgv = Int(overcalibration(entry.sgv!))
+                return entry
+            }
+        } else {
+            return entries
+        }
+    }
 }
 
 extension CGMManager {

+ 1 - 0
FreeAPS/Sources/Assemblies/APSAssembly.swift

@@ -10,5 +10,6 @@ final class APSAssembly: Assembly {
         container.register(FetchAnnouncementsManager.self) { r in BaseFetchAnnouncementsManager(resolver: r) }
         container.register(BluetoothStateManager.self) { r in BaseBluetoothStateManager(resolver: r) }
         container.register(PluginManager.self) { r in BasePluginManager(resolver: r) }
+        container.register(CalibrationService.self) { r in BaseCalibrationService(resolver: r) }
     }
 }

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

@@ -47,6 +47,12 @@ extension CGM {
                             }
                         }
                     }
+                    if state.cgmCurrent.type == .plugin && state.cgmCurrent.id.contains("Libre") {
+                        Section(header: Text("Calibrations")) {
+                            Text("Calibrations").navigationLink(to: .calibrations, from: self)
+                        }
+                    }
+
                     Section(header: Text("Calendar")) {
                         Toggle("Create events in calendar", isOn: $state.createCalendarEvents)
                         if state.calendarIDs.isNotEmpty {

+ 13 - 0
FreeAPS/Sources/Modules/Calibrations/CalibrationsDataFlow.swift

@@ -0,0 +1,13 @@
+enum Calibrations {
+    enum Config {}
+
+    struct Item: Hashable, Identifiable {
+        let calibration: Calibration
+
+        var id: String {
+            calibration.id.uuidString
+        }
+    }
+}
+
+protocol CalibrationsProvider {}

+ 3 - 0
FreeAPS/Sources/Modules/Calibrations/CalibrationsProvider.swift

@@ -0,0 +1,3 @@
+extension Calibrations {
+    final class Provider: BaseProvider, CalibrationsProvider {}
+}

+ 73 - 0
FreeAPS/Sources/Modules/Calibrations/CalibrationsStateModel.swift

@@ -0,0 +1,73 @@
+import SwiftDate
+import SwiftUI
+
+extension Calibrations {
+    final class StateModel: BaseStateModel<Provider> {
+        @Injected() var glucoseStorage: GlucoseStorage!
+        @Injected() var calibrationService: CalibrationService!
+
+        @Published var slope: Double = 1
+        @Published var intercept: Double = 1
+        @Published var newCalibration: Decimal = 0
+        @Published var calibrations: [Calibration] = []
+        @Published var calibrate: (Int) -> Double = { Double($0) }
+        @Published var items: [Item] = []
+
+        var units: GlucoseUnits = .mmolL
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+            calibrate = calibrationService.calibrate
+            setupCalibrations()
+        }
+
+        private func setupCalibrations() {
+            slope = calibrationService.slope
+            intercept = calibrationService.intercept
+            calibrations = calibrationService.calibrations
+            items = calibrations.map {
+                Item(calibration: $0)
+            }
+        }
+
+        func addCalibration() {
+            defer {
+                UIApplication.shared.endEditing()
+                setupCalibrations()
+            }
+
+            var glucose = newCalibration
+            if units == .mmolL {
+                glucose = newCalibration.asMgdL
+            }
+
+            guard let lastGlucose = glucoseStorage.recent().last,
+                  lastGlucose.dateString.addingTimeInterval(60 * 4.5) > Date(),
+                  let unfiltered = lastGlucose.unfiltered
+            else {
+                info(.service, "Glucose is stale for calibration")
+                return
+            }
+
+            let calibration = Calibration(x: Double(unfiltered), y: Double(glucose))
+
+            calibrationService.addCalibration(calibration)
+        }
+
+        func removeLast() {
+            calibrationService.removeLast()
+            setupCalibrations()
+        }
+
+        func removeAll() {
+            calibrationService.removeAllCalibrations()
+            setupCalibrations()
+        }
+
+        func removeAtIndex(_ index: Int) {
+            let calibration = calibrations[index]
+            calibrationService.removeCalibration(calibration)
+            setupCalibrations()
+        }
+    }
+}

+ 60 - 0
FreeAPS/Sources/Modules/Calibrations/View/CalibrationsChart.swift

@@ -0,0 +1,60 @@
+import SwiftUI
+
+struct CalibrationsChart: View {
+    @EnvironmentObject var state: Calibrations.StateModel
+
+    private var dateFormatter: DateFormatter {
+        let formatter = DateFormatter()
+        formatter.timeStyle = .short
+        formatter.dateStyle = .short
+        return formatter
+    }
+
+    private let maxValue = 400.0
+
+    var body: some View {
+        GeometryReader { geo in
+            ZStack(alignment: .top) {
+                Rectangle().fill(Color.secondary)
+                    .frame(height: geo.size.width)
+                Path { path in
+                    let size = geo.size.width
+                    path.move(
+                        to:
+                        CGPoint(
+                            x: 0,
+                            y: size - state.calibrate(0) / maxValue * geo.size.width
+                        )
+                    )
+                    path.addLine(
+                        to: CGPoint(
+                            x: size,
+                            y: size - state.calibrate(Int(maxValue)) / maxValue * geo.size.width
+                        )
+                    )
+                }
+                .stroke(.blue, lineWidth: 2)
+
+                ForEach(state.calibrations, id: \.self) { value in
+                    ZStack {
+                        Circle().fill(.red)
+                            .frame(width: 6, height: 6)
+                            .position(
+                                x: value.x / maxValue * geo.size.width,
+                                y: geo.size.width - (value.y / maxValue * geo.size.width)
+                            )
+                        Text(dateFormatter.string(from: value.date))
+                            .foregroundColor(.white)
+                            .font(.system(size: 10))
+                            .position(
+                                x: value.x / maxValue * geo.size.width,
+                                y: geo.size.width - (value.y / maxValue * geo.size.width) + 10
+                            )
+                    }
+                }
+            }
+            .frame(height: geo.size.width)
+            .clipped()
+        }
+    }
+}

+ 109 - 0
FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift

@@ -0,0 +1,109 @@
+import SwiftUI
+import Swinject
+
+extension Calibrations {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+
+        private var formatter: NumberFormatter {
+            let formatter = NumberFormatter()
+            formatter.numberStyle = .decimal
+            formatter.maximumFractionDigits = 2
+            return formatter
+        }
+
+        private var dateFormatter: DateFormatter {
+            let formatter = DateFormatter()
+            formatter.timeStyle = .short
+            formatter.dateStyle = .short
+            return formatter
+        }
+
+        var body: some View {
+            GeometryReader { geo in
+                Form {
+                    Section(header: Text("Add calibration")) {
+                        HStack {
+                            Text("Meter glucose")
+                            Spacer()
+                            DecimalTextField(
+                                "0",
+                                value: $state.newCalibration,
+                                formatter: formatter,
+                                autofocus: false,
+                                cleanInput: true
+                            )
+                            Text(state.units.rawValue).foregroundColor(.secondary)
+                        }
+                        Button {
+                            state.addCalibration()
+                        }
+                        label: { Text("Add") }
+                            .disabled(state.newCalibration <= 0)
+                    }
+
+                    Section(header: Text("Info")) {
+                        HStack {
+                            Text("Slope")
+                            Spacer()
+                            Text(formatter.string(from: state.slope as NSNumber)!)
+                        }
+                        HStack {
+                            Text("Intercept")
+                            Spacer()
+                            Text(formatter.string(from: state.intercept as NSNumber)!)
+                        }
+                    }
+
+                    Section(header: Text("Remove")) {
+                        Button {
+                            state.removeLast()
+                        }
+                        label: { Text("Remove Last") }
+                            .disabled(state.calibrations.isEmpty)
+
+                        Button {
+                            state.removeAll()
+                        }
+                        label: { Text("Remove All") }
+                            .disabled(state.calibrations.isEmpty)
+                        List {
+                            ForEach(state.items) { item in
+                                HStack {
+                                    Text(dateFormatter.string(from: item.calibration.date))
+                                    Spacer()
+                                    VStack(alignment: .leading) {
+                                        Text("raw: \(item.calibration.x)")
+                                            .font(.caption2)
+                                            .foregroundColor(.secondary)
+                                        Text("value: \(item.calibration.y)")
+                                            .font(.caption2)
+                                            .foregroundColor(.secondary)
+                                    }
+                                }
+
+                            }.onDelete(perform: delete)
+                        }
+                    }
+
+                    if state.calibrations.isNotEmpty {
+                        Section(header: Text("Chart")) {
+                            CalibrationsChart().environmentObject(state)
+                                .frame(minHeight: geo.size.width)
+                        }
+                    }
+                }
+            }
+            .dynamicTypeSize(...DynamicTypeSize.xxLarge)
+            .onAppear(perform: configureView)
+            .navigationTitle("Calibrations")
+            .navigationBarItems(trailing: EditButton().disabled(state.calibrations.isEmpty))
+            .navigationBarTitleDisplayMode(.automatic)
+        }
+
+        private func delete(at offsets: IndexSet) {
+            state.removeAtIndex(offsets[offsets.startIndex])
+        }
+    }
+}

+ 3 - 0
FreeAPS/Sources/Router/Screen.swift

@@ -30,6 +30,7 @@ enum Screen: Identifiable, Hashable {
     case statistics
     case watch
     case statisticsConfig
+    case calibrations
 
     var id: Int { String(reflecting: self).hashValue }
 }
@@ -93,6 +94,8 @@ extension Screen {
             Stat.RootView(resolver: resolver)
         case .statisticsConfig:
             StatConfig.RootView(resolver: resolver)
+        case .calibrations:
+            Calibrations.RootView(resolver: resolver)
         }
     }
 

+ 55 - 0
FreeAPSTests/CalibrationsTests.swift

@@ -0,0 +1,55 @@
+@testable import FreeAPS
+import Swinject
+import XCTest
+
+class CalibrationsTests: XCTestCase, Injectable {
+    let fileStorage = BaseFileStorage()
+    @Injected() var calibrationService: CalibrationService!
+    let resolver = FreeAPSApp().resolver
+
+    override func setUp() {
+        injectServices(resolver)
+    }
+
+    func testCreateSimpleCalibration() {
+        let calibration = Calibration(x: 100.0, y: 102.0)
+        calibrationService.addCalibration(calibration)
+
+        XCTAssertTrue(calibrationService.calibrations.isNotEmpty)
+
+        XCTAssertTrue(calibrationService.slope == 1)
+
+        XCTAssertTrue(calibrationService.intercept == 2)
+
+        XCTAssertTrue(calibrationService.calibrate(value: 104) == 106)
+    }
+
+    func testCreateMultipleCalibration() {
+        let calibration = Calibration(x: 100.0, y: 120)
+        calibrationService.addCalibration(calibration)
+
+        let calibration2 = Calibration(x: 120.0, y: 130.0)
+        calibrationService.addCalibration(calibration2)
+
+        XCTAssertTrue(calibrationService.slope == 0.8)
+
+        XCTAssertTrue(calibrationService.intercept == 37)
+
+        XCTAssertTrue(calibrationService.calibrate(value: 80) == 101)
+
+        calibrationService.removeLast()
+
+        XCTAssertTrue(calibrationService.calibrations.count == 1)
+
+        calibrationService.removeAllCalibrations()
+        XCTAssertTrue(calibrationService.calibrations.isEmpty)
+    }
+
+    override func setUpWithError() throws {
+        // Put setup code here. This method is called before the invocation of each test method in the class.
+    }
+
+    override func tearDownWithError() throws {
+        // Put teardown code here. This method is called after the invocation of each test method in the class.
+    }
+}