Skip to content

v18: resolve the sleep_state nibble ordering, correct 36/37, 40 and 82 - #23

Open
sunny-noop wants to merge 1 commit into
OpenStrap:mainfrom
sunny-noop:fix/v18-field-semantics
Open

v18: resolve the sleep_state nibble ordering, correct 36/37, 40 and 82#23
sunny-noop wants to merge 1 commit into
OpenStrap:mainfrom
sunny-noop:fix/v18-field-semantics

Conversation

@sunny-noop

@sunny-noop sunny-noop commented Aug 10, 2026

Copy link
Copy Markdown

v18: resolve the sleep_state nibble ordering, correct 36/37, 40 and 82

Scope: gen5 v18 only. Every claim below is measured on hist_version == 18. I checked the gen4 v24
record for an equivalent of the sleep-state byte and there isn't one: at the same frame-abs 81 a v24
record takes only 3 distinct values with the high nibble always 0, so nothing here transfers to
records.dart.

Corpus: 2,663,358 v18 records from two bands worn by two different people (550,251 and 2,113,107).
Where the two bands differ I give both rather than pooling. Band A is my own; band B is a second wearer.

Since CONTRIBUTING asks for raw bytes: what follows are complete value distributions rather than a
handful of frames. For a field-semantics question these are the stronger artifact, because a claim like
"this bit is never set" is only meaningful over a corpus. Happy to send frames too if you want them.

1. The sleep_state nibble (frame-abs 81, bits 4-5) is 0 wake / 1 still / 2 sleep / 3 up

Three independent lines agree, and the first one needs no labels at all.

1a. Raw: byte 81 takes only 14 of its 256 possible values, identically on both bands

byte 81 state on_wrist wake_quality band A band B
0x00 0 0 0 196,458 583,902
0x01 0 1 0 74,789 477,824
0x04 0 0 1 34 498
0x05 0 1 1 46 708
0x08 0 0 2 225 1,872
0x09 0 1 2 275 3,416
0x0C 0 0 3 9,425 9,685
0x0D 0 1 3 7,656 14,150
0x10 1 0 0 43,098 133,570
0x11 1 1 0 6,023 36,294
0x20 2 0 0 172,484 658,279
0x21 2 1 0 11,356 97,077
0x30 3 0 0 20,880 67,479
0x31 3 1 0 7,502 28,353

wake_quality is nonzero only when the state nibble is 0. Zero exceptions, both bands, 2.66M
records. A field the existing docs already call wake_quality is populated exclusively in one state, and
that state is the one under dispute. That alone settles which nibble is wake, using nothing but the byte
itself.

1b. The record's own motion and heart rate order the states

state band A n band A mean dynamicAccelerationG band A median HR band B n band B mean band B median HR
0 288,908 0.0755 g 89 bpm 1,092,055 0.1567 g 86 bpm
1 49,121 0.0284 g 78 bpm 169,864 0.0254 g 78 bpm
2 183,840 0.0107 g 60 bpm 755,356 0.0154 g 61 bpm
3 28,382 0.0520 g 79 bpm 95,832 0.0684 g 77 bpm

State 0 has both the most motion and the highest heart rate, so it cannot be "still". State 2 has the
least motion and by far the lowest heart rate, so it is sleep. State 1 sits between them, and state 3
sits above 1 on motion while returning heart rate to daytime levels, which is what an out-of-bed state
looks like. Both bands agree on the ordering.

That makes whoop-rs's 0 still / 1 wake the wrong way round.

1c. The transition graph

Over about 352 h of continuous records the observed edges are 0->1 (51), 1->0 (45), 1->2 (7),
2->3 (13), 3->2 (6), 3->0 (4). 0->2 never occurs, so state 1 is the obligatory waypoint into
sleep, which is exactly what a settling state is for, and state 3 is reachable only from sleep.

1d. Against a reference hypnogram

Cross-checked against an independent per-epoch hypnogram for the same nights: epochs the reference
scores as awake land in {wake, up} 82% of the time.

The same comparison sets the two limits now documented on the enum, because getting either wrong would
be worse than not decoding the field at all. It is a wake/sleep envelope, not a stage. Deep, light
and REM epochs all collapse into sleep (100% / 84% / 74%). And sleep lags true onset by roughly
ten minutes, because the band wants a sustained stretch of stillness before it commits.

2. Frame-abs 36 is not the low half of a fixed-point heart rate

One reading in circulation treats 36-37 as a single u16 with bpm = value/256. Byte 36 splits cleanly
into two halves, and neither behaves like the fraction of a heart rate.

High nibble, every observed value:

bits 4-7 band A band B
0x0 93,988 (17.08%) 715,545 (33.86%)
0x2 8 903
0x4 976 (0.18%) 3,240 (0.15%)
0x6 none 852
0x8 449,101 (81.62%) 1,320,918 (62.51%)
0xA 12 1,068
0xC 6,166 (1.12%) 44,938 (2.13%)
0xE none 25,643 (1.21%)

Only even high-nibble values occur, i.e. bit 4 is never set: 0 of 2,663,358 records. Your docs
already noted "bit4 never observed set", and that observation is the whole answer. A fractional byte cannot
have a structurally dead bit; over 2.66M samples bit 4 would be set about half the time. So bits 5, 6
and 7 are flags, not fraction bits.

Low nibble is uniform over 0..15. Band A counts per value run 27,549 to 45,810; band B 116,749 to
142,474. No value is preferred, which is what a counter or free-running field looks like and not what
the low bits of a heart-rate fraction look like.

The fixture already in this repo shows the consequence: 0x658D / 256 = 101.55 bpm against
heartRate = 102, where the .55 is just bit 7.

I've exposed the low nibble as hrQualityCounter for diagnostics without claiming to know what it
counts.

3. heartRateAlt is not a ~99.6% duplicate of heartRate

band A band B
overall agreement 60.9% 54.8%
hrRrValidThisSecond true 68.1% (n=455,279) 73.3% (n=1,392,567)
hrRrValidThisSecond false 26.5% (n=94,972) 19.1% (n=720,540)

The bit 7 gate is real and worth keeping: agreement rises sharply when it's set, on both bands. But even
gated this is a corroboration signal rather than a second heart rate, so the doc now says so. I'd guess
the 99.6% figure came from a narrower capture, a single worn session rather than full wear cycles.

4. The >=192 gate on frame-abs 40 is close to a no-op

It admits 96.4% of records on band A and 90.3% on band B, so as a filter it removes very little.
The byte takes 228 distinct values with a heavy mode at 255 and a smooth tail below it.

It doesn't behave like a quality measure either. Fraction below 192, split by sleep state, was 1.5% /
6.1% / 5.1% / 12.5% on one band against 2.3% / 11.4% / 21.7% / 15.5% on the other: no consistent
ordering, and more rejection during sleep on the second band, which is backwards for signal quality.

I've left the byte raw and kept your warning against gating on it, now with a number behind it.

5. Frame-abs 82 is not SpO2

band A band B
value == 0 99.19% 99.16%
value in [70,100] 0.58% 0.51%
value > 100 0.09% 0.14%

Nonzero rate by sleep state:

state band A band B
wake 0 of 288,908 0 of 1,092,055
still 0 of 49,121 0 of 169,864
sleep 4,440 of 183,840 (2.42%) 17,792 of 755,356 (2.36%)
up 0 of 28,382 9 of 95,832 (0.009%)

Outside band-declared sleep the byte is zero on band A without exception, and on band B with nine
exceptions out of 1.36M non-sleep records. A blood oxygen channel does not switch itself off while the
band is worn and the front end is running.

Note also that the observed nonzero values are not confined to a plausible saturation range: alongside
the 88-100 cluster the byte takes 2, 3, 4, 8, 16, 20, 24, 32, 36, 40, 52, 56, 73, and 128, 136, 144,
148, 152, 160, 164, 168. Roughly a fifth of distinct nonzero values sit above 100.

The 88-100 cluster is what makes the byte look like SpO2, and the 70-100 gate is the risky part: a
consumer sees a physiologically plausible number with no indication it's absent 99.4% of the time. That
reads to me like the "a wrong field is worse than a missing one" case, so I've deprecated
spo2Candidate rather than removing it. Renaming spo2CandidateRaw or dropping the getter outright is
a breaking change and your call, not mine.

What changed

Gen5SleepState enum plus a sleepState getter, a hrQualityCounter getter, @Deprecated on
spo2Candidate, and corrected docs on the four fields above. No decoded value changes, so the frozen
parity oracle is untouched. I checked edge and analytics for consumers of spo2Candidate and the
byte-81 getters and found none, so the deprecation shouldn't ripple.

Verification

dart analyze clean, dart test 163 passed (4 new, plus the usual 4 skips for the capture replay set
that lives beside the repo). New tests cover the nibble to state mapping, a sweep of all 256 values of
byte 81 confirming the mask never overruns the enum, and an assertion on the existing v18 fixture that
byte 36 has bit 4 clear.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds public Gen5SleepState decoding for the v18 sleep-state nibble, clarifies heart-rate and raw-field semantics, deprecates the SpO2 getter, and adds coverage for masking and adjacent fields.

Changes

Gen5 v18 decoding

Layer / File(s) Summary
Sleep-state contract and decoding
lib/openstrap_protocol.dart, lib/src/gen5_records.dart
The public API exports Gen5SleepState. The decoder maps the two-bit nibble to wake, still, sleep, or up while preserving adjacent fields.
Heart-rate and raw-field semantics
lib/src/gen5_records.dart
The decoder exposes the HR quality counter and documents alternate HR and cardiac fields as corroboration or uninterpreted raw data. The SpO2 getter is deprecated.
Decoder validation
test/gen5_historical_test.dart
Tests validate quality fields, raw values, sleep-state mappings, two-bit masking, and preservation of adjacent fields.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • OpenStrap/protocol#16: Extends the Gen5SleepState decoding and public export changes covered by this PR.

Suggested reviewers: abdulsaheel

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the v18 sleep-state nibble correction and the affected fields, which matches the main changes.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Adds Gen5SleepState (0 wake / 1 still / 2 sleep / 3 up) and fixes three field docs that overstated what the bytes carry.
@sunny-noop
sunny-noop force-pushed the fix/v18-field-semantics branch from 3f02686 to 11b34c3 Compare August 10, 2026 11:28
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