Skip to content

feat: decode WHOOP 5/MG raw 6-axis IMU offload buffer + activity features (#423) - #455

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:whoop5-imu-decode
Jul 15, 2026
Merged

ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:whoop5-imu-decode

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

What

Decode the WHOOP 5.0/MG raw 6-axis IMU offload buffer and extract activity features from it. Pure, tested, no strap, no decode-gating of any existing score. Follows the capture recorder in #454; coordinates with #423.

  • WhoopProtocol.Whoop5RawImu — the 1244-byte type-0x2F offload buffer → 100 Hz 3-axis accel (g) + 3-axis gyro (°/s).
  • StrandAnalytics.ImuFeatureExtractor — a window of samples → {accelEnergy, gyroEnergy, jerkRms, cadenceHz, cadenceStrength}, the input a coarse sport / HAR classifier needs (the real-IMU upgrade path behind WorkoutTypeClassifying).

Why

The 5/MG's live raw-IMU stream is firmware-refused (TOGGLE_IMU_MODE/106 acks but never streams, #423), so this looked like a dead end. But the offload burst carries a 1244-byte buffer that is a full second of raw inertial data — including gyro. So 100 Hz 6-axis IMU is obtainable on the 5.0, via the historical path. At 100 Hz it resolves gait cadence, jerk/impact, and rotational energy the 1 Hz gravity vector physically cannot (a 1.8 Hz step rate is above the 1 Hz stream's Nyquist limit).

Layout (validated)

Reference: Asherlc/dofek docs/whoop-ble-protocol.md documents this raw packet; same columnar convention as the 4.0 1917 variant.

frame = 8-byte puffin envelope + payload:
  @15  u32 LE  strap unix seconds
  @24  u16 LE  countA (=100)
  @28  ax[100]  @228 ay[100]  @428 az[100]   (i16 LE)   1/4096 g/LSB
  @630 u16 LE  countB (=100)
  @640 gx[100]  @840 gy[100]  @1040 gz[100]  (i16 LE)   2000/32768 (°/s)/LSB

Verification

  • swift test — Whoop5RawImuTests + ImuFeatureExtractorTests (pure packages, run in CI's swift-packages).
  • Real hardware: a test decodes an actual buffer captured off a WHOOP 5.0 (fw 50.40.1.0) and asserts the accelerometer forms a ~1 g gravity shell (≥95/100 samples in-shell, median 1.01 g); gyro sits near zero at rest and correlates 0.79 with accel motion across 1423 captured buffers.
  • Per the derived-signal rule, cadence is a feature, never a physiological gate, and its test recovers four distinct injected cadences (1.4/1.8/2.4/3.0 Hz), not one match. On 23 min of real captured wear the same method found a 1.79 Hz ≈ 107 steps/min walking cadence.

Notes

  • Decode + features only — no store/UI/classifier wiring here (that follows, gated on labeled training data).
  • The other big offload buffer (2140 B) is a separate optical suite (PPG/SpO₂ and, on MG, blood-pressure vascular channels) — still undecoded; not in this PR.
  • Android twins deferred (research instrumentation; can follow once wired to a shipped path).

vishk23 added 2 commits July 14, 2026 10:31
The 1244-byte type-0x2F offload buffer carries a full second of raw
inertial data: 100 accelerometer + 100 gyroscope samples, columnar i16 LE
(ax/ay/az then gx/gy/gz), same convention as the 4.0 1917 variant and the
layout dofek documents. accel 1/4096 g/LSB, gyro 2000/32768 (deg/s)/LSB.

Whoop5RawImu.decode gates on the exact length + the two in-packet sample
counts (=100). Pure/deterministic, no strap. VALIDATED by a test that
decodes a REAL captured buffer (fw 50.40.1.0) and asserts the accelerometer
forms a ~1g gravity shell (>=95/100 samples) with resting gyro small --
plus a synthetic-frame test pinning the exact offsets and scales.

Note: the 5/MG LIVE raw-IMU stream is firmware-refused (cmd 106), but the
OFFLOAD buffer carries full accel+gyro, so 100Hz 6-axis IMU IS obtainable
via the historical path. Decode only; wiring into the store/HAR follows.
ImuFeatureExtractor turns a window of decoded 100Hz 6-axis samples
(WhoopProtocol.Whoop5RawImu) into a compact activity-feature vector:
accel AC energy (intensity), gyro energy (rotation), jerk RMS (impact),
and gait cadence via band-limited autocorrelation of the accel-magnitude
AC + its normalized strength. This is the real-IMU input for coarse sport
/ HAR classification -- the upgrade path the 1Hz-proxy WorkoutTypeClassifier
(#414) was built to accept behind WorkoutTypeClassifying.

Per the derived-signal rule, cadence is a feature (never a physiological
gate) and the test recovers MULTIPLE distinct injected cadences (1.4/1.8/
2.4/3.0 Hz), not one match, plus still/energy/gyro/end-to-end cases. Pure,
no strap. On real captured data (23 min, fw 50.40.1.0) this same method
found a 1.79 Hz = ~107 steps/min walking cadence -- which the 1Hz gravity
stream physically cannot resolve.
@ryanbr
ryanbr merged commit 893d25d into ryanbr:main Jul 15, 2026
8 checks passed
ryanbr added a commit that referenced this pull request Jul 15, 2026
…ep-buffer log (#423)

The #454 recorder banks the raw 1244-B/2140-B type-0x2F offload buffers, and #455
added a validated 6-axis IMU decoder — but nothing called it outside its own tests.

Run Whoop5RawImu.decode + ImuFeatureExtractor on each captured 1244-B IMU buffer and
inline the activity summary (cadence/energy/jerk/gyro) as an "imu" field on the
existing JSONL line. This is the first caller of the decoder outside tests: it
exercises it on real device captures and makes each line self-checking (raw hex vs
decode) with no stored table, migration, or downstream gate. Instrumentation only,
per the derived-signal rule; the 2140-B optical buffer stays raw-only (undecoded).

decodedImuField is pure and non-throwing — a decode miss (optical buffer, or count
fields != 100) omits the field and can never disturb the capture path.
ryanbr added a commit that referenced this pull request Jul 15, 2026
…ep-buffer log (#423) (#476)

The #454 recorder banks the raw 1244-B/2140-B type-0x2F offload buffers, and #455
added a validated 6-axis IMU decoder — but nothing called it outside its own tests.

Run Whoop5RawImu.decode + ImuFeatureExtractor on each captured 1244-B IMU buffer and
inline the activity summary (cadence/energy/jerk/gyro) as an "imu" field on the
existing JSONL line. This is the first caller of the decoder outside tests: it
exercises it on real device captures and makes each line self-checking (raw hex vs
decode) with no stored table, migration, or downstream gate. Instrumentation only,
per the derived-signal rule; the 2140-B optical buffer stays raw-only (undecoded).

decodedImuField is pure and non-throwing — a decode miss (optical buffer, or count
fields != 100) omits the field and can never disturb the capture path.
ryanbr pushed a commit that referenced this pull request Jul 15, 2026
… IMU decode (#423) (#481)

Ports the Swift #423 research cluster (upstream #454/#455/#476) to Kotlin so noop-tan reaches
parity with the WhoopProtocol/StrandAnalytics/BLE Swift side:

- Whoop5RawImu (com.noop.protocol): decode the 1244-byte 5/MG raw 6-axis IMU offload buffer —
  100 Hz accel (1/4096 g) + gyro (2000/32768 deg/s), columnar i16 LE at the frame-absolute offsets
  15/24/28/228/428/630/640/840/1040. Gated on length + both in-packet counts (=100), not the type
  byte. baseTs is Long (Swift Int = 64-bit; holds the full u32 without truncation).
- ImuFeatureExtractor (com.noop.analytics): activity features (accel AC energy, gyro energy, jerk
  RMS, autocorrelation cadence + strength) over a window of decoded samples. Cadence is a FEATURE
  reported with its own strength, never a physiological gate (derived-signal rule).
- PuffinDeepBufferLog (com.noop.ble) + WhoopBleClient wiring: durable append-only JSONL of the big
  (>= 1 KB) type-0x2F R22 deep buffers (1244/2140 B) for offline layout RE, in its own file the bulk
  capture never churns. The 1244-B IMU buffer carries its decoded activity summary inline. Gated on
  the existing capture toggle (PuffinExperiment.isCaptureEnabled); passive/read-only, no new setting.

Byte-identical to the Swift decode/features (verified against the same synthetic vectors + a real
1244-byte buffer captured off a WHOOP 5.0 fw 50.40.1.0: baseTs 1784037165, ~1 g gravity shell). The
inline-IMU JSON is value-parity (numbers may format differently than Swift's JSONEncoder — both valid
JSON — since it's a research JSONL the analysis tool parses, not a stored .noopbak value). 14 new
unit tests; full Full-debug suite green (2660 tests, 0 fail).
ryanbr added a commit that referenced this pull request Jul 17, 2026
…ller

Doc-only. The earlier wording ("decode first, consume later") implied a house pattern that
does not exist -- I asserted it without checking. Whoop5RawImu.kt shipped WITH its
PuffinDeepBufferLog caller in a single commit (091b3c6, #481), and the Swift side landed
decoder + ImuFeatureExtractor + caller together (#455). Neither ever sat callerless, which
makes this the protocol package's first callerless decoder.

The real reason is narrower and worth stating accurately: no parity-safe consumer exists yet.
v20 channel identity is unproven, and Swift deliberately keeps the 2140-B buffer raw-only in
its deep-buffer log, so wiring a consumer on Android alone would re-open the parity gap from
the other side. A consumer wants both platforms at once, once #423 settles identity.

Tested: ./gradlew testFullDebugUnitTest --tests "com.noop.protocol.*" --no-build-cache ->
168/168 unchanged.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ures (ryanbr#423) (ryanbr#455)

* feat(protocol): decode WHOOP 5/MG raw 6-axis IMU offload buffer (ryanbr#423)

The 1244-byte type-0x2F offload buffer carries a full second of raw
inertial data: 100 accelerometer + 100 gyroscope samples, columnar i16 LE
(ax/ay/az then gx/gy/gz), same convention as the 4.0 1917 variant and the
layout dofek documents. accel 1/4096 g/LSB, gyro 2000/32768 (deg/s)/LSB.

Whoop5RawImu.decode gates on the exact length + the two in-packet sample
counts (=100). Pure/deterministic, no strap. VALIDATED by a test that
decodes a REAL captured buffer (fw 50.40.1.0) and asserts the accelerometer
forms a ~1g gravity shell (>=95/100 samples) with resting gyro small --
plus a synthetic-frame test pinning the exact offsets and scales.

Note: the 5/MG LIVE raw-IMU stream is firmware-refused (cmd 106), but the
OFFLOAD buffer carries full accel+gyro, so 100Hz 6-axis IMU IS obtainable
via the historical path. Decode only; wiring into the store/HAR follows.

* feat(analytics): IMU activity features from decoded 5/MG raw IMU (ryanbr#423)

ImuFeatureExtractor turns a window of decoded 100Hz 6-axis samples
(WhoopProtocol.Whoop5RawImu) into a compact activity-feature vector:
accel AC energy (intensity), gyro energy (rotation), jerk RMS (impact),
and gait cadence via band-limited autocorrelation of the accel-magnitude
AC + its normalized strength. This is the real-IMU input for coarse sport
/ HAR classification -- the upgrade path the 1Hz-proxy WorkoutTypeClassifier
(ryanbr#414) was built to accept behind WorkoutTypeClassifying.

Per the derived-signal rule, cadence is a feature (never a physiological
gate) and the test recovers MULTIPLE distinct injected cadences (1.4/1.8/
2.4/3.0 Hz), not one match, plus still/energy/gyro/end-to-end cases. Pure,
no strap. On real captured data (23 min, fw 50.40.1.0) this same method
found a 1.79 Hz = ~107 steps/min walking cadence -- which the 1Hz gravity
stream physically cannot resolve.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ep-buffer log (ryanbr#423) (ryanbr#476)

The ryanbr#454 recorder banks the raw 1244-B/2140-B type-0x2F offload buffers, and ryanbr#455
added a validated 6-axis IMU decoder — but nothing called it outside its own tests.

Run Whoop5RawImu.decode + ImuFeatureExtractor on each captured 1244-B IMU buffer and
inline the activity summary (cadence/energy/jerk/gyro) as an "imu" field on the
existing JSONL line. This is the first caller of the decoder outside tests: it
exercises it on real device captures and makes each line self-checking (raw hex vs
decode) with no stored table, migration, or downstream gate. Instrumentation only,
per the derived-signal rule; the 2140-B optical buffer stays raw-only (undecoded).

decodedImuField is pure and non-throwing — a decode miss (optical buffer, or count
fields != 100) omits the field and can never disturb the capture path.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
… IMU decode (ryanbr#423) (ryanbr#481)

Ports the Swift ryanbr#423 research cluster (upstream ryanbr#454/ryanbr#455/ryanbr#476) to Kotlin so noop-tan reaches
parity with the WhoopProtocol/StrandAnalytics/BLE Swift side:

- Whoop5RawImu (com.noop.protocol): decode the 1244-byte 5/MG raw 6-axis IMU offload buffer —
  100 Hz accel (1/4096 g) + gyro (2000/32768 deg/s), columnar i16 LE at the frame-absolute offsets
  15/24/28/228/428/630/640/840/1040. Gated on length + both in-packet counts (=100), not the type
  byte. baseTs is Long (Swift Int = 64-bit; holds the full u32 without truncation).
- ImuFeatureExtractor (com.noop.analytics): activity features (accel AC energy, gyro energy, jerk
  RMS, autocorrelation cadence + strength) over a window of decoded samples. Cadence is a FEATURE
  reported with its own strength, never a physiological gate (derived-signal rule).
- PuffinDeepBufferLog (com.noop.ble) + WhoopBleClient wiring: durable append-only JSONL of the big
  (>= 1 KB) type-0x2F R22 deep buffers (1244/2140 B) for offline layout RE, in its own file the bulk
  capture never churns. The 1244-B IMU buffer carries its decoded activity summary inline. Gated on
  the existing capture toggle (PuffinExperiment.isCaptureEnabled); passive/read-only, no new setting.

Byte-identical to the Swift decode/features (verified against the same synthetic vectors + a real
1244-byte buffer captured off a WHOOP 5.0 fw 50.40.1.0: baseTs 1784037165, ~1 g gravity shell). The
inline-IMU JSON is value-parity (numbers may format differently than Swift's JSONEncoder — both valid
JSON — since it's a research JSONL the analysis tool parses, not a stored .noopbak value). 14 new
unit tests; full Full-debug suite green (2660 tests, 0 fail).
@vishk23
vishk23 deleted the whoop5-imu-decode branch July 30, 2026 19:59
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ures (ryanbr#423) (ryanbr#455)

* feat(protocol): decode WHOOP 5/MG raw 6-axis IMU offload buffer (ryanbr#423)

The 1244-byte type-0x2F offload buffer carries a full second of raw
inertial data: 100 accelerometer + 100 gyroscope samples, columnar i16 LE
(ax/ay/az then gx/gy/gz), same convention as the 4.0 1917 variant and the
layout dofek documents. accel 1/4096 g/LSB, gyro 2000/32768 (deg/s)/LSB.

Whoop5RawImu.decode gates on the exact length + the two in-packet sample
counts (=100). Pure/deterministic, no strap. VALIDATED by a test that
decodes a REAL captured buffer (fw 50.40.1.0) and asserts the accelerometer
forms a ~1g gravity shell (>=95/100 samples) with resting gyro small --
plus a synthetic-frame test pinning the exact offsets and scales.

Note: the 5/MG LIVE raw-IMU stream is firmware-refused (cmd 106), but the
OFFLOAD buffer carries full accel+gyro, so 100Hz 6-axis IMU IS obtainable
via the historical path. Decode only; wiring into the store/HAR follows.

* feat(analytics): IMU activity features from decoded 5/MG raw IMU (ryanbr#423)

ImuFeatureExtractor turns a window of decoded 100Hz 6-axis samples
(WhoopProtocol.Whoop5RawImu) into a compact activity-feature vector:
accel AC energy (intensity), gyro energy (rotation), jerk RMS (impact),
and gait cadence via band-limited autocorrelation of the accel-magnitude
AC + its normalized strength. This is the real-IMU input for coarse sport
/ HAR classification -- the upgrade path the 1Hz-proxy WorkoutTypeClassifier
(ryanbr#414) was built to accept behind WorkoutTypeClassifying.

Per the derived-signal rule, cadence is a feature (never a physiological
gate) and the test recovers MULTIPLE distinct injected cadences (1.4/1.8/
2.4/3.0 Hz), not one match, plus still/energy/gyro/end-to-end cases. Pure,
no strap. On real captured data (23 min, fw 50.40.1.0) this same method
found a 1.79 Hz = ~107 steps/min walking cadence -- which the 1Hz gravity
stream physically cannot resolve.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ep-buffer log (ryanbr#423) (ryanbr#476)

The ryanbr#454 recorder banks the raw 1244-B/2140-B type-0x2F offload buffers, and ryanbr#455
added a validated 6-axis IMU decoder — but nothing called it outside its own tests.

Run Whoop5RawImu.decode + ImuFeatureExtractor on each captured 1244-B IMU buffer and
inline the activity summary (cadence/energy/jerk/gyro) as an "imu" field on the
existing JSONL line. This is the first caller of the decoder outside tests: it
exercises it on real device captures and makes each line self-checking (raw hex vs
decode) with no stored table, migration, or downstream gate. Instrumentation only,
per the derived-signal rule; the 2140-B optical buffer stays raw-only (undecoded).

decodedImuField is pure and non-throwing — a decode miss (optical buffer, or count
fields != 100) omits the field and can never disturb the capture path.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
… IMU decode (ryanbr#423) (ryanbr#481)

Ports the Swift ryanbr#423 research cluster (upstream ryanbr#454/ryanbr#455/ryanbr#476) to Kotlin so noop-tan reaches
parity with the WhoopProtocol/StrandAnalytics/BLE Swift side:

- Whoop5RawImu (com.noop.protocol): decode the 1244-byte 5/MG raw 6-axis IMU offload buffer —
  100 Hz accel (1/4096 g) + gyro (2000/32768 deg/s), columnar i16 LE at the frame-absolute offsets
  15/24/28/228/428/630/640/840/1040. Gated on length + both in-packet counts (=100), not the type
  byte. baseTs is Long (Swift Int = 64-bit; holds the full u32 without truncation).
- ImuFeatureExtractor (com.noop.analytics): activity features (accel AC energy, gyro energy, jerk
  RMS, autocorrelation cadence + strength) over a window of decoded samples. Cadence is a FEATURE
  reported with its own strength, never a physiological gate (derived-signal rule).
- PuffinDeepBufferLog (com.noop.ble) + WhoopBleClient wiring: durable append-only JSONL of the big
  (>= 1 KB) type-0x2F R22 deep buffers (1244/2140 B) for offline layout RE, in its own file the bulk
  capture never churns. The 1244-B IMU buffer carries its decoded activity summary inline. Gated on
  the existing capture toggle (PuffinExperiment.isCaptureEnabled); passive/read-only, no new setting.

Byte-identical to the Swift decode/features (verified against the same synthetic vectors + a real
1244-byte buffer captured off a WHOOP 5.0 fw 50.40.1.0: baseTs 1784037165, ~1 g gravity shell). The
inline-IMU JSON is value-parity (numbers may format differently than Swift's JSONEncoder — both valid
JSON — since it's a research JSONL the analysis tool parses, not a stored .noopbak value). 14 new
unit tests; full Full-debug suite green (2660 tests, 0 fail).
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