Skip to content

feat(store): persist the WHOOP 5.0 v26 raw PPG waveform (#156 follow-up) - #415

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:ppg-waveform-capture
Jul 15, 2026
Merged

feat(store): persist the WHOOP 5.0 v26 raw PPG waveform (#156 follow-up)#415
ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:ppg-waveform-capture

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

Summary

The WHOOP 5.0 strap sends a ~24 Hz raw optical PPG waveform in type-47 v26 records during v26-heavy stretches of a night. NOOP already fully decodes it (ppg_waveform, 24 little-endian i16 ADC samples/record — see decodeWhoop5HistoricalV26 in Interpreter.swift), but the samples were only ever used to derive a per-second HR estimate (PpgHr.derivePpgHrppgHrSample, v12) and then discarded. rejectedHistoricalRecords even explicitly excludes v26 from the undecodable-record reject archive as "known-and-unstored by design" — so a fully-decoded biometric signal had no durable home at all.

This is a storage-only change: no BLE subscription/command is added or modified. The waveform is already received and decoded; this just stops throwing it away.

Storage approach

A new table (ppgWaveformSample), not the reject archive:

  • The reject archive (RawHistoryArchive / rejectedHistoricalRecords) exists for genuinely-undecodable frames (CRC failure, unmapped layout). A v26 record that decodes fine was never one of those, so re-routing it there would be a category error — and it's why v26 is excluded from that path unconditionally (even on a CRC failure), independent of this change.
  • A queryable table is more useful than an opaque archive blob: it can be read back per-device/time-range (ppgWaveformSamples(deviceId:from:to:)) for a future re-analysis (a better HR estimator, HRV-from-PPG, a waveform viewer) without re-parsing raw frames.
  • Shape: one row per (deviceId, ts) — the same key as every sibling per-second stream (hrSample, spo2Sample, ppgHrSample, ...) — but the 24 samples are packed into a compact BLOB (little-endian i16, 2 bytes/sample) instead of 24 scalar rows. That keeps a v26-heavy night to roughly the same order of magnitude as one extra per-second stream (~50 bytes/row), not 24x that.

Retention

No new pruning — this matches every other durable per-second table (hrSample, spo2Sample, etc. are never pruned either); it's decoded biometric history, not the transient raw outbox. PrunePolicy's ~50 MB cap governs only rawBatch (pre-decode raw frames kept for re-sync) and is untouched by and unrelated to this table. Growth is bounded by how much v26 data a strap actually emits (firmware picks v26 vs v18 per second; not every night is v26-heavy), not by an artificial cap, and the compact packed-BLOB encoding keeps the per-record cost small regardless.

Changes

  • Packages/WhoopProtocol: Streams gains ppgWaveform: [PpgWaveformSample] (ts + raw samples: [Int]). extractHistoricalStreams populates it alongside the existing ppgHr derivation — same (ts, samples) the HR estimator already collects, just no longer discarded after. isEmpty/CodingKeys/the custom decoder are updated so a waveform-only decode (too little context for a confident HR estimate — PpgHr.derivePpgHr needs >= 3 consecutive seconds) still counts as decoded data for the Backfiller's silent-data-loss diagnostic, and older JSON fixtures without the key still decode (decodeIfPresent ... ?? [], mirroring every prior stream addition).
  • Packages/WhoopStore: new migration v27-ppg-waveform creates ppgWaveformSample (deviceId, ts, samples BLOB, PK (deviceId, ts)). packPpgSamples/unpackPpgSamples do the little-endian i16 packing (handles any sample count — a truncated frame can decode fewer than 24 — and drops a corrupt trailing odd byte on read rather than throwing). Wired into the existing insert(_:deviceId:) choke point as a persist-only block (mirrors steps/sleepState/ppgHr: not added to the 8-field return tuple), so Collector/Backfiller/RawHistoryArchive.replay all pick it up with no changes of their own — they already just pass Streams straight through to store.insert.
  • Added ppgWaveformSample to DeviceRegistryStore.deviceScopedTables — caught by the existing testDeviceScopedTablesCoversEveryDeviceIdKeyedTable coverage test, since without it "delete all of this device's data" would silently leave orphaned waveform rows behind.
  • Updated the now-stale "known-and-unstored by design" comments in HistoricalStreams.swift (rejectedHistoricalRecords) to reflect that the waveform now has its own durable stream.

Android parity

Kotlin twin included + gradle-tested. Android straps connect, so this twins BOTH the schema and the store-write plumbing:

  • Room migration v19→v20 + PpgWaveformSampleEntity (deviceId, ts, samples BLOB; PK (deviceId, ts); column order == entity field order) mirror the GRDB v27-ppg-waveform table so the .noopbak schema stays byte-identical, pinned by PpgWaveformMigrationTest. (On the Android branch v19 is already the efficiency-heal migration = Swift v26, so the PPG table takes v20 = Swift's v27 — the migration shape, not the number, is the contract.)
  • StreamPersistence.packPpgSamples/unpackPpgSamples do the little-endian i16 packing, byte-identical to the Swift packer, so the stored BLOB round-trips a .noopbak across platforms.
  • extractHistoricalStreams now also collects the raw per-second waveform into StreamBatch.ppgWaveform; WhoopRepository.insert packs + persists it (IGNORE-dedupe by (deviceId, ts)); ppgWaveformSamples(...) reads it back. ppgWaveformSample is added to the device-data wipe (privacy parity).

Tests: PpgWaveformMigrationTest (schema + pack/unpack + insert wiring) and Whoop5PpgWaveformStreamTest (extraction over the same real v26 fixture the Swift Whoop5PpgWaveformTests uses). ./gradlew :app:testFullDebugUnitTest green.

Tests

  • Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5PpgWaveformTests.swift: extractHistoricalStreams populates ppgWaveform from a real captured v26 frame even when the HR estimator has too little context to produce anything (proving the fix — before this change that record would have vanished entirely); Streams.isEmpty correctly treats a waveform-only decode as non-empty; JSON decode tolerates a missing/present ppg_waveform key and round-trips (including negative AC-coupled samples).
  • Packages/WhoopStore/Tests/WhoopStoreTests/PpgWaveformSampleTests.swift: the v27 migration creates the table with the right PK/columns; insert + read round-trips a real captured waveform exactly; re-insert is idempotent (ON CONFLICT DO NOTHING); range/device-scoping is respected; a short (< 24) sample array round-trips (the format isn't hardcoded to a fixed count); the pack/unpack codec round-trips signed extremes (-32768/32767) and drops a corrupt trailing odd byte instead of crashing.
  • swift test is green in both packages: WhoopProtocol 281 tests, WhoopStore 266 tests (0 failures).
  • macOS: xcodegen generate && xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO buildBUILD SUCCEEDED, no errors. (No app-target/Collect code needed to change — the Collector/Backfiller already pass Streams generically to store.insert.)

Test plan

  • cd Packages/WhoopProtocol && swift test — 281 tests, 0 failures
  • cd Packages/WhoopStore && swift test — 266 tests, 0 failures
  • xcodegen generate && xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build — BUILD SUCCEEDED
  • On-device verification of a real v26-heavy offload populating ppgWaveformSample (this PR was built/tested in an isolated worktree without a paired strap; welcome for a maintainer or follow-up to confirm on hardware)

vishk23 added 2 commits July 13, 2026 20:25
The strap's 24 Hz optical PPG buffer (type-47 v26 records) was fully decoded
(ppg_waveform, 24 i16 ADC samples/record) but only ever used to derive a
per-second HR estimate (ppgHrSample, v12) — the waveform itself was discarded
right after, and explicitly excluded from the undecodable-record reject
archive as "known-and-unstored by design". This reverses that: the samples
now survive in their own durable stream.

- WhoopProtocol: Streams gains ppgWaveform: [PpgWaveformSample] (ts + raw i16
  samples), populated by extractHistoricalStreams alongside the existing
  ppgHr derivation. isEmpty/CodingKeys/decoder updated so a waveform-only
  decode (too little context for a confident HR estimate) still counts as
  decoded data, and older JSON fixtures without the key still decode.
- WhoopStore: new v27 migration creates ppgWaveformSample (deviceId, ts,
  samples BLOB), one row per second like every sibling stream, but with the
  24 samples packed as compact little-endian i16 (2 bytes/sample) instead of
  24 scalar rows — a v26-heavy night costs about the same order of magnitude
  as one extra per-second stream, not 24x that. Wired into the existing
  insert(_:deviceId:) choke point (persist-only, mirrors steps/sleepState/
  ppgHr) so the Collector/Backfiller paths pick it up with no changes of
  their own. Added ppgWaveformSample to DeviceRegistryStore.deviceScopedTables
  so "delete all of this device's data" clears it too (caught by the
  existing coverage test).
- No new pruning: matches every other durable per-second table (hrSample,
  spo2Sample, ... are never pruned either). This is decoded biometric
  history, not the transient raw outbox — PrunePolicy's ~50 MB cap governs
  only rawBatch and is untouched.
- Android's Kotlin HistoricalStreams.kt/PpgHr.kt has the identical
  decode-then-discard (only ppgHrSample persists); no Room table or entity
  exists for the raw waveform. Left as a follow-up, not implemented here.

swift test is green in both packages (WhoopProtocol 281 tests, WhoopStore
266 tests), and the macOS Strand scheme builds clean.
…with PR #415)

Ports the durable WHOOP 5.0 v26 optical-PPG waveform storage from WhoopStore
(issue #156 follow-up). WHOOP straps connect on Android, so this twins BOTH the
Room schema AND the store-write plumbing:

- Room migration v19 -> v20 + @entity ppgWaveformSample (deviceId, ts, samples BLOB;
  PK deviceId, ts), matching the GRDB v27-ppg-waveform table. Pinned by
  PpgWaveformMigrationTest. (v19 is the efficiency-heal on this branch, so the PPG
  table takes v20, twinning Swift's v27 = next slot after v26.)
- StreamPersistence.packPpgSamples/unpackPpgSamples: little-endian i16, BYTE-identical
  to the Swift WhoopStore packer so a .noopbak round-trips.
- extractHistoricalStreams now also collects the raw per-second waveform into
  StreamBatch.ppgWaveform; WhoopRepository.insert packs + persists it (IGNORE-dedupe
  by deviceId, ts); ppgWaveformSamples() reads it back.
- DeviceRegistry device-data wipe clears ppgWaveformSample too (privacy parity).

Tests: PpgWaveformMigrationTest (schema + pack/unpack + insert wiring) and
Whoop5PpgWaveformStreamTest (extraction over the same real v26 fixture the Swift
Whoop5PpgWaveformTests uses).
@ryanbr
ryanbr merged commit ee1de9a into ryanbr:main Jul 15, 2026
8 checks passed
digitalerdude added a commit to digitalerdude/noop that referenced this pull request Jul 17, 2026
Upstream landed feat(store): persist v26 raw PPG waveform (ryanbr#415), which took
GRDB v27-ppg-waveform and Room MIGRATION_19_20 — the exact slots this PR
previously used. Renumbered the ppgRespSample migration to GRDB
v28-ppg-resp-sample / Room MIGRATION_20_21 (schemaVersion 21), keeping both
platforms' migration counts aligned, and updated the two migration tests'
version-pair assertions + doc comments to match. The waveform table (ryanbr#415)
and this resp table are three streams off the same v26 buffer, each in its
own table, no schema overlap.
digitalerdude added a commit to digitalerdude/noop that referenced this pull request Jul 17, 2026
Upstream landed feat(store): persist v26 raw PPG waveform (ryanbr#415), which took
GRDB v27-ppg-waveform and Room MIGRATION_19_20 — the exact slots this PR
previously used. Renumbered the ppgRespSample migration to GRDB
v28-ppg-resp-sample / Room MIGRATION_20_21 (schemaVersion 21), keeping both
platforms' migration counts aligned, and updated the two migration tests'
version-pair assertions + doc comments to match. The waveform table (ryanbr#415)
and this resp table are three streams off the same v26 buffer, each in its
own table, no schema overlap.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ollow-up) (ryanbr#415)

* feat(store): persist the WHOOP 5.0 v26 raw PPG waveform (ryanbr#156 follow-up)

The strap's 24 Hz optical PPG buffer (type-47 v26 records) was fully decoded
(ppg_waveform, 24 i16 ADC samples/record) but only ever used to derive a
per-second HR estimate (ppgHrSample, v12) — the waveform itself was discarded
right after, and explicitly excluded from the undecodable-record reject
archive as "known-and-unstored by design". This reverses that: the samples
now survive in their own durable stream.

- WhoopProtocol: Streams gains ppgWaveform: [PpgWaveformSample] (ts + raw i16
  samples), populated by extractHistoricalStreams alongside the existing
  ppgHr derivation. isEmpty/CodingKeys/decoder updated so a waveform-only
  decode (too little context for a confident HR estimate) still counts as
  decoded data, and older JSON fixtures without the key still decode.
- WhoopStore: new v27 migration creates ppgWaveformSample (deviceId, ts,
  samples BLOB), one row per second like every sibling stream, but with the
  24 samples packed as compact little-endian i16 (2 bytes/sample) instead of
  24 scalar rows — a v26-heavy night costs about the same order of magnitude
  as one extra per-second stream, not 24x that. Wired into the existing
  insert(_:deviceId:) choke point (persist-only, mirrors steps/sleepState/
  ppgHr) so the Collector/Backfiller paths pick it up with no changes of
  their own. Added ppgWaveformSample to DeviceRegistryStore.deviceScopedTables
  so "delete all of this device's data" clears it too (caught by the
  existing coverage test).
- No new pruning: matches every other durable per-second table (hrSample,
  spo2Sample, ... are never pruned either). This is decoded biometric
  history, not the transient raw outbox — PrunePolicy's ~50 MB cap governs
  only rawBatch and is untouched.
- Android's Kotlin HistoricalStreams.kt/PpgHr.kt has the identical
  decode-then-discard (only ppgHrSample persists); no Room table or entity
  exists for the raw waveform. Left as a follow-up, not implemented here.

swift test is green in both packages (WhoopProtocol 281 tests, WhoopStore
266 tests), and the macOS Strand scheme builds clean.

* feat(android): Kotlin twin of the v26 raw PPG waveform store (parity with PR ryanbr#415)

Ports the durable WHOOP 5.0 v26 optical-PPG waveform storage from WhoopStore
(issue ryanbr#156 follow-up). WHOOP straps connect on Android, so this twins BOTH the
Room schema AND the store-write plumbing:

- Room migration v19 -> v20 + @entity ppgWaveformSample (deviceId, ts, samples BLOB;
  PK deviceId, ts), matching the GRDB v27-ppg-waveform table. Pinned by
  PpgWaveformMigrationTest. (v19 is the efficiency-heal on this branch, so the PPG
  table takes v20, twinning Swift's v27 = next slot after v26.)
- StreamPersistence.packPpgSamples/unpackPpgSamples: little-endian i16, BYTE-identical
  to the Swift WhoopStore packer so a .noopbak round-trips.
- extractHistoricalStreams now also collects the raw per-second waveform into
  StreamBatch.ppgWaveform; WhoopRepository.insert packs + persists it (IGNORE-dedupe
  by deviceId, ts); ppgWaveformSamples() reads it back.
- DeviceRegistry device-data wipe clears ppgWaveformSample too (privacy parity).

Tests: PpgWaveformMigrationTest (schema + pack/unpack + insert wiring) and
Whoop5PpgWaveformStreamTest (extraction over the same real v26 fixture the Swift
Whoop5PpgWaveformTests uses).
@vishk23
vishk23 deleted the ppg-waveform-capture branch July 30, 2026 19:59
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.

2 participants