CPU Saturation with Charts real time plotting from data received from Bluetooth

Hello everyone,

I am new to Swift, it is my first project and I am a PhD Electrical Engineer student.

I am designing an iOS app for a device that we are designing that is capable of reading electrical brain data and sending them via BLE with a sampling frequency of 2400 Hz.

I created a Bluetooth service for the Swift app that every time it receives new data, processes it to split the different channels and add the new data to the Charts data arrays. Here is the code I've designed:

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        
    if characteristic.uuid == Nordic_UART_TX_CHAR_UUID {
            
        guard error == nil, let data = characteristic.value else {
            print("[Bluetooth] Error receiving data or no data: \(error?.localizedDescription ?? "Unknown Error")")
            return
        }
            
        DispatchQueue.global(qos: .background).async {
            self.processReceivedData(data)
        }
            
    }
        
}
    func processReceivedData(_ data: Data) {
        
        var batch = [(Int, Int)]()

        for i in stride(from: 0, to: data.count - 4, by: 4) {
            let channel = Int(data[i] & 0xFF)
            let value = Int((Int(data[i + 3] & 0xFF) << 16) | (Int(data[i + 2] & 0xFF) << 8) | (Int(data[i + 1] & 0xFF))) - 8388608
            batch.append((channel, value))
        }

        DispatchQueue.main.async {
            for (channel, value) in batch {
                
                let nowTime = (Date().timeIntervalSince1970 - self.dataGraphService.startTime)
                
                let newDataPoint = DataGraphService.VoltagePerTime(time: nowTime, voltage: Double(value)/8388608, channel: "Channel \(channel - 15)")

                if channel == 16 {
                    self.dataGraphService.lastX1 = nowTime
                    self.dataGraphService.dataCh1.append(newDataPoint)
                } else if channel == 17 {
                    self.dataGraphService.lastX2 = nowTime
                    self.dataGraphService.dataCh2.append(newDataPoint)
                } else if channel == 18 {
                    self.dataGraphService.lastX3 = nowTime
                    self.dataGraphService.dataCh3.append(newDataPoint)
                } else if channel == 19 {
                    self.dataGraphService.lastX4 = nowTime
                    self.dataGraphService.dataCh4.append(newDataPoint)
                }
                
            }
        }
    }
    // DataGraphService.swift

    struct VoltagePerTime {
        var time: Double
        var voltage: Double
        var channel: String
    }
    
    @Published var dataCh1: [VoltagePerTime] = []
    @Published var dataCh2: [VoltagePerTime] = []
    @Published var dataCh3: [VoltagePerTime] = []
    @Published var dataCh4: [VoltagePerTime] = []
    
    @Published var windowSize: Double = 2.0
    
    @Published var lastX1: Double = 0
    @Published var lastX2: Double = 0
    @Published var lastX3: Double = 0
    @Published var lastX4: Double = 0

I also created a View that shows the real-time data from the different channels.

ChartView(
    data: dataGraphService.dataCh1.filter {
         dataGraphService.getXAxisRange(for: dataGraphService.dataCh1, windowSize: dataGraphService.windowSize).contains($0.time)
    },
    xAxisRange: dataGraphService.getXAxisRange(for: dataGraphService.dataCh1, windowSize: dataGraphService.windowSize),
    channel: "Channel 1",
    windowSize: dataGraphService.windowSize
)
//  ChartView.swift

import SwiftUI
import Charts

struct ChartView: View {
    
    var data: [DataGraphService.VoltagePerTime]
    var xAxisRange: ClosedRange<Double>
    var channel: String
    var windowSize: Double

    var body: some View {
        
        RoundedRectangle(cornerRadius: 10)
            .fill(Color.gray.opacity(0.1))
            .overlay(
                VStack{
                    Text("\(channel)")
                        .foregroundColor(Color.gray)
                        .font(.system(size: 16, weight: .semibold))
                    Chart(data, id: \.time) { item in
                        LineMark(
                            x: .value("Time [s]", item.time),
                            y: .value("Voltage [V]", item.voltage)
                        )
                    }
                    .chartYAxisLabel(position: .leading) {
                        Text("Voltage [V]")
                    }
                    .chartYScale(domain: [-1.6, 1.6])
                    .chartYAxis {
                        AxisMarks(position: .leading, values: [-1.6, -0.8, 0, 0.8, 1.6])
                       
                        AxisMarks(values: [-1.6, -1.2, -0.8, -0.4, 0, 0.4, 0.8, 1.2, 1.6]) {
                            AxisGridLine()
                        }
                    }
                    .chartXAxisLabel(position: .bottom, alignment: .center) {
                        Text("Time [s]")
                    }
                    .chartXScale(domain: xAxisRange)
                    .chartXAxis {
                        AxisMarks(values: .automatic(desiredCount: Int(windowSize)*2))
                        
                        AxisMarks(values: .automatic(desiredCount: 4*Int(windowSize)-2)) {
                            AxisGridLine()
                        }
                    }
                    .padding(5)
                }
            )
            .padding(2.5)
            .padding([.leading, .trailing], 5)
    
    }
}

With these code I can receive and plot the data in real-time but after some time the CPU of the iPhone gets saturated and the app stop working. I have the guess that the code is designed in a way that the functions are called one inside the other one in a very fast speed that the CPU cannot handle.

My doubt is if there is any other way to code this real-time plotting actions without make the iPhone's CPU power hungry.

Thank you very much for your help!

Replies

I see it has been a week and no-one has answered. I've never used Swift Charts, but I'll take a stab at this. You're feeding four channels of data at 2400 samples/sec into an ever-growing array of structs. Each sample struct (VoltagePerTime) contains a string with the channel name, but the channel name probably doesn't change from one sample to the next sample in a given channel, right? So why not infer the channel name from the array you put the sample into. Then you don't store a bunch of identical strings in your sample array.

Are the samples arriving at constant intervals? If so, the time can be inferred from the position in the array, so you don't need to store the time. At most, you would need to store the start time of each sample set.

I assume the samples arrive in strictly monotonic time order. However, your ChartView filter closure examines every sample you have collected, to see if it falls within the time you wish to graph. As your sample set grows, simply fetching the samples in your graph window takes longer and longer.

You should consider using a smarter algorithm to select the data that you pass to the graph, using what you know in advance about the data. Store as little as possible, prefer numbers to strings. Also, you have @Published every array. But if your graph is currently viewing the first 2 seconds of data, while you collect the tenth second, nothing is going to change on screen, but you're nevertheless asking the ChartView's body property to be re-evaluated.

If you leave your app running for long enough, you're going to run out of memory, so you should consider implementing some kind of persistent buffer for your data, or a circular in-memory buffer, or both.

There are further optimizations you could consider, but these suggestions may be enough to give you acceptable performance so that you can get on with the rest of the project.