Export overnight beat-to-beat (HKHeartbeatSeriesSample) to Apple Health so Health readers can compute RMSSD #599
Description
The gap
On iOS the nightly HRV sample is written as HEART_RATE_VARIABILITY_SDNN (healthHrvExportValue, the sdnn_window since #315), because SDNN is HealthKit's only HRV quantity type. On Android the same export writes RMSSD to Health Connect.
So on iPhone, OpenStrap's RMSSD never reaches Apple Health. That matters for any app that aggregates from Health and uses RMSSD for recovery. Bevel is the one I use: its default and recommended HRV method is RMSSD, which it computes itself from heartbeat series (HKHeartbeatSeriesSample) during sleep. Today only an Apple Watch writes those. With a WHOOP band on OpenStrap the choice is:
- Bevel set to RMSSD: no HRV at all on OpenStrap nights (no beat-to-beat data in Health).
- Bevel set to SDNN: it shows the single nightly SDNN, which isn't comparable with RMSSD from other devices (for example, a Garmin through Bevel's direct integration).
#315 was the same mismatch from the other side. The sdnn_window fix stopped the 2× inflation, but SDNN is still not what RMSSD-based readers want.
Proposal
Write the night's beats to HealthKit as HKHeartbeatSeriesSamples, so a reader can compute RMSSD from the same beats OpenStrap does.
- Which beats: the primary band's own beats (
decoded_rr,kPrimaryBandSourceSql), inside the night's sleep window. Same "band's own data only" rule the minute-HR export follows. - Which clock:
correctRr(rr, rrTsMs: …).nnTimesMs, the clock the nightly RMSSD runs on. It cumsums measured R-R inside a contiguous run and re-anchors at dropouts. A spline-corrected beat keeps its real time, so only measured instants are written and nothing interpolated leaves the app. - Gaps:
precededByGap = trueunless the elapsed time from the previous exported beat equals its cleaned interval. That covers dropped artifact runs, dropouts and corrected beats. A reader that honours the flag pairs the same intervalshrvTimepairs, minus the few next to a corrected beat. - Shape: one series per ≤5 min. Apple Watch writes about 1 min per series; 5 min is the standard short-term window and the one
sdnn_windowalready uses. A night is about 100 series. - Idempotent: delete our own heartbeat series in the sleep replace's cleanup window, then write, same as
HealthKitSleepWriter. A night whose beats are already pruned is skipped, not cleared. - Authorization: the
healthplugin has no heartbeat-series type, so a nativerequestAuthorization(toShare: [HKSeriesType.heartbeat()])runs alongside the plugin grant inHealthExporter.request().
No change to any metric, kAlgoVersion, the schema, or Android.
Open questions for maintainers
- Will readers use non-Watch heartbeat series? Bevel's docs only describe computing RMSSD from Apple Watch beat-to-beat data. I can't confirm it reads series from other sources until a build with this lands on a device.
- Volume: about 25k beats per night as about 100 builder saves, re-run whenever a non-finalized day re-exports. Acceptable, or should the heartbeat write wait until the day is finalized?
- Backfill: should
kHealthSleepExportEpochbe bumped so nights still indecoded_rrget series retroactively? - iOS 27: if HealthKit's reported RMSSD quantity type is real, writing the nightly
rmssdscalar there too would be the simpler complement. I couldn't verify that type exists.
I have a branch implementing this (Dart builder + native writer + tests) and can open a PR if the approach looks right.
Activity