René van Mil

Heart Rate Variability and Brain Waves

Recently I’ve attended a hackathon powerpointathon which required the teams to build a working prototype sell a load of bullshit. Because our team consisted of actual engineers without a PhD in Bullshitology, we failed miserably at the challenge because we built a working prototype. 😆

We had the chance to work with an Apple Watch, a Polar H10 heart rate sensor, and a Muse brain sensing headband. Our goal was to measure Heart Rate Variability and alpha wave values, which both may (or may not, medical science is very complex) be related to the amount of stress a person is experiencing.

Summary

In this post I’ll show you how to accomplish the following with an iOS app:

  • Get the most accurate heart rate measurement possible using an Apple Watch and HealthKit
  • Get a lot more accurate heart rate measurements using the Polar H10
  • Get the alpha wave measurements using the Muse headband

As always all source code is available on my GitHub account.

Apple Watch

Reading heart rate data using an Apple Watch is a lot different than reading from conventional bluetooth monitors, because it is not possible to read the bluetooth data directly. Instead all health data, such as the heart rate, must be read with the HealthKit APIs.

Before you can read the heart rate from the Apple Watch, you must ask the user permission to access this data. First you need to specify the reason you wish to access this data inside the Info.plist file of the iPhone app (not the Watch app or extension). Add the following key and a description:

Info.plist
1
2
<key>NSHealthShareUsageDescription</key>
<string>Measure your heart rate so we can write a blog post about it.</string>

You can now request read access to the heart rate data when the Watch app has finished launching using the requestAuthorization method of your HKHealthStore.

WatchKit Extension - Request access to HealthKit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let heartRateType = HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.heartRate)!
healthStore.requestAuthorization(toShare: nil, read: Set([heartRateType])) { (granted, error) in
    if (error != nil) {
        print("request authorization for health failed", error!)
    } else {
        if (granted) {
            // Authorization granted
            print("request authorization for health granted")
        } else {
            // Authorization denied
            print("request authorization for health denied")
        }
    }
}
iPhone AppDelegate - Handle request access to HealthKit
1
2
3
4
5
6
7
8
9
10
11
func applicationShouldRequestHealthAuthorization(_ application: UIApplication) {
    healthStore.handleAuthorizationForExtension { (done, error) in
        if (done) {
            // User handled authorization request view
            print("user handled authorization request view")
        } else {
            // User cancelled authorization request view
            print("user cancelled authorization request view")
        }
    }
}

Requesting access will display an short explanation on the Watch screen and will trigger an alert on the iPhone.

Once access is granted we can start measuring the heart rate. The Apple Watch continuously monitors the users heart rate, but only once every ~10 minutes when not in motion. To get a more frequent reading we need to start an HKWorkoutSession. If you take off the Apple Watch and take a look at the bottom of the watch when starting a session, you will see the green LED lights of the heart rate monitor. Measuring heart rate with these lights is called photoplethysmography. Learn to pronounce this word correctly and then casually mention it in a conversation to impress friends and colleagues with your expert medical knowledge. 😉

WatchKit Extension - Start an HKWorkoutSession
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let healthStore = HKHealthStore()
var session: HKWorkoutSession?

func start() {
    let workoutConfiguration = HKWorkoutConfiguration()
    workoutConfiguration.activityType = .other
    workoutConfiguration.locationType = .indoor
    do {
        session = try HKWorkoutSession(configuration: workoutConfiguration)
    } catch {
        fatalError("unable to create the workout session")
    }
    session!.delegate = self
    healthStore.start(session!)
}

// MARK: - HKWorkoutSessionDelegate

func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
    print("workout state change")
    switch toState {
    case .running:
        print("workout running")
        handleWorkoutRunning(date)
    case .paused:
        print("workout paused")
    case .notStarted:
        print("workout not started")
    case .ended:
        print("workout ended")
        handleWorkoutEnded(date)
    }
}

As you can see the workout session uses a delegate for its callbacks. Once the session is running the Apple Watch will automatically start transmitting health data to the iPhone, but it is not possible to read this data directly. Instead we need to query the HKHealthStore for the stored heart rate data. To do this we use an HKAnchoredObjectQuery which has an updateHandler callback which continuously returns new and deleted values for the query.

WatchKit Extension - Continuously query heart rate data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func handleWorkoutRunning(_ date : Date) {
    if let query = createHeartRateStreamingQuery(date) {
        heartRateQuery = query
        healthStore.execute(query)
    } else {
        print("create heart rate query failed")
    }
}

func createHeartRateStreamingQuery(_ workoutStartDate: Date) -> HKQuery? {
    guard let heartRateQuantityType = HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.heartRate) else {
        return nil
    }
    let datePredicate = HKQuery.predicateForSamples(withStart: workoutStartDate, end: nil, options: .strictEndDate)
    let predicate = NSCompoundPredicate(andPredicateWithSubpredicates:[datePredicate])
    let heartRateQuery = HKAnchoredObjectQuery(type: heartRateQuantityType, predicate: predicate, anchor: nil, limit: Int(HKObjectQueryNoLimit)) { (query, sampleObjects, deletedObjects, newAnchor, error) -> Void in
        self.updateHeartRate(sampleObjects)
    }
    heartRateQuery.updateHandler = {(query, samples, deleteObjects, newAnchor, error) -> Void in
        self.updateHeartRate(samples)
    }
    return heartRateQuery
}

func updateHeartRate(_ samples: [HKSample]?) {
    guard let heartRateSamples = samples as? [HKQuantitySample] else {
        return
    }
    guard let sample = heartRateSamples.first else {
        return
    }
    let value = sample.quantity.doubleValue(for: heartRateUnit)
    let hr = String(UInt16(value))

    let currentTimestamp = currentTimeMillis()
    let diff = (previousTimestamp == 0) ? 0 : currentTimestamp - previousTimestamp
    print(diff, " ms - ", hr, " bpm")
    previousTimestamp = currentTimestamp
}

func currentTimeMillis() -> Int64 {
    var darwinTime : timeval = timeval(tv_sec: 0, tv_usec: 0)
    gettimeofday(&darwinTime, nil)
    return (Int64(darwinTime.tv_sec) * 1000) + Int64(darwinTime.tv_usec / 1000)
}

The updateHeartRate method receives all heart rate updates while the workout session is running. It also calculates the difference in time between each update, and as you can see when running this code you’ll notice the Apple Watch will send a heart rate value each ~5 seconds.

Because we are interested in calculating the Heart Rate Variability this is not accurate enough at all, since this method requires that we know the amount of milliseconds between each heart beat, also known as the RR interval. With the introduction of HealthKit the RR interval was mentioned as part of the available data, but unfortunately it didn’t make it into the released version of HealthKit on our devices.

Which brings us to the next part of this post, measuring the heart rate and RR intervals with a Polar H10 heart rate monitor.

Polar H10

The Polar H10 is a bluetooth heart rate monitor which can be connected to using CoreBluetooth. It’s the same process as described in my previous post about measuring heart rates with the Mio Alpha 2 watch.

iPhone - Bluetooth constants
1
2
static let uuidHeartRate: String = "180D"
static let uuidHeartRateMeasurement: String = "2A37"
iPhone - Connect to the Polar H10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
var centralManager: CBCentralManager!
var hrMeter: CBPeripheral?
var hrCharacteristic: CBCharacteristic?
var ready = false

func setup() {
    centralManager = CBCentralManager(delegate: self, queue: nil)
    let services = [CBUUID(string: AppConstants.uuidHeartRate)]
    // HR meter may already be connected by the user
    let hrMeters = centralManager.retrieveConnectedPeripherals(withServices: services)
    if hrMeters.count > 0 {
        hrMeter = hrMeters.first
        hrMeter?.delegate = self
    }
}

func scan() {
    if hrMeter == nil {
        let services: [CBUUID] = [CBUUID(string: AppConstants.uuidHeartRate)]
        centralManager.scanForPeripherals(withServices: services, options: nil)
    } else {
        connect()
    }
}

func connect() {
    centralManager.connect(hrMeter!, options: nil)
}

// MARK: CBCentralManagerDelegate

func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
    if let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String {
        print("Found device \(localName)")
        centralManager.stopScan()
        hrMeter = peripheral
        hrMeter!.delegate = self
        connect()
    }
}

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    print("Connected")
    hrMeter!.discoverServices(nil)
}

func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
    print("Connect failed")
}

func centralManagerDidUpdateState(_ central: CBCentralManager) {
    if central.state == .poweredOff {
        print("Powered Off")
    } else if central.state == .poweredOn {
        print("Powered On")
        scan()
    } else if central.state == .resetting {
        print("Resetting")
    } else if central.state == .unauthorized {
        print("Unauthorized")
    } else if central.state == .unknown {
        print("Unknown")
    } else if central.state == .unsupported {
        print("Unsupported")
    }
}


// MARK: CBPeripheralDelegate

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    print("Discovered services:")
    for service in hrMeter!.services! {
        print("    \(service.uuid)")
        hrMeter!.discoverCharacteristics(nil, for: service)
    }
}

func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
    print("Discovered characteristics for service \(service.uuid):")
    for characteristic in service.characteristics! {
        print("    \(characteristic.uuid)")
        // Request heart rate notifications
        if service.uuid == CBUUID(string: AppConstants.uuidHeartRate) {
            if characteristic.uuid == CBUUID(string: AppConstants.uuidHeartRateMeasurement) {
                hrCharacteristic = characteristic
                ready = true
            }
        }

    }
}

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
    if characteristic.uuid == CBUUID(string: AppConstants.uuidHeartRateMeasurement) {
        processHRCharacteristic(characteristic: characteristic)
    }
}

Once the Polar H10 is connected it automatically starts transmitting the heart rate. To read this data we can enable notifications for the hrCharacteristic which was found while connecting to the device.

iPhone - Receive Polar H10 heart rate data
1
2
3
4
5
6
7
8
func run() {
    if ready {
        hrMeter?.setNotifyValue(true, for: hrCharacteristic!)
        print("Enabled notifications for characteristic \(hrCharacteristic!.uuid):")
    } else {
        print("HR not ready")
    }
}

The heart rate data is transmitted as raw bytes according to the Bluetooth Heart Rate Measurement specification.

This specification tells us the first byte contains the Flags, which will tell us how to read the remaining bytes we received. Reading the first byte with (rxData as NSData).getBytes(&flags, length: 1) returns 16. Converting 16 to the 8 bits in this byte gives us 00010000. Using these bit values we can determine how to interpret the rest of the received data. The meaning of these bits (from right to left):

  • 0 - 1 bit for Heart Rate Value Format => 0: Heart Rate Value Format is set to UINT8. Units: beats per minute (bpm).
  • 00 - 2 bits for Sensor Contact Status => 0: Sensor Contact feature is not supported in the current connection.
  • 0 - 1 bit for Energy Expended Status => 0: Energy Expended field is not present.
  • 1 - 1 bit for RR-Interval => 1: One or more RR-Interval values are present. Yay! 🎉
  • 0 - Reserved.
  • 0 - Reserved.
  • 0 - Reserved.

So, we have received the heart rate value as UINT8, and one or more RR interval values as UINT16. This means the next byte contains the heart rate value, and every 2 subsequent bytes contain an RR interval value.

iPhone - Process Polar H10 heart rate data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func processHRCharacteristic(characteristic: CBCharacteristic) {
    let rxData = characteristic.value
    if let rxData = rxData {
        var flags = UInt8()
        var bpm = UInt8()
        let bpmRange = NSRange.init(location: 1, length: 1)
        let rrByteCount = rxData.count - 2
        var rrArray = [UInt16](repeating: 0, count: rrByteCount)
        let rrRange = NSRange.init(location: 2, length: rrByteCount)
        (rxData as NSData).getBytes(&flags, length: 1)
        (rxData as NSData).getBytes(&bpm, range: bpmRange)
        (rxData as NSData).getBytes(&rrArray, range: rrRange)
        print("bpm", bpm)
        for rr in rrArray {
            if rr != 0 {
                print("rr", rr)
            }
        }
    }
}

This is great, the Polar H10 is sending us the RR interval for every heart beat. This means we don’t even need the BPM value which is sent as well because once we have the RR interval we can easily calculate the current BPM which is 60 / (rr / 1000).

The next step is actually calculating the Heart Rate Variability. This turned out to be much more complicated than we thought and it wasn’t possible to do this within 24 hours we had for this hackathon. Sorry! I did find an interesting open source Heart Rate Monitor for macOS which also worked properly with the Polar H10 and does a lot of different HRV calculations.

Brain Waves

The final topic of this post is measuring brain waves with the Muse headband. This is also a bluetooth device like the Apple Watch and Polar H10 and I included it in this post because this device has yet another way of connecting to it. Both HealthKit and a regular bluetooth connection are not available. I tried connecting to the device using CoreBluetooth and it does report several available characteristics, but none of them seem to broadcast any data once connected. I don’t know how they did it but it’s probably because they offer an SDK and want to keep implementation details private. If you know more perhaps you can leave a comment on this post. 😊

The Muse SDK is an Objective-C framework (at the time of writing), so adding this framework to our Swift project requires that we create an Objective-C bridging header in the project. The easiest way to do this is to create a new file in the Xcode project, select Objective-C File and enter a random filename with file type Empty File. When you do this Xcode will ask if you wish to configure an Objective-C bridging header. Click the Create Bridging Header button and afterwards delete the Objective-C file you just created.

Now all you have to do is open the bridging header file and import the Muse framework. Once this is done you will be able to magically call all Objective-C methods from the framework in Swift style. 😎

iPhone - Objective-C bridging header
1
#import <Muse/Muse.h>

Connecting to the Muse and receiving the data is pretty straight forward and documented in the SDK. You can view my code in the GitHub repository which contains all code mentioned in this post.

Our Prototype

Thanks for reading this post! Here’s a short clip of the prototype we made. You can see the Alpha waves measured at 4 different points of my head and my heart rate as measured by the Polar H10. As you can see, I was not experiencing any stress at all. 🙄

Comments

Real Time Web Analytics