From 11b34c3baf799181f52fdf35b6a618a0985a2112 Mon Sep 17 00:00:00 2001 From: sunny_noop Date: Mon, 10 Aug 2026 13:07:38 +0200 Subject: [PATCH] v18: resolve the sleep_state nibble ordering, correct 36/37, 40 and 82 Adds Gen5SleepState (0 wake / 1 still / 2 sleep / 3 up) and fixes three field docs that overstated what the bytes carry. --- lib/openstrap_protocol.dart | 1 + lib/src/gen5_records.dart | 107 ++++++++++++++++++++++++--------- test/gen5_historical_test.dart | 76 +++++++++++++++++++++-- 3 files changed, 151 insertions(+), 33 deletions(-) diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 1c97915..892d6f7 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -24,6 +24,7 @@ export 'src/gen5_records.dart' Gen5ImuBuffer, Gen5PpgWaveform, Gen5RecordDecoder, + Gen5SleepState, Gen5V18Decoder, Gen5V20Decoder, Gen5V21Decoder, diff --git a/lib/src/gen5_records.dart b/lib/src/gen5_records.dart index 97a0054..b4650c2 100644 --- a/lib/src/gen5_records.dart +++ b/lib/src/gen5_records.dart @@ -100,6 +100,30 @@ abstract class Gen5HistoricalRecord { // ── v18 — per-second biometric summary (the gen5 analogue of gen4's v24; // this is the record that actually ships as gen5's "1 Hz" history). ──────── +/// The band's own coarse wake/sleep state, from bits 4-5 of +/// [Gen5HistorySample.sleepStateByte] (frame-abs 81). +/// +/// Two things to know before consuming it: +/// +/// - **It is an envelope, not a stage.** Deep, light and REM all read as +/// [sleep], so it carries no in-sleep stage information. +/// - **[sleep] lags true onset** by roughly ten minutes — the band wants a +/// sustained stretch of stillness before it commits. +enum Gen5SleepState { + /// Nibble 0. Awake/active — the highest-motion, highest-HR state. + wake, + + /// Nibble 1. Settling: still, but not yet declared asleep. The only path + /// from [wake] to [sleep]. + still, + + /// Nibble 2. The band has declared sleep. Lowest motion, lowest HR. + sleep, + + /// Nibble 3. Out of bed / arousal. Reachable only from [sleep]. + up, +} + /// Decoded gen5 v18 historical record. Field confidence/status is annotated /// per-field below — several fields have OPEN semantic disagreements between /// the two reference implementations (whoop-rs vs noop) that could not be @@ -120,23 +144,37 @@ class Gen5HistorySample extends Gen5HistoricalRecord { final int cardiacFlags; /// @ inner[28] (frame-abs 36). bit7 = HR/RR-valid this second (gates - /// whether [heartRateAlt] should be trusted); bit4 never observed set. + /// whether [heartRateAlt] should be trusted); the low 4 bits are a separate + /// small field — see [hrQualityCounter]. + /// + /// NOT the low half of a fixed-point heart rate. **bit4 is never set** — 0 + /// of 2,663,358 records across two bands — and a fractional byte cannot + /// have a structurally dead bit. The low 4 bits are a separate field, + /// uniform over 0..15; bits 5-7 are flags. final int hrQualityFlags; - /// Duplicate HR @ inner[29] (frame-abs 37), ~99.6% match to [heartRate] on - /// the reference corpus. Trust only when `hrQualityFlags & 0x80 != 0`. + /// Low 4 bits of [hrQualityFlags] — a small field, uniform over 0..15, + /// independent of bit7. Purpose unknown; exposed for diagnostics only. + int get hrQualityCounter => hrQualityFlags & 0x0F; + + /// Second heart-rate byte @ inner[29] (frame-abs 37). + /// + /// Not the near-duplicate of [heartRate] this was previously documented as: + /// it agrees 58-64% of the time, rising to 67-75% when + /// [hrRrValidThisSecond]. The gate is real, but even gated this is a + /// corroboration signal — do not substitute it for [heartRate]. final int heartRateAlt; /// @ inner[30:32] (frame-abs 38). Meaning UNPINNED — exposed raw, do not /// consume as a decoded value yet. final int rrPacked; - /// @ inner[32] (frame-abs 40). DISPUTED: whoop-rs calls this - /// "signal_quality" and gates an HR-anomaly confidence check on `>=192`; - /// noop calls the SAME offset "cardiac_status" and treats it as - /// raw/uninterpreted. Neither claim is cross-validated against ground - /// truth — exposed raw ONLY. Do NOT wire an HR-anomaly gate off this byte - /// until validated against a labelled dataset. + /// @ inner[32] (frame-abs 40). Meaning still unpinned. whoop-rs calls it + /// "signal_quality" and gates an HR-anomaly check on `>=192` — that gate + /// passes 96.7% of records and its rejections don't track [sleepState] + /// consistently between bands, so it is not doing what it looks like. + /// + /// Exposed raw ONLY. Do NOT wire an HR-anomaly gate off this byte. final int cardiacStatusRaw; /// Gravity-removed motion magnitude (g) @ inner[33:37] f32 LE (frame-abs @@ -189,22 +227,19 @@ class Gen5HistorySample extends Gen5HistoricalRecord { /// Raw @ inner[73] (frame-abs 81). Packed: /// bits 0-1: on-wrist /// bits 2-3: wake_quality - /// bits 4-5: sleep_state — ORDERING UNRESOLVED (whoop-rs's doc comment - /// says "0 still/1 wake"; noop glosses the same shift "0 wake/1 still" - /// in one place. A real fixture's value here was 0, which is - /// ambiguous between both orderings and could not disambiguate this - /// from bytes alone.) Exposed as the raw byte ONLY — do not decode the - /// sleep_state nibble into an enum until a labelled-sleep capture - /// resolves the ordering; wiring the wrong ordering into a sleep - /// stager would silently corrupt every downstream sleep metric. + /// bits 4-5: sleep_state — 0 wake / 1 still / 2 sleep / 3 up. Prefer + /// [sleepState] over reading the nibble yourself. whoop-rs's + /// "0 still / 1 wake" is the wrong way round. final int sleepStateByte; - /// @ inner[74] (frame-abs 82). EXPERIMENTAL candidate SpO2% — noop treats - /// this as instrumentation-only with cross-device evidence split, never a - /// shipped metric (whoop-rs's `physio-algo` treats it as legitimate; noop's - /// caution is trusted here per §1.7 — larger, multi-strap corpus). Use - /// [spo2Candidate] for the gated (70-100) getter; never present this - /// alongside gen4's real red/IR SpO2 as an equivalent metric. + /// @ inner[74] (frame-abs 82). **NOT SpO2** — the name is kept only because + /// removing it breaks the API. Treat it as an opaque sleep-gated byte. + /// + /// It reads 0 in 99% of records and is *identically* zero unless + /// [sleepState] is [Gen5SleepState.sleep], where it fires on 2.4% of + /// records. A blood-oxygen channel does not switch itself off while the + /// band is worn. The values that do appear cluster at 95-99, which is what + /// makes the byte look like SpO2 in the first place. final int spo2CandidateRaw; /// @ inner[98] (frame-abs 106). Proven NOT the high half of a u16 with @@ -262,10 +297,20 @@ class Gen5HistorySample extends Gen5HistoricalRecord { bool get hrRrValidThisSecond => (hrQualityFlags & 0x80) != 0; /// [heartRateAlt] gated on [hrRrValidThisSecond]; null when unconfirmed. + /// + /// Note that "confirmed" still only means 67-75% agreement with + /// [heartRate] — see [heartRateAlt]. This is a corroboration signal, not a + /// substitute HR. int? get trustedHeartRateAlt => hrRrValidThisSecond ? heartRateAlt : null; - /// EXPERIMENTAL candidate SpO2%, gated 70-100 per §1.7; null otherwise. This - /// is NOT gen4's real dual-wavelength SpO2 — never surface it as equivalent. + /// **Do not use — frame-abs 82 is not SpO2.** The byte is zero in 99% of + /// records and nonzero *only* while [sleepState] is [Gen5SleepState.sleep], + /// so this getter surfaces a plausible-looking 95-99 on 0.6% of records and + /// null on the rest. See [spo2CandidateRaw] for the measurements. + @Deprecated( + 'frame-abs 82 is not SpO2: zero in 99% of records and nonzero only ' + 'during band-declared sleep. Read spo2CandidateRaw if you need the byte.', + ) int? get spo2Candidate => (spo2CandidateRaw >= 70 && spo2CandidateRaw <= 100) ? spo2CandidateRaw : null; @@ -275,16 +320,20 @@ class Gen5HistorySample extends Gen5HistoricalRecord { /// amplitude reading of 128 on each channel. bool get isOpticalAmpSentinel => opticalAmpA == 128 && opticalAmpB == 128; - /// bits 0-1 of [sleepStateByte] — the one sub-field of that byte NOT under - /// active ordering dispute. + /// bits 0-1 of [sleepStateByte]. int get onWristRaw => sleepStateByte & 0x03; /// bits 2-3 of [sleepStateByte]. int get wakeQualityRaw => (sleepStateByte >> 2) & 0x03; - /// bits 4-5 of [sleepStateByte] — RAW ONLY. See [sleepStateByte]'s doc for - /// why this is deliberately not decoded into a named sleep-state enum. + /// bits 4-5 of [sleepStateByte], raw. Prefer [sleepState]. int get sleepStateRawNibble => (sleepStateByte >> 4) & 0x03; + + /// The band's own coarse wake/sleep state. Total over the 2-bit nibble, so + /// never null. **A wake/sleep envelope, not a sleep stage** — see + /// [Gen5SleepState] for the evidence and the limits. + Gen5SleepState get sleepState => + Gen5SleepState.values[sleepStateRawNibble]; } /// Minimum inner length to read every v18 field this decoder touches (the diff --git a/test/gen5_historical_test.dart b/test/gen5_historical_test.dart index a1d7c07..c018ec7 100644 --- a/test/gen5_historical_test.dart +++ b/test/gen5_historical_test.dart @@ -61,10 +61,24 @@ void main() { test('quality flags + alt HR', () { expect(sample.hrQualityFlags, 0x8D); expect(sample.hrRrValidThisSecond, isTrue); // bit7 set + expect(sample.hrQualityCounter, 0x0D); // low 4 bits, separate field expect(sample.heartRateAlt, 101); expect(sample.trustedHeartRateAlt, 101); }); + test('frame-abs 36/37 is NOT one fixed-point HR', () { + // Read as one u16 this fixture gives 0x658D/256 = 101.55 bpm against + // heartRate 102 — and the ".55" is just bit7 of byte 36. + // + // bit4 is never set on any observed record, which a fractional byte + // could not manage. + expect(sample.hrQualityFlags & 0x10, 0); + + // And even with bit7 set, the second HR byte disagrees with heartRate + // by a full bpm — it is a corroboration signal, not a duplicate. + expect(sample.heartRateAlt, isNot(sample.heartRate)); + }); + test('motion: gravity is unit magnitude, dynamic accel small', () { expect(sample.gravityG[0], closeTo(-0.7252, 1e-3)); expect(sample.gravityG[1], closeTo(0.4944, 1e-3)); @@ -95,14 +109,68 @@ void main() { }); test('experimental fields exposed raw, not fabricated', () { - // cardiac_status / signal_quality (disputed offset semantics, §1.7): - // raw byte only, no anomaly gate wired off it. + // frame-abs 40: still unnamed, and the whoop-rs `>=192` gate passes + // 96.7% of records, so no anomaly gate is wired off it. 255 is the + // modal value. expect(sample.cardiacStatusRaw, 255); - // spo2_candidate gated 70..100 — this fixture's raw byte is 0, so the - // gated getter must be null, never a fabricated "0%". + // frame-abs 82 is not SpO2 — zero in 99% of records and nonzero only + // during band-declared sleep. This fixture is awake, so it reads 0. expect(sample.spo2CandidateRaw, 0); + // ignore: deprecated_member_use_from_same_package expect(sample.spo2Candidate, isNull); }); + + test('band sleep state: this fixture is awake', () { + expect(sample.sleepStateByte, 0); + expect(sample.sleepStateRawNibble, 0); + expect(sample.sleepState, Gen5SleepState.wake); + }); + }); + + group('parseGen5Historical — v18 sleep_state nibble ordering', () { + // Same real v18 fixture, with frame-abs 81 (inner[73]) overridden to each + // of the four nibble values. Ordering (0 wake / 1 still / 2 sleep / 3 up) + // was resolved on 400,000 records from two bands: mean dynamic + // acceleration runs 0.0773 / 0.0255 / 0.0104 / 0.0504 g and median heart + // rate 88 / 76 / 60 / 77 bpm across nibbles 0..3, so nibble 0 is the + // highest-motion, highest-HR state (it cannot be "still") and nibble 2 is + // the lowest of both. whoop-rs's "0 still / 1 wake" is reversed. + final frame = hex( + 'aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000' + '000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000' + '000000000000f7000901f10b0007010c020c000000000000000000000000000' + '00000000000000000000100656f1e1e0000009d61a7c00000003e862817', + ); + + Gen5HistorySample decodeWithNibble(int nibble) { + final inner = Uint8List.fromList( + parseFrame(frame, profile: BandProfile.gen5)!.inner, + ); + // Preserve the low bits (on-wrist, wake_quality) — only bits 4-5 move. + inner[73] = (inner[73] & 0x0F) | (nibble << 4); + return parseGen5Historical(inner) as Gen5HistorySample; + } + + test('each nibble maps to its named state', () { + expect(decodeWithNibble(0).sleepState, Gen5SleepState.wake); + expect(decodeWithNibble(1).sleepState, Gen5SleepState.still); + expect(decodeWithNibble(2).sleepState, Gen5SleepState.sleep); + expect(decodeWithNibble(3).sleepState, Gen5SleepState.up); + }); + + test('the nibble is masked to 2 bits and never overruns the enum', () { + for (int b = 0; b <= 255; b++) { + final inner = Uint8List.fromList( + parseFrame(frame, profile: BandProfile.gen5)!.inner, + ); + inner[73] = b; + final s = parseGen5Historical(inner) as Gen5HistorySample; + expect(s.sleepStateRawNibble, (b >> 4) & 0x03); + expect(s.sleepState, Gen5SleepState.values[(b >> 4) & 0x03]); + expect(s.onWristRaw, b & 0x03); + expect(s.wakeQualityRaw, (b >> 2) & 0x03); + } + }); }); group('parseGen5Historical — v26 (real fixture)', () {