diff --git a/lib/src/control.dart b/lib/src/control.dart index e5f2784..f5fd674 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -190,10 +190,14 @@ RealtimeHr? parseRealtimeHr(Uint8List inner) { final n = inner.length > 9 ? inner[9] : 0; if (n > 0 && inner.length >= 12) { final v = u16(inner, 10); + // 200-2500ms = 24-300bpm; was relaxed to `v>0` without comment, which lets + // corrupted/out-of-range timing noise straight into live RR — reverted. if (v >= 200 && v <= 2500) rr.add(v); } if (n > 1 && inner.length >= 14) { final v = u16(inner, 12); + // 200-2500ms = 24-300bpm; was relaxed to `v>0` without comment, which lets + // corrupted/out-of-range timing noise straight into live RR — reverted. if (v >= 200 && v <= 2500) rr.add(v); } final wearing = inner.length > 18 ? inner[18] == 1 : true; diff --git a/lib/src/live.dart b/lib/src/live.dart index 3883dd8..00133aa 100644 --- a/lib/src/live.dart +++ b/lib/src/live.dart @@ -206,6 +206,11 @@ _Motion _r10Motion(ByteData view, int len) { const activityFloor = 0.05; if (std < activityFloor || n < 24) return _Motion(activity, 0); + // NOTE: was bumped to 25 without comment/test coverage; reverted — no test + // (parity or otherwise) exercises steps_inc/activity from R10, and ±25 + // approaches the 7-40 sample autocorrelation lag range this detrend feeds, + // which risks attenuating genuine step periodicity at slower cadences. + // Re-widen only with a real-capture regression test backing it. const w = 9; final x = List.filled(n, 0); for (int i = 0; i < n; i++) { @@ -292,6 +297,11 @@ DecodedSample? decodeRecord(String hex) { hr: d.hr, activity: 0, stepsInc: 0, + // skinContact is contact QUALITY, not wear (see protocol notes) — the + // parity oracle proves it: 28 real v24 records have hr=87-97 (clearly + // worn) with skinContact<=50. hr>0 is the only signal in this fixture + // that agrees with ground truth on every case; keep it even though it's + // always false for v25 (hr deliberately undecoded there — see parseR24). wristOn: d.hr > 0, recType: recType, ); diff --git a/ts/live.ts b/ts/live.ts deleted file mode 100644 index 0de66a1..0000000 --- a/ts/live.ts +++ /dev/null @@ -1,251 +0,0 @@ -// live.ts — verified WHOOP record decoders for the live + IMU frame types, mirroring -// the reference client / PROTOCOL.md. Companion to records.ts (parse_r24): records.ts -// owns the Type-24 historical substrate; this owns the live/compact + IMU frames. -// All byte-offset knowledge for the band lives in this package (openstrap-protocol); -// the backend is pure orchestration and calls in here to decode. -// -// Each decoded record emits a DecodedSample: -// { ts, hr, activity, steps_inc, wrist_on, rec_type } -// where `activity` is the actigraphy signal = stddev of |accel(g)| over the -// 100-sample IMU window (R10 only; 0 for HR-only records). -// -// Offsets (PROTOCOL.md): -// R10 (rec_type 10, pkt 0x2F live/0x2B): ts@7, hr@17, counter@3, -// accel arrays @85/285/485, gyro @688/888/1088, scale ÷4096 (4096 LSB/g). -// 0x28 (live compact HR): ts@2 (u32 LE), hr@8 (u8), wrist via hr>0. -// 0x2B: same layout as R10 (live R10). -// R24 (rec_type 24): header ts@7, counter@3; spo2@72, skin_temp@70/4, resting_hr@88 (RELATIVE-only, not surfaced here). -// 0x33: live IMU stream — RAW-ONLY, no sample emitted (low decode confidence). -// -// RR/HRV: the historical source is R24 (records.ts, rr_count@18 / rr@19). Live RR is -// also carried by the compact-HR (0x28) and R10 records — see realtimeRr() below. -// The separate "type-17 / Labrador" RR record (count@24/rr@26 in our Python probe) is -// NOT shipped: an independent reference implementation has no such record type and reads -// live RR from 0x28/R10 instead, so our type-17 offsets are uncorroborated. RR unit is -// raw u16 ms (confirmed); callers MUST physiologically -// gate (300–2000 ms) so an unvalidated offset yields droppable garbage, never corruption. - -import { parse_r24 } from './records' - -export interface DecodedSample { - ts: number // unix seconds - hr: number // bpm (0 = off-wrist / no reading) - activity: number // motion magnitude (stddev of |accel(g)|), 0 if no IMU - steps_inc: number // steps detected in this record's IMU window (R10 only) - wrist_on: boolean // worn proxy (hr>0; authoritative wear is WRIST_ON/OFF events) - rec_type: number // 10 | 24 | 28 -} - -export const hexToBytes = (hex: string): Uint8Array => - new Uint8Array(hex.trim().match(/.{1,2}/g)!.map((b) => parseInt(b, 16))) - -/** One IMU frame's accel as ordered magnitude samples (g) + its time + sub-order. */ -export interface ImuFrame { ts: number; idx: number; mags: number[] } - -// frameAccel — decode one IMU frame's accelerometer into ordered |accel|(g) -// samples. Handles BOTH channels the strap streams: -// • 0x33 live IMU stream — ts@4, sub-frame idx@14, 10 accel samples -// (X[0:10],Y[10:20],Z[20:30]) from offset 24, scale 1/4096. -// • R10 (rec 0x0A) — ts@7, 100 accel samples @85/285/485, scale 1/4096. -// Returns null if it isn't an accel-bearing frame. Used by the backend steps -// runner (steps_imu.ts) to rebuild the signal for the AN-2554 pedometer that -// lives in openstrap-analytics (calcSteps). Kept here with the other IMU -// decoders (see r10Motion) so all byte-offset knowledge stays in one place. -export function frameAccel(hex: string): ImuFrame | null { - let b: Uint8Array - try { b = hexToBytes(hex) } catch { return null } - if (b.length < 32) return null - const view = new DataView(b.buffer, b.byteOffset, b.byteLength) - const pkt = b[0], rec = b[1] - // 0x33 IMU stream: 10 accel samples (X,Y,Z) from offset 24. - if (pkt === 0x33 && b.length >= 84) { - const ts = view.getUint32(4, true) - const idx = view.getUint16(14, true) - const mags: number[] = [] - for (let i = 0; i < 10; i++) { - const x = view.getInt16(24 + 2 * i, true) - const y = view.getInt16(24 + 2 * (10 + i), true) - const z = view.getInt16(24 + 2 * (20 + i), true) - mags.push(Math.sqrt(x * x + y * y + z * z) / 4096) - } - return ts > 0 ? { ts, idx, mags } : null - } - // R10: rec 0x0A, ts@7, accel X@85/Y@285/Z@485 (100 int16 each). - if (rec === 0x0a && b.length >= 685) { - const ts = view.getUint32(7, true) - const mags: number[] = [] - for (let i = 0; i < 100; i++) { - const x = view.getInt16(85 + 2 * i, true) - const y = view.getInt16(285 + 2 * i, true) - const z = view.getInt16(485 + 2 * i, true) - mags.push(Math.sqrt(x * x + y * y + z * z) / 4096) - } - return ts > 0 ? { ts, idx: 0, mags } : null - } - return null -} - -// realtimeRr — extract beat-to-beat (R-R) intervals (ms) from the LIVE records that -// carry them (cross-validated against independent capture: R10 RR offset equals R24's). -// Offsets are inner-relative (inner[0] = packet/record byte): -// • 0x28 REALTIME_DATA (compact HR): rr_count u8 @ [9], rr i16 LE @ [10 + 2i] -// • R10 (rec_type 10, 0x2B/0x2F): rr_count u8 @ [18], rr i16 LE @ [19 + 2i] -// (R24 historical RR is decoded separately in records.ts / parse_r24.) -// -// RR unit = raw u16 ms (confirmed). NOT yet hardware-validated on our firmware for the -// 0x28 carrier, so this is deliberately defensive: a count>8 (realtime carries 0–4) or -// any value the caller's 300–2000 ms gate rejects is dropped. A wrong offset therefore -// produces nothing storable — never a corrupted interval. -export function realtimeRr(hex: string): { ts: number; rr_ms: number[] } | null { - let b: Uint8Array - try { b = hexToBytes(hex) } catch { return null } - if (b.length < 12) return null - const view = new DataView(b.buffer, b.byteOffset, b.byteLength) - const pkt = b[0], rec = b[1] - let tsOff: number, cntOff: number - if (pkt === 0x28) { tsOff = 2; cntOff = 9 } - else if (rec === 10) { tsOff = 7; cntOff = 18 } - else return null - if (cntOff + 1 >= b.length) return null - const ts = view.getUint32(tsOff, true) - if (ts <= 0) return null - const n = b[cntOff] - if (n === 0 || n > 8) return null // realtime carries 0–4; a large count = wrong offset → bail - const rr_ms: number[] = [] - const first = cntOff + 1 - for (let i = 0; i < n && first + 2 * i + 2 <= b.length; i++) { - const v = view.getInt16(first + 2 * i, true) - if (v > 0) rr_ms.push(v) // drop 0-ms placeholders (matches R24) - } - return rr_ms.length ? { ts, rr_ms } : null -} - -// Decode the R10 IMU arrays into (activity, steps) over the 100-sample window. -// activity = stddev of per-sample |accel|(g) — actigraphy intensity. -// steps = count of GAIT CYCLES only when the window is genuinely rhythmic. -// -// The band has no pedometer field — steps are estimated from wrist IMU (ESTIMATE -// tier). The naive "count every peak" approach over-counts badly: any arm gesture, -// typing, or vehicle bump clears a fixed threshold. Instead we require RHYTHM: -// walking produces a periodic acceleration signal, so we (1) detrend the magnitude -// to remove gravity + slow drift, (2) measure how periodic it is via normalized -// autocorrelation over plausible step lags, and (3) only count steps when that -// periodicity is strong AND the limb is actually moving. Steps in the window = -// number of gait cycles = n / (dominant lag). Non-rhythmic motion → 0 steps. -// (Same autocorrelation idea we use for respiratory rate; standard wrist pedometry.) -function r10Motion(view: DataView, len: number): { activity: number; steps: number } { - if (len < 685) return { activity: 0, steps: 0 } - const ACC = 1 / 4096 - const arr = (off: number): number[] => { - const out: number[] = [] - for (let i = 0; i < 100; i++) { - const o = off + 2 * i - if (o + 2 <= len) out.push(view.getInt16(o, true)) - } - return out - } - const ax = arr(85), ay = arr(285), az = arr(485) // accel X/Y/Z - const n = Math.min(ax.length, ay.length, az.length) - if (n === 0) return { activity: 0, steps: 0 } - const mags: number[] = [] - for (let i = 0; i < n; i++) { - mags.push(Math.hypot(ax[i] * ACC, ay[i] * ACC, az[i] * ACC)) - } - const mean = mags.reduce((s, v) => s + v, 0) / n - const variance = mags.reduce((s, v) => s + (v - mean) ** 2, 0) / n - const std = Math.sqrt(variance) - const activity = Math.round(std * 1000) / 1000 - - // Limb must actually be oscillating — quiet wrist (typing/holding) → no steps. - const ACTIVITY_FLOOR = 0.05 // g RMS of the detrended signal - if (std < ACTIVITY_FLOOR || n < 24) return { activity, steps: 0 } - - // Detrend: remove a centered moving average (gravity + slow drift), leaving the - // gait oscillation around 0. - const W = 9 - const x: number[] = new Array(n); - for (let i = 0; i < n; i++) { - let s = 0, c = 0 - for (let j = Math.max(0, i - W); j <= Math.min(n - 1, i + W); j++) { s += mags[j]; c++ } - x[i] = mags[i] - s / c - } - const x0 = x.reduce((s, v) => s + v, 0) / n - let denom = 0 - for (let i = 0; i < n; i++) denom += (x[i] - x0) ** 2 - if (denom <= 1e-9) return { activity, steps: 0 } - - // Normalized autocorrelation over plausible step lags. Cadence ~1.4–3.0 steps/s; - // with a ~25 Hz IMU window that's ~8–18 samples/step. We scan a generous band - // and rely on the periodicity strength to confirm gait. - const MIN_LAG = 7, MAX_LAG = 40 - let bestLag = 0, bestR = 0 - for (let lag = MIN_LAG; lag <= Math.min(MAX_LAG, n - 1); lag++) { - let num = 0 - for (let i = 0; i < n - lag; i++) num += (x[i] - x0) * (x[i + lag] - x0) - const r = num / denom - if (r > bestR) { bestR = r; bestLag = lag } - } - - // Strong, sustained periodicity ⇒ walking/running; count the gait cycles. - const RHYTHM_THRESH = 0.45 - if (bestLag === 0 || bestR < RHYTHM_THRESH) return { activity, steps: 0 } - const steps = Math.round(n / bestLag) - return { activity, steps } -} - -/** - * Decode one hex record into a DecodedSample, or null if it carries no - * surfaceable sample (0x33 IMU stream, malformed, or unknown type). - */ -export function decodeRecord(hex: string): DecodedSample | null { - let b: Uint8Array - try { - b = hexToBytes(hex) - } catch { - return null - } - if (b.length < 4) return null - const view = new DataView(b.buffer, b.byteOffset, b.byteLength) - const pktType = b[0] - const recType = b[1] - - // 0x28 — live compact HR: ts@2 (u32 LE), hr@8 (u8). NO RR-intervals. - if (pktType === 0x28) { - if (b.length < 9) return null - const ts = view.getUint32(2, true) - const hr = b[8] - return { ts, hr, activity: 0, steps_inc: 0, wrist_on: hr > 0, rec_type: 28 } - } - - // 0x33 — live IMU stream: raw-only (kept in R2, no sample emitted). - if (pktType === 0x33) return null - - if (b.length < 18) return null - - // R24 — type-24 historical telemetry. - if (recType === 24) { - const d = parse_r24(b) - if (!d) return null - return { ts: d.ts_epoch, hr: d.hr, activity: 0, steps_inc: 0, wrist_on: d.hr > 0, rec_type: 24 } - } - - // R10 / 0x2B — ts@7, hr@17, IMU arrays → activity. - if (recType === 10) { - const ts = view.getUint32(7, true) - const hr = b[17] - const m = r10Motion(view, b.length) - return { ts, hr, activity: m.activity, steps_inc: m.steps, wrist_on: hr > 0, rec_type: 10 } - } - - return null -} - -/** Decode a batch of hex records, returning all surfaceable samples. */ -export function decodeBatch(records: string[]): DecodedSample[] { - const out: DecodedSample[] = [] - for (const hex of records) { - const s = decodeRecord(hex) - if (s) out.push(s) - } - return out -} diff --git a/ts/records.ts b/ts/records.ts deleted file mode 100644 index 6a8e873..0000000 --- a/ts/records.ts +++ /dev/null @@ -1,96 +0,0 @@ -export interface R24 { - ts_epoch: number; - ts_subsec: number; - counter: number; - hr: number; - /** Beat-to-beat (R-R) intervals in ms for this 1 s record, 0–4 of them. - * Validated on 127,971 records: 99.7% fall in 300–2000 ms. The HRV source. */ - rr_count: number; - rr_intervals_ms: number[]; - /** Raw green-LED PPG ADC count @ [29]. Pulsatile; not a finished value. */ - ppg_green: number; - /** Raw red/IR-LED PPG ADC count @ [31]. 2nd PPG channel; pulsatile, full dynamic range. - * Validated on 261 R2 records over 113 h (108 distinct, 195–61695). RELATIVE. */ - ppg_red_ir: number; - /** Gravity/accel vector (g), 3× float32 @ [36:48]. |g| ≈ 1.0 at rest (corpus mean 1.012). - * Note: a second f32 triplet at [52:64] is byte-identical to this across all 811 - * validation records (a firmware-mirrored copy, not an independent sensor) — so it - * is deliberately NOT surfaced. */ - accel_g: [number, number, number]; - /** Skin-contact quality @ [51] (u8, 0–198). Varies with optical coupling. - * NOT a clean on/off-wrist flag — zero rows still carry valid HR; wear is the - * WRIST_ON/OFF events. Surface as contact quality only. */ - skin_contact: number; - /** Raw red-channel ADC @ [64]. RELATIVE only — SpO₂ % is computed in WHOOP's cloud, not sent. */ - spo2_red_raw: number; - /** Raw IR-channel ADC @ [66]. RELATIVE only. Pairs with spo2_red_raw → the red/IR ratio - * that an SpO₂ estimate needs (we previously kept only the red channel). */ - spo2_ir_raw: number; - /** Raw skin-temperature ADC @ [68]. RELATIVE only — °C is computed server-side, never sent. */ - skin_temp_raw: number; - /** Raw ambient-light ADC @ [70]. RELATIVE. Used to background-correct the PPG/SpO₂ channels. */ - ambient_raw: number; - /** Untouched payload [13:] — kept so records can be re-decoded as the map improves. */ - raw_tail: string; -} - -/** - * Decode a Type-24 historical biometric record (96 bytes, 1 Hz). - * - * Offsets verified against 127,971 of our own stored records and cross-checked - * cross-validated against independent reference implementations. Only fields that - * survived per-byte variance - * validation on real data are surfaced. The optical block (ppg_red_ir@31, - * spo2_ir@66, ambient@70) and skin_contact@51 were added after confirming they - * VARY across 261 R2 records spanning 113 h (plus 550 golden capture records). - * The f32 triplet at @52 is dropped: byte-identical to accel_g@36 on all 811 - * records (a mirrored copy). Conversely `resp_rate_raw`@76 and - * `signal_quality`@78 are NOT decoded: both are bit-constant (3073 / 3074) - * across all 811 records — a fixed trailer on our firmware, not a measurement - * (WHOOP derives respiration in-cloud from PPG; see backend resp.ts). Raw ADC - * fields are RELATIVE: the band relays them uncalibrated and WHOOP derives - * SpO₂/°C in its cloud. - */ -export function parse_r24(inner: Uint8Array): R24 | null { - if (inner.length < 89) { - return null; - } - - const view = new DataView(inner.buffer, inner.byteOffset, inner.byteLength); - const round = (v: number, decimals: number = 0) => { - const p = Math.pow(10, decimals); - return Math.round(v * p) / p; - }; - - // R-R intervals: rr_count @ [18], then rr_count signed int16 LE from [19]. - const rr_count = inner[18]; - const rr_intervals_ms: number[] = []; - for (let i = 0; i < rr_count && 19 + 2 * i + 2 <= inner.length; i++) { - const v = view.getInt16(19 + 2 * i, true); - if (v > 0) rr_intervals_ms.push(v); - } - - return { - ts_epoch: view.getUint32(7, true), // unix seconds @ [7:11] - ts_subsec: view.getUint16(11, true), // sub-seconds @ [11:13] - counter: view.getUint32(3, true), // record counter (+1/rec) @ [3:7] - hr: inner[17], // heart rate bpm @ [17] (0 = no reading) - rr_count, - rr_intervals_ms, - ppg_green: view.getUint16(29, true), // raw green PPG ADC @ [29] - ppg_red_ir: view.getUint16(31, true), // raw red/IR PPG ADC @ [31] - accel_g: [ // gravity/accel (g) float32 ×3 @ [36:48] - round(view.getFloat32(36, true), 4), - round(view.getFloat32(40, true), 4), - round(view.getFloat32(44, true), 4), - ], - skin_contact: inner[51], // contact-quality u8 @ [51] (0–198, NOT a wear flag) - spo2_red_raw: view.getUint16(64, true), // raw red ADC @ [64] (relative) - spo2_ir_raw: view.getUint16(66, true), // raw IR ADC @ [66] (relative) - skin_temp_raw: view.getUint16(68, true), // raw temp ADC @ [68] (relative) - ambient_raw: view.getUint16(70, true), // raw ambient-light ADC @ [70] (relative) - raw_tail: Array.from(inner.slice(13)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""), - }; -} diff --git a/ts/test_decoder.ts b/ts/test_decoder.ts deleted file mode 100644 index 3533760..0000000 --- a/ts/test_decoder.ts +++ /dev/null @@ -1,111 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import { parse_r24 } from "./records"; - -const FIXTURE_PATH = path.join(__dirname, "../../whoop_hist.jsonl"); - -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); - } - return bytes; -} - -function runTests() { - console.log("Loading fixtures from:", FIXTURE_PATH); - const data = fs.readFileSync(FIXTURE_PATH, "utf-8"); - const lines = data.trim().split("\n"); - const records = lines.map((line) => JSON.parse(line)); - - console.log(`Loaded ${records.length} records.`); - - if (records.length === 0) { - throw new Error("No records found in fixture!"); - } - - // Test record 0 - console.log("Testing record 0..."); - const record0 = records[0]; - const bytes0 = hexToBytes(record0.hex); - const result0 = parse_r24(bytes0); - - if (!result0) { - throw new Error("Record 0 failed to decode!"); - } - - console.log("Record 0 decoded:", result0); - - // Assertions for record 0 - // hr=98, ts_epoch=1775395266; rr/ppg/raw ADCs are decoded but not pinned to - // exact expected values here (validated statistically across the corpus). - console.assert(result0.hr === 98, `Expected HR 98, got ${result0.hr}`); - console.assert( - result0.ts_epoch === 1775395266, - `Expected ts_epoch 1775395266, got ${result0.ts_epoch}` - ); - console.assert( - result0.rr_count >= 0 && result0.rr_count <= 4, - `Expected rr_count 0-4, got ${result0.rr_count}` - ); - console.assert( - result0.rr_intervals_ms.length === result0.rr_count || - result0.rr_intervals_ms.length <= result0.rr_count, - `rr_intervals length should not exceed rr_count` - ); - console.assert( - Number.isInteger(result0.spo2_red_raw) && Number.isInteger(result0.skin_temp_raw), - `Expected raw ADCs to decode as integers` - ); - // New optical/accel fields (validated on 261 R2 records / 113 h). - console.assert( - [result0.ppg_red_ir, result0.spo2_ir_raw, result0.ambient_raw, result0.skin_contact].every( - (v) => Number.isInteger(v) - ), - `Expected ppg_red_ir/spo2_ir/ambient/skin_contact as integers` - ); - - // Accel assertions: accel≈(-0.150,-0.331,1.001) - const [ax, ay, az] = result0.accel_g; - console.assert( - Math.abs(ax - -0.15) < 0.001, - `Expected accel_x -0.150, got ${ax}` - ); - console.assert( - Math.abs(ay - -0.331) < 0.001, - `Expected accel_y -0.331, got ${ay}` - ); - console.assert( - Math.abs(az - 1.001) < 0.001, - `Expected accel_z 1.001, got ${az}` - ); - - console.log("Record 0 assertions passed!"); - - // Test all records - console.log("Testing all 550 records..."); - let successCount = 0; - for (let i = 0; i < records.length; i++) { - try { - const bytes = hexToBytes(records[i].hex); - const res = parse_r24(bytes); - if (res) { - successCount++; - } else { - console.error(`Record ${i} returned null`); - } - } catch (e) { - console.error(`Error decoding record ${i}:`, e); - throw e; - } - } - - console.log(`Successfully decoded ${successCount}/${records.length} records.`); - if (successCount === records.length) { - console.log("ALL TESTS PASSED"); - } else { - throw new Error(`Only ${successCount}/${records.length} records decoded successfully.`); - } -} - -runTests();