Skip to content

feat(health): write each night's beat-to-beat intervals to Apple Health as heartbeat series - #2282

Draft
Iskrata wants to merge 5 commits into
ryanbr:mainfrom
Iskrata:feat/health-heartbeat-series
Draft

Iskrata wants to merge 5 commits into
ryanbr:mainfrom
Iskrata:feat/health-heartbeat-series

Conversation

@Iskrata

@Iskrata Iskrata commented Sep 16, 2026

Copy link
Copy Markdown

Stacked on #2281 (it reuses sleepPlan(sessions:)). Review only the last commit.

What this PR does

NOOP's only HRV export to Apple Health was a single nightly SDNN value. A reader that computes its own HRV gets nothing to work from. Bevel's Recovery, for example, computes HRV from beat-to-beat measurements inside the sleep window, and shows no Recovery when there are none.

This PR writes each finished night's R-R intervals as HKHeartbeatSeriesSamples, in 5-minute chunks:

  • Beat layout (HealthWriteback.heartbeatSeriesPlan): each beat lands one interval after the previous one, not on its whole-second row stamp. A row more than 2 s off from that prediction starts a gap (precededByGap).
  • Trust (HRVAnalyzer.beatSeriesIsExportable): a night is exported only if its beats pass both checks NOOP already applies before trusting this statistic itself. successiveDiffIsTrustworthy rules out over-counted beats; beatValuesAreTrustworthy rules out a record period decomposed across one timestamp. No reader gets intervals NOOP itself would refuse.
  • Idempotence (heartbeatSyncPlan): a night is rewritten only when its fingerprint changes, and cleared if it stops being exportable. Series samples are immutable, so a rewrite first deletes that night's series by HKMetadataKeyExternalUUID (noop:heartbeat:<startTs>), scoped to our own HKSource. The fingerprint is saved only after the whole night is written, so a failure partway retries.
  • Authorization: HKSeriesType.heartbeat() is added to the share types. Existing installs get asked once, in the foreground, through the existing re-ask for newly added write types.

Health Connect has no beat-to-beat record type, so the new declarations carry platform_specific parity dispositions.

Type of change

  • New feature

How it was tested

  • swift test in Packages/StrandImport (329, including beat layout, gaps, 5-minute split, sync plan) and Packages/StrandAnalytics (2024, including new HRVBeatSeriesExportTests: clean train accepted, over-counted and decomposed nights refused).
  • Parity-governance suite OK; ratchet shows 0 errors; doc_comment_lint.py clean.
  • NOOPiOS builds for device and is installed on an iPhone 16 with a WHOOP 5.0. Draft until an overnight run shows the night's heartbeat series in Apple Health (Heart → Heart Rate Variability → beat-to-beat) and Bevel computing Recovery from it.

Checklist

  • Swift package tests pass for any package I touched (StrandImport, StrandAnalytics)
  • No new build warnings introduced
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

None open for this; related HRV-trust context in #1118.

…t at its wake

Resting HR, HRV, SpO2 and respiratory rate were each written as one instant at the day's latest wake,
which sits exactly on the sleep window's boundary. A reader that selects them by that window cannot
count on a boundary sample: Bevel, for one, shows no Recovery when HRV or resting HR is not captured
during the sleep window. The latest wake could also belong to a nap after the main sleep.

They are now stamped at the midpoint of the longest bridged night that woke on that day, from the same
night plan the sleep write exports (factored out of `writeSleep` so the two cannot disagree). Keys are
unchanged, so the next write-back replaces the wake-stamped samples. `vitalsInstantByDay` is pure and
covered in `HealthWritebackTests`.
…th as heartbeat series

NOOP exported HRV to Apple Health only as one nightly SDNN value. A reader that computes its own HRV
has nothing to compute from: Bevel's Recovery, for one, reads HRV from beat-to-beat measurements inside
the sleep window, and shows no Recovery without it.

Each finished night's R-R intervals are now written as `HKHeartbeatSeriesSample`s in 5-minute chunks.
Beats are laid out one interval after the previous rather than on their whole-second row stamps, and a
row that disagrees with that by more than 2 s starts a gap (`HealthWriteback.heartbeatSeriesPlan`).

Only nights whose beats clear both gates NOOP applies before trusting the same statistic itself are
written — no over-counted beats, and individually accurate values rather than a record period decomposed
across one timestamp (`HRVAnalyzer.beatSeriesIsExportable`) — so no reader is handed intervals NOOP would
refuse. A night is rewritten only when its fingerprint moves, and cleared if it stops being exportable;
series are immutable, so a rewrite deletes the night's series by key under our own `HKSource` first. The
fingerprint is recorded only once the whole night is written, so a failure mid-night retries.

Heartbeat series joins the share types, so an existing install is asked once, in the foreground, through
the existing re-ask for newly added write types. Health Connect has no beat-to-beat record type, so the
new declarations carry `platform_specific` parity dispositions.
@Iskrata
Iskrata force-pushed the feat/health-heartbeat-series branch from 9c36572 to f616b27 Compare September 17, 2026 12:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant