Accuracy of IBI Values Measured by Apple Watch

I am currently developing an app that measures HRV to estimate stress levels.

To align the values more closely with those from Galaxy devices, I decided not to use the heartRateVariabilitySDNN value provided by HealthKit.

Instead, I extracted individual interbeat intervals (IBI) using the HKHeartBeatSeries data.

Can I obtain accurate IBI data using this method?

If not, I would like to know how I can retrieve more precise data.

Any insights or suggestions would be greatly appreciated.

Here is a sample code I tried.

@Observable
class HealthKitManager: ObservableObject {
    let healthStore = HKHealthStore()
    var ibiValues: [Double] = []
    var isAuthorized = false
    
    func requestAuthorization() {
        let types = Set([
            HKSeriesType.heartbeat(),
            HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!,
        ])
        
        healthStore.requestAuthorization(toShare: nil, read: types) { success, error in
            DispatchQueue.main.async {
                self.isAuthorized = success
                if success {
                    self.fetchIBIData()
                }
            }
        }
    }
    
    func fetchIBIData() {
        var timePoints: [TimeInterval] = []
        var absoluteStartTime: Date?
        
        let dateFormatter = DateFormatter()
        dateFormatter.timeZone = TimeZone(identifier: "Asia/Seoul")
        dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
        
        var calendar = Calendar.current
        calendar.timeZone = TimeZone(identifier: "Asia/Seoul") ?? .current
        
        var components = DateComponents()
        components.year = 2025
        components.month = 4
        components.day = 3
        components.hour = 15
        components.minute = 52
        components.second = 0
        let startTime = calendar.date(from: components)!
        
        components.hour = 16
        components.minute = 0
        let endTime = calendar.date(from: components)!
        
        let predicate = HKQuery.predicateForSamples(withStart: startTime,
                                                   end: endTime,
                                                   options: .strictStartDate)
        
        let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
        let query = HKSampleQuery(sampleType: HKSeriesType.heartbeat(),
                                predicate: predicate,
                                limit: HKObjectQueryNoLimit,
                                sortDescriptors: [sortDescriptor]) { (_, samples, _) in
            if let sample = samples?.first as? HKHeartbeatSeriesSample {
                absoluteStartTime = sample.startDate
                let startDateKST = dateFormatter.string(from: sample.startDate)
                let endDateKST = dateFormatter.string(from: sample.endDate)
                print("series start(KST):\(startDateKST)\tend(KST):\(endDateKST)")
                
                let seriesQuery = HKHeartbeatSeriesQuery(heartbeatSeries: sample) {
                    query, timeSinceSeriesStart, precededByGap, done, error in
                    
                    if !precededByGap {
                        timePoints.append(timeSinceSeriesStart)
                    }
                    
                    if done {
                        for i in 1..<timePoints.count {
                            let ibi = (timePoints[i] - timePoints[i-1]) * 1000 // Convert to milliseconds
                            
                            // Calculate absolute time for current beat
                            if let startTime = absoluteStartTime {
                                let beatTime = startTime.addingTimeInterval(timePoints[i])
                                let beatTimeString = dateFormatter.string(from: beatTime)
                                print("IBI: \(String(format: "%.2f", ibi)) ms at \(beatTimeString)")
                            }
                            
                            self.ibiValues.append(ibi)
                        }
                    }
                }
                self.healthStore.execute(seriesQuery)
            } else {
                print("No samples found for the specified time range")
            }
        }
        self.healthStore.execute(query)
    }
}

Yeah, the method your code snippet demonstrates looks right. If you see any problem when going along this path, feel free to follow up here.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Are HKHeartbeatSeriesSample objects generated during an active HKWorkoutSession, or only as companions to system-computed heartRateVariabilitySDNN samples during stationary periods — and is there any supported path to receiving them with sub-5-second latency?

Hi, came across this while working with the same API and wanted to flag something in the snippet that's easy to miss.

The precededByGap handling drops the flagged beat, but the interval loop still diffs every consecutive pair:

swift if !precededByGap { timePoints.append(timeSinceSeriesStart) }

So if beats land at t₁, t₂, [gap], t₅, t₆: t₅ gets discarded, and the loop then computes t₆ − t₂ and reports it as a single IBI. That value spans both the gap and the dropped beat, so you end up with intervals roughly 2–3× a normal one scattered through the output.

The flag is telling you the interval leading into this beat isn't valid, not that the beat's timestamp is bad. So keep every timestamp and skip the interval instead:

swift struct Beat { let time: TimeInterval let precededByGap: Bool } var beats: [Beat] = []

// in the HKHeartbeatSeriesQuery handler: beats.append(Beat(time: timeSinceSeriesStart, precededByGap: precededByGap))

if done { for i in 1..<beats.count { guard !beats[i].precededByGap else { continue } // interval spans a gap let ibi = (beats[i].time - beats[i-1].time) * 1000 self.ibiValues.append(ibi) } }

Two benefits: the invalid interval goes away, and you recover the valid t₆ − t₅ interval the original version was throwing out.

Worth sorting before you benchmark against the Galaxy values, given you're computing stress from HRV. RMSSD squares successive differences, so one inflated interval can dominate a window, and in the frequency domain it injects broadband energy right across the LF/HF boundary. A couple of these per window will move your metric more than any real difference between the two devices.

Accuracy of IBI Values Measured by Apple Watch
 
 
Q