Explorar o código

Add cone of uncertainty for Trio/OpenAPS predictions (#605)

Replace individual prediction lines (ZT, IOB, COB, UAM) with a filled
cone that shows the min/max envelope across all prediction arrays,
matching Trio's native forecast visualization.

- ConeChartDataEntry and ConeOfUncertaintyRenderer draw the filled band
  via a custom CGContext polygon in the Charts library
- PredictionDisplayType setting (.cone / .lines) lets Trio users switch
  between styles; Loop users are locked to lines
- Reactive Combine subscription auto-sets the display type when the
  device changes (Loop -> lines, Trio -> cone) while preserving manual
  user preference across Nightscout refreshes
- Cone dataset added at index 18 in both main and small BG graphs
Jonas Björkert hai 3 meses
pai
achega
762e4ae429

+ 4 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -99,6 +99,7 @@
 		DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; };
 		DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */; };
 		DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; };
+		DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */; };
 		DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; };
 		DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
 		DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
@@ -548,6 +549,7 @@
 		DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = "<group>"; };
 		DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuViewController.swift; sourceTree = "<group>"; };
 		DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = "<group>"; };
+		DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionDisplayType.swift; sourceTree = "<group>"; };
 		DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = "<group>"; };
 		DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
 		DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
@@ -1667,6 +1669,7 @@
 				656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */,
 				DD4A407D2E6AFEE6007B318B /* AuthService.swift */,
 				DD1D52BF2E4C100000000001 /* AppearanceMode.swift */,
+				DD1D52C12E4C100000000002 /* PredictionDisplayType.swift */,
 				DD1D52B82E1EB5DC00432050 /* TabPosition.swift */,
 				DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */,
 				DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */,
@@ -2345,6 +2348,7 @@
 				DD7F4C112DD51ED900D449E9 /* TempTargetStartAlarmEditor.swift in Sources */,
 				DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */,
 				DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */,
+				DD1D52C22E4C100000000002 /* PredictionDisplayType.swift in Sources */,
 				DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */,
 				654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */,
 				6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */,

+ 135 - 4
LoopFollow/Controllers/Graphs.swift

@@ -26,6 +26,7 @@ enum GraphDataIndex: Int {
     case uamPrediction = 15
     case smb = 16
     case tempTarget = 17
+    case predictionCone = 18
 }
 
 extension GraphDataIndex {
@@ -49,6 +50,7 @@ extension GraphDataIndex {
         case .uamPrediction: return "UAM Prediction"
         case .smb: return "SMB"
         case .tempTarget: return "Temp Target"
+        case .predictionCone: return "Prediction Cone"
         }
     }
 }
@@ -56,8 +58,9 @@ extension GraphDataIndex {
 class CompositeRenderer: LineChartRenderer {
     let tempTargetRenderer: TempTargetRenderer
     let triangleRenderer: TriangleRenderer
+    let coneRenderer: ConeOfUncertaintyRenderer
 
-    init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, tempTargetDataSetIndex: Int, smbDataSetIndex: Int) {
+    init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, tempTargetDataSetIndex: Int, smbDataSetIndex: Int, coneDataSetIndex: Int) {
         tempTargetRenderer = TempTargetRenderer(
             dataProvider: dataProvider,
             animator: animator,
@@ -70,11 +73,18 @@ class CompositeRenderer: LineChartRenderer {
             viewPortHandler: viewPortHandler,
             smbDataSetIndex: smbDataSetIndex
         )
+        coneRenderer = ConeOfUncertaintyRenderer(
+            dataProvider: dataProvider,
+            animator: animator,
+            viewPortHandler: viewPortHandler,
+            coneDataSetIndex: coneDataSetIndex
+        )
         super.init(dataProvider: dataProvider!, animator: animator!, viewPortHandler: viewPortHandler!)
     }
 
     override func drawExtras(context: CGContext) {
         super.drawExtras(context: context)
+        coneRenderer.drawExtras(context: context)
         tempTargetRenderer.drawExtras(context: context)
         triangleRenderer.drawExtras(context: context)
     }
@@ -204,13 +214,15 @@ extension MainViewController {
     func updateChartRenderers() {
         let tempTargetDataIndex = GraphDataIndex.tempTarget.rawValue
         let smbDataIndex = GraphDataIndex.smb.rawValue
+        let coneDataIndex = GraphDataIndex.predictionCone.rawValue
 
         let compositeRenderer = CompositeRenderer(
             dataProvider: BGChart,
             animator: BGChart.chartAnimator,
             viewPortHandler: BGChart.viewPortHandler,
             tempTargetDataSetIndex: tempTargetDataIndex,
-            smbDataSetIndex: smbDataIndex
+            smbDataSetIndex: smbDataIndex,
+            coneDataSetIndex: coneDataIndex
         )
         BGChart.renderer = compositeRenderer
 
@@ -595,7 +607,16 @@ extension MainViewController {
         data.append(COBlinePrediction) // Dataset 14
         data.append(UAMlinePrediction) // Dataset 15
         data.append(lineSmb) // Dataset 16
-        data.append(lineTempTarget)
+        data.append(lineTempTarget) // Dataset 17
+
+        // Dataset 18: Prediction Cone (rendered via ConeOfUncertaintyRenderer)
+        let lineCone = LineChartDataSet(entries: [ChartDataEntry](), label: "")
+        lineCone.lineWidth = 0
+        lineCone.drawCirclesEnabled = false
+        lineCone.drawValuesEnabled = false
+        lineCone.highlightEnabled = false
+        lineCone.axisDependency = YAxis.AxisDependency.right
+        data.append(lineCone)
 
         data.setValueFont(UIFont.systemFont(ofSize: 12))
 
@@ -783,6 +804,9 @@ extension MainViewController {
         BGChart.data?.dataSets[dataIndex].notifyDataSetChanged()
         BGChart.data?.notifyDataChanged()
         BGChart.notifyDataSetChanged()
+
+        // Re-render prediction display in case display type changed
+        updateOpenAPSPredictionDisplay()
     }
 
     func updateBGGraph() {
@@ -1605,7 +1629,16 @@ extension MainViewController {
         data.append(COBlinePrediction) // Dataset 14
         data.append(UAMlinePrediction) // Dataset 15
         data.append(lineSmb) // Dataset 16
-        data.append(lineTempTarget)
+        data.append(lineTempTarget) // Dataset 17
+
+        // Dataset 18: Prediction Cone placeholder (not rendered on small chart)
+        let lineConeSmall = LineChartDataSet(entries: [ChartDataEntry](), label: "")
+        lineConeSmall.lineWidth = 0
+        lineConeSmall.drawCirclesEnabled = false
+        lineConeSmall.drawValuesEnabled = false
+        lineConeSmall.highlightEnabled = false
+        lineConeSmall.axisDependency = YAxis.AxisDependency.right
+        data.append(lineConeSmall)
 
         BGChartFull.highlightPerDragEnabled = true
         BGChartFull.leftAxis.enabled = false
@@ -1902,6 +1935,104 @@ extension MainViewController {
         }
     }
 
+    func updateConeGraph(coneData: [ConeChartDataEntry]) {
+        let dataIndex = GraphDataIndex.predictionCone.rawValue
+        let mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet
+        mainChart.clear()
+        for entry in coneData {
+            mainChart.addEntry(entry)
+        }
+        BGChart.rightAxis.axisMaximum = Double(calculateMaxBgGraphValue())
+        updateChartRenderers()
+    }
+
+    func clearConeGraph() {
+        let dataIndex = GraphDataIndex.predictionCone.rawValue
+        guard let lineData = BGChart.lineData, lineData.dataSets.count > dataIndex else { return }
+        lineData.dataSets[dataIndex].clear()
+        BGChart.data?.dataSets[dataIndex].notifyDataSetChanged()
+        BGChart.data?.notifyDataChanged()
+        BGChart.notifyDataSetChanged()
+    }
+
+    func updateOpenAPSPredictionDisplay() {
+        guard let predBGs = openAPSPredBGs else { return }
+
+        // Cone is only for OpenAPS-based systems; Loop always uses lines
+        let displayType: PredictionDisplayType = Storage.shared.device.value == "Loop" ? .lines : Storage.shared.predictionDisplayType.value
+        let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
+        let predictionStart = openAPSPredUpdatedTime ?? Date().timeIntervalSince1970
+
+        let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [
+            ("ZT", "ZT", GraphDataIndex.ztPrediction.rawValue),
+            ("IOB", "Insulin", GraphDataIndex.iobPrediction.rawValue),
+            ("COB", "LoopYellow", GraphDataIndex.cobPrediction.rawValue),
+            ("UAM", "UAM", GraphDataIndex.uamPrediction.rawValue),
+        ]
+
+        topPredictionBG = Storage.shared.minBGScale.value
+
+        if displayType == .cone {
+            var allArrays = [[Double]]()
+            for (type, _, _) in predictionTypes {
+                if let arr = predBGs[type], !arr.isEmpty {
+                    allArrays.append(arr)
+                }
+            }
+
+            var coneData = [ConeChartDataEntry]()
+            if !allArrays.isEmpty {
+                let maxLength = min(allArrays.map { $0.count }.max()!, toLoad + 1)
+                var t = predictionStart
+                for i in 0 ..< maxLength {
+                    var valuesAtIndex = [Double]()
+                    for arr in allArrays where i < arr.count {
+                        valuesAtIndex.append(arr[i])
+                    }
+                    if !valuesAtIndex.isEmpty {
+                        var yMin = max(valuesAtIndex.min()!, Double(globalVariables.minDisplayGlucose))
+                        var yMax = min(valuesAtIndex.max()!, Double(globalVariables.maxDisplayGlucose))
+                        // Ensure minimum ±1 mg/dL range so the cone is visible when predictions agree
+                        if yMin == yMax {
+                            yMin -= 1
+                            yMax += 1
+                        }
+                        coneData.append(ConeChartDataEntry(x: t, yMin: yMin, yMax: yMax))
+                        if yMax > topPredictionBG - 20 { topPredictionBG = yMax + 20 }
+                    }
+                    t += 300
+                }
+            }
+
+            updateConeGraph(coneData: coneData)
+
+            // Clear individual prediction lines
+            for (_, _, dataIndex) in predictionTypes {
+                updatePredictionGraphGeneric(dataIndex: dataIndex, predictionData: [], chartLabel: "", color: .clear)
+            }
+
+        } else {
+            clearConeGraph()
+
+            for (type, colorName, dataIndex) in predictionTypes {
+                var predictionData = [ShareGlucoseData]()
+                if let graphdata = predBGs[type] {
+                    var t = predictionStart
+                    for i in 0 ... toLoad {
+                        if i < graphdata.count {
+                            let v = graphdata[i]
+                            let clamped = min(max(Int(round(v)), globalVariables.minDisplayGlucose), globalVariables.maxDisplayGlucose)
+                            predictionData.append(ShareGlucoseData(sgv: clamped, date: t, direction: "flat"))
+                            t += 300
+                        }
+                    }
+                }
+                let color = UIColor(named: colorName) ?? UIColor.systemPurple
+                updatePredictionGraphGeneric(dataIndex: dataIndex, predictionData: predictionData, chartLabel: type, color: color)
+            }
+        }
+    }
+
     func updatePredictionGraphGeneric(
         dataIndex: Int,
         predictionData: [ShareGlucoseData],

+ 17 - 32
LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift

@@ -176,46 +176,26 @@ extension MainViewController {
             let predictioncolor = UIColor.systemGray
             PredictionLabel.textColor = predictioncolor
             topPredictionBG = Storage.shared.minBGScale.value
-            if let predbgdata = predBGsData {
-                let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [
-                    ("ZT", "ZT", 12),
-                    ("IOB", "Insulin", 13),
-                    ("COB", "LoopYellow", 14),
-                    ("UAM", "UAM", 15),
-                ]
 
+            if let predbgdata = predBGsData {
+                let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
+                var rawPredBGs = [String: [Double]]()
                 var minPredBG = Double.infinity
                 var maxPredBG = -Double.infinity
 
-                for (type, colorName, dataIndex) in predictionTypes {
-                    var predictionData = [ShareGlucoseData]()
-                    if let graphdata = predbgdata[type] as? [Double] {
-                        var predictionTime = updatedTime ?? Date().timeIntervalSince1970
-                        let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
-
-                        for i in 0 ... toLoad {
-                            if i < graphdata.count {
-                                let predictionValue = graphdata[i]
-                                minPredBG = min(minPredBG, predictionValue)
-                                maxPredBG = max(maxPredBG, predictionValue)
-
-                                let clampedValue = min(max(Int(round(predictionValue)), globalVariables.minDisplayGlucose), globalVariables.maxDisplayGlucose)
-                                let prediction = ShareGlucoseData(sgv: clampedValue, date: predictionTime, direction: "flat")
-                                predictionData.append(prediction)
-                                predictionTime += 300
-                            }
+                for type in ["ZT", "IOB", "COB", "UAM"] {
+                    if let arr = predbgdata[type] as? [Double], !arr.isEmpty {
+                        rawPredBGs[type] = arr
+                        for i in 0 ... min(toLoad, arr.count - 1) {
+                            minPredBG = min(minPredBG, arr[i])
+                            maxPredBG = max(maxPredBG, arr[i])
                         }
                     }
-
-                    let color = UIColor(named: colorName) ?? UIColor.systemPurple
-                    updatePredictionGraphGeneric(
-                        dataIndex: dataIndex,
-                        predictionData: predictionData,
-                        chartLabel: type,
-                        color: color
-                    )
                 }
 
+                openAPSPredBGs = rawPredBGs.isEmpty ? nil : rawPredBGs
+                openAPSPredUpdatedTime = updatedTime
+
                 if minPredBG != Double.infinity, maxPredBG != -Double.infinity {
                     let value = "\(Localizer.toDisplayUnits(String(minPredBG)))/\(Localizer.toDisplayUnits(String(maxPredBG)))"
                     infoManager.updateInfoData(type: .minMax, value: value)
@@ -224,6 +204,11 @@ extension MainViewController {
                 } else {
                     infoManager.updateInfoData(type: .minMax, value: "N/A")
                 }
+
+                updateOpenAPSPredictionDisplay()
+            } else {
+                openAPSPredBGs = nil
+                openAPSPredUpdatedTime = nil
             }
 
             if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {

+ 73 - 0
LoopFollow/Helpers/Chart.swift

@@ -141,3 +141,76 @@ class PillMarker: MarkerImage {
         return "\(formattedString)"
     }
 }
+
+// MARK: - Cone of Uncertainty
+
+class ConeChartDataEntry: ChartDataEntry {
+    var yMin: Double = 0.0
+    var yMax: Double = 0.0
+
+    required init() {
+        super.init()
+    }
+
+    init(x: Double, yMin: Double, yMax: Double) {
+        self.yMin = yMin
+        self.yMax = yMax
+        super.init(x: x, y: yMax)
+    }
+
+    override func copy(with _: NSZone? = nil) -> Any {
+        let copy = ConeChartDataEntry(x: x, yMin: yMin, yMax: yMax)
+        copy.data = data
+        return copy
+    }
+}
+
+class ConeOfUncertaintyRenderer: LineChartRenderer {
+    let coneDataSetIndex: Int
+
+    init(dataProvider: LineChartDataProvider?, animator: Animator?, viewPortHandler: ViewPortHandler?, coneDataSetIndex: Int) {
+        self.coneDataSetIndex = coneDataSetIndex
+        super.init(dataProvider: dataProvider!, animator: animator!, viewPortHandler: viewPortHandler!)
+    }
+
+    override func drawExtras(context: CGContext) {
+        super.drawExtras(context: context)
+
+        guard let dataProvider = dataProvider,
+              dataProvider.lineData?.dataSets.count ?? 0 > coneDataSetIndex,
+              let lineDataSet = dataProvider.lineData?.dataSets[coneDataSetIndex] as? LineChartDataSet,
+              lineDataSet.entryCount > 1 else { return }
+
+        let trans = dataProvider.getTransformer(forAxis: lineDataSet.axisDependency)
+        let phaseY = animator.phaseY
+
+        var upperPoints = [CGPoint]()
+        var lowerPoints = [CGPoint]()
+
+        for i in 0 ..< lineDataSet.entryCount {
+            guard let entry = lineDataSet.entryForIndex(i) as? ConeChartDataEntry else { continue }
+            upperPoints.append(trans.pixelForValues(x: entry.x, y: entry.yMax * phaseY))
+            lowerPoints.append(trans.pixelForValues(x: entry.x, y: entry.yMin * phaseY))
+        }
+
+        guard upperPoints.count > 1 else { return }
+
+        context.saveGState()
+
+        let path = CGMutablePath()
+        path.move(to: upperPoints[0])
+        for i in 1 ..< upperPoints.count {
+            path.addLine(to: upperPoints[i])
+        }
+        for i in stride(from: lowerPoints.count - 1, through: 0, by: -1) {
+            path.addLine(to: lowerPoints[i])
+        }
+        path.closeSubpath()
+
+        context.addPath(path)
+        context.setFillColor(UIColor.systemBlue.withAlphaComponent(0.4).cgColor)
+        context.fillPath()
+
+        context.restoreGState()
+    }
+}

+ 14 - 0
LoopFollow/Helpers/PredictionDisplayType.swift

@@ -0,0 +1,14 @@
+// LoopFollow
+// PredictionDisplayType.swift
+
+enum PredictionDisplayType: String, CaseIterable, Codable {
+    case cone
+    case lines
+
+    var displayName: String {
+        switch self {
+        case .cone: return "Cone"
+        case .lines: return "Lines"
+        }
+    }
+}

+ 10 - 0
LoopFollow/Settings/GraphSettingsView.swift

@@ -16,6 +16,7 @@ struct GraphSettingsView: View {
 
     @ObservedObject private var smallGraphHeight = Storage.shared.smallGraphHeight
     @ObservedObject private var predictionToLoad = Storage.shared.predictionToLoad
+    @ObservedObject private var predictionDisplayType = Storage.shared.predictionDisplayType
     @ObservedObject private var minBasalScale = Storage.shared.minBasalScale
     @ObservedObject private var minBGScale = Storage.shared.minBGScale
     @ObservedObject private var lowLine = Storage.shared.lowLine
@@ -82,6 +83,15 @@ struct GraphSettingsView: View {
                             value: $predictionToLoad.value,
                             format: { "\($0.localized(maxFractionDigits: 2)) h" }
                         )
+
+                        if Storage.shared.device.value != "Loop" {
+                            Picker("Prediction Style", selection: $predictionDisplayType.value) {
+                                ForEach(PredictionDisplayType.allCases, id: \.self) { type in
+                                    Text(type.displayName).tag(type)
+                                }
+                            }
+                            .onChange(of: predictionDisplayType.value) { _ in markDirty() }
+                        }
                     }
                 }
 

+ 2 - 0
LoopFollow/Storage/Storage.swift

@@ -130,6 +130,7 @@ class Storage {
 
     var smallGraphHeight = StorageValue<Int>(key: "smallGraphHeight", defaultValue: 40)
     var predictionToLoad = StorageValue<Double>(key: "predictionToLoad", defaultValue: 1.0)
+    var predictionDisplayType = StorageValue<PredictionDisplayType>(key: "predictionDisplayType", defaultValue: .cone)
     var minBasalScale = StorageValue<Double>(key: "minBasalScale", defaultValue: 5.0)
     var minBGScale = StorageValue<Double>(key: "minBGScale", defaultValue: 250.0)
     var lowLine = StorageValue<Double>(key: "lowLine", defaultValue: 70.0)
@@ -336,6 +337,7 @@ class Storage {
         smallGraphTreatments.reload()
         smallGraphHeight.reload()
         predictionToLoad.reload()
+        predictionDisplayType.reload()
         minBasalScale.reload()
         minBGScale.reload()
         lowLine.reload()

+ 16 - 0
LoopFollow/ViewControllers/MainViewController.swift

@@ -91,6 +91,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     var overrideGraphData: [DataStructs.overrideStruct] = []
     var tempTargetGraphData: [DataStructs.tempTargetStruct] = []
     var predictionData: [ShareGlucoseData] = []
+    var openAPSPredBGs: [String: [Double]]?
+    var openAPSPredUpdatedTime: TimeInterval?
     var bgCheckData: [ShareGlucoseData] = []
     var suspendGraphData: [DataStructs.timestampOnlyStruct] = []
     var resumeGraphData: [DataStructs.timestampOnlyStruct] = []
@@ -425,6 +427,20 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             }
             .store(in: &cancellables)
 
+        Storage.shared.device.$value
+            .receive(on: DispatchQueue.main)
+            .map { device -> Bool? in
+                device.isEmpty ? nil : (device == "Loop")
+            }
+            .removeDuplicates()
+            .dropFirst()
+            .sink { [weak self] isLoop in
+                guard let isLoop = isLoop else { return }
+                Storage.shared.predictionDisplayType.value = isLoop ? .lines : .cone
+                self?.updateOpenAPSPredictionDisplay()
+            }
+            .store(in: &cancellables)
+
         updateQuickActions()
 
         // Delay initial tab setup to ensure view hierarchy is ready