Skip to content

Export overnight beat-to-beat (HKHeartbeatSeriesSample) to Apple Health so Health readers can compute RMSSD #599

Open

Description

@RyanBeckett

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 = true unless 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 intervals hrvTime pairs, 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_window already 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 health plugin has no heartbeat-series type, so a native requestAuthorization(toShare: [HKSeriesType.heartbeat()]) runs alongside the plugin grant in HealthExporter.request().

No change to any metric, kAlgoVersion, the schema, or Android.

Open questions for maintainers

  1. 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.
  2. 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?
  3. Backfill: should kHealthSleepExportEpoch be bumped so nights still in decoded_rr get series retroactively?
  4. iOS 27: if HealthKit's reported RMSSD quantity type is real, writing the nightly rmssd scalar 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions