v18: resolve the sleep_state nibble ordering, correct 36/37, 40 and 82 - #23
Open
sunny-noop wants to merge 1 commit into
Open
v18: resolve the sleep_state nibble ordering, correct 36/37, 40 and 82#23sunny-noop wants to merge 1 commit into
sunny-noop wants to merge 1 commit into
Conversation
📝 WalkthroughWalkthroughThe PR adds public ChangesGen5 v18 decoding
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
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. Comment |
Adds Gen5SleepState (0 wake / 1 still / 2 sleep / 3 up) and fixes three field docs that overstated what the bytes carry.
sunny-noop
force-pushed
the
fix/v18-field-semantics
branch
from
August 10, 2026 11:28
3f02686 to
11b34c3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 v24record 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 upThree 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
wake_qualityis nonzero only when the state nibble is 0. Zero exceptions, both bands, 2.66Mrecords. 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 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 wakethe 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->2never occurs, so state 1 is the obligatory waypoint intosleep, 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%). Andsleeplags true onset by roughlyten 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 cleanlyinto two halves, and neither behaves like the fraction of a heart rate.
High nibble, every observed value:
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.55bpm againstheartRate = 102, where the.55is just bit 7.I've exposed the low nibble as
hrQualityCounterfor diagnostics without claiming to know what itcounts.
3. heartRateAlt is not a ~99.6% duplicate of heartRate
hrRrValidThisSecondtruehrRrValidThisSecondfalseThe 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
>=192gate on frame-abs 40 is close to a no-opIt 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
Nonzero rate by sleep state:
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
spo2Candidaterather than removing it. Renamingspo2CandidateRawor dropping the getter outright isa breaking change and your call, not mine.
What changed
Gen5SleepStateenum plus asleepStategetter, ahrQualityCountergetter,@Deprecatedonspo2Candidate, and corrected docs on the four fields above. No decoded value changes, so the frozenparity oracle is untouched. I checked
edgeandanalyticsfor consumers ofspo2Candidateand thebyte-81 getters and found none, so the deprecation shouldn't ripple.
Verification
dart analyzeclean,dart test163 passed (4 new, plus the usual 4 skips for the capture replay setthat 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.