Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
f33e99e
feat(android): add hardware ground truth collector
bhelm Aug 24, 2026
ccc8c70
fix(android): stabilize ground truth capture
bhelm Aug 24, 2026
ec59ed8
fix(android): clarify ground truth session layout
bhelm Aug 24, 2026
7b9a3c4
fix(android): make collector sessions restartable
bhelm Aug 24, 2026
35af052
fix(android): launch collector export safely
bhelm Aug 24, 2026
9625c38
fix(android): clarify collector undo action
bhelm Aug 24, 2026
babc56c
feat(android): manage ground truth sessions
bhelm Aug 24, 2026
f8e979b
feat(android): export step algorithm signals
bhelm Aug 24, 2026
fd4eff3
Add ground-truth realtime IMU diagnostics
bhelm Aug 26, 2026
01f2fca
Start the raw producer for ground-truth IMU capture
bhelm Aug 26, 2026
73939af
Build a cross-platform 5/MG raw data collector
bhelm Aug 26, 2026
40f7f63
Move 5/MG developer controls into Test Centre
bhelm Aug 26, 2026
2504ad3
Remove unrelated sync translations from collector branch
bhelm Aug 26, 2026
62de1ef
Add raw capture session deletion controls
bhelm Aug 26, 2026
14d5ca8
Bring iOS raw capture sessions to Android parity
bhelm Aug 26, 2026
7954254
Add hybrid rolling and durable IMU storage
bhelm Aug 26, 2026
3ecaeec
Disambiguate Apple history export formatting
bhelm Aug 26, 2026
c8e6c8d
Fix Apple parity test error propagation
bhelm Aug 26, 2026
8126cca
Store session IMU in compressed append-only files
bhelm Aug 26, 2026
5495f7b
Import IMU chunk metadata in collector view
bhelm Aug 26, 2026
53443a2
Make IMU session start timestamp nonoptional
bhelm Aug 26, 2026
011c691
Batch IMU compression into 30-second blocks
bhelm Aug 26, 2026
0b7b3d6
Keep recent-period exclusion in step capture fork
bhelm Aug 26, 2026
e396de8
Document verified 5MG raw IMU capture
bhelm Aug 26, 2026
1971179
Refine Android raw capture sessions
bhelm Aug 26, 2026
a699e21
Measure IMU coverage on complete sensor seconds
bhelm Aug 27, 2026
06a935e
refactor(android): scope raw collector to capture sessions
bhelm Aug 27, 2026
2d3878a
fix: harden raw data capture lifecycle
bhelm Aug 27, 2026
d4badeb
fix(apple): preserve IMU chunk type inference
bhelm Aug 27, 2026
34419db
test(apple): track current deletion guard
bhelm Aug 27, 2026
df25f0e
Store decoded IMU in canonical time segments
bhelm Aug 27, 2026
abae701
Keep staging database upgrades monotonic
bhelm Aug 27, 2026
e1dcafd
Keep preview migration out of upstream feature
bhelm Aug 28, 2026
7321baf
Fix Apple IMU segment parsing
bhelm Aug 28, 2026
bc697b5
Report live IMU startup coverage accurately
bhelm Aug 28, 2026
2d47e06
Keep raw sessions on one canonical IMU path
bhelm Aug 28, 2026
af0bb51
Fix Apple coverage label syntax
bhelm Aug 28, 2026
2fe700e
Return Apple session coverage card
bhelm Aug 28, 2026
946056b
Merge remote-tracking branch 'origin/main' into feature/5mg-raw-data-…
bhelm Aug 28, 2026
2f21780
Localize Apple IMU coverage label
bhelm Aug 28, 2026
d23690b
Remove abandoned raw outbox capture API
bhelm Aug 28, 2026
7206f38
Match Apple export status with Android
bhelm Aug 28, 2026
200f881
perf(capture): speed IMU transfer and coverage UI
bhelm Aug 28, 2026
da9e2b2
docs(imu): record observed 100 Hz battery drain
bhelm Aug 28, 2026
6d74b02
Merge main into the 5/MG raw data collector branch
ryanbr Aug 29, 2026
7f03a2e
Localize the Raw Data Collector into the five ungated iOS locales
ryanbr Aug 29, 2026
27e82ed
fix(raw-capture): align parity guard with file storage
bhelm Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ public final class ReadonlyNoopStore {
let decodedTables = [
"hrSample", "rrInterval", "event", "battery", "spo2Sample",
"skinTempSample", "respSample", "gravitySample", "ppgHrSample", "stepSample",
"sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample",
"sleepStateSample", "ppgWaveformSample", "v18AuxSample",
]
var decodedRows = 0
for table in decodedTables where tableNames.contains(table) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import Foundation
// app captures (documented in Asherlc/dofek `docs/whoop-ble-protocol.md` as the type-0x2B raw packet)
// and the same columnar convention as the 4.0's 1917 REALTIME_RAW_DATA variant.
//
// IMPORTANT: the 5/MG's *live* raw-IMU stream is firmware-refused (TOGGLE_IMU_MODE / cmd 106 acks but
// never streams), but the OFFLOAD buffer carries full accel AND gyro — so 100 Hz 6-axis IMU IS
// obtainable on the 5.0 via the historical path.
// Hardware validation on a WHOOP 5/MG established that live raw IMU requires START_RAW_DATA (81)
// followed by TOGGLE_IMU_MODE (106) with payload [1,1]. Opcode 106 on its own acknowledges without
// starting the producer. The same 1244-byte shape is also banked and later returned by history sync.
//
// Frame layout (the reassembled BLE frame = 8-byte puffin envelope + payload; offsets are FRAME-absolute):
// @15 u32 LE strap unix seconds for this 1-second frame
Expand Down
16 changes: 6 additions & 10 deletions Packages/WhoopStore/Sources/WhoopStore/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -572,16 +572,8 @@ extension WhoopStore {
// the six wire columns (ax…az,gx…gz). Twin of the Android `rawImuSample` table (MIGRATION_20_21);
// same column order + PK so a `.noopbak` round-trips byte-for-byte.
//
// CONSUMER STATUS — deliberately none on this platform, in the same shape as the `ppgWaveformSample`
// (v27) and `v18AuxSample` (v31) notes. The writer (`Collector.storeRawImu`) is instrument-first: it
// fires only with raw capture enabled AND a 5/MG deep-data unlock, and is bounded to
// `WhoopStore.rawImuRetentionRows` (3600 one-second buffers, a rolling window — not a corpus). No
// analytic, score, gate, UI or export reads a row: the only SQL is the retention DELETE (`StreamStore`)
// plus a COUNT in the storage-stats readout (`LocalAccessCore`); `ImuFeatureExtractor` takes the
// protocol struct, never this table, so it is not a consumer either. Swift has NO reader yet, on
// purpose — a reader lands WITH a validated consumer, not before ("artifact, not one match", CLAUDE.md).
// Android keeps a dormant `rawImuSamples` reader (zero callers) for the eventual cross-check; do NOT
// "clean up" either side as dead code — the retained rows are the deliverable. Audit: #978.
// Historical rolling cache. Its opt-in writer retained at most 3,600 one-second rows, but no analytics,
// UI or export consumed them. v41 retires those legacy rows after file-backed capture replaces the cache.
migrator.registerMigration("v28-raw-imu") { db in
try db.create(table: "rawImuSample") { t in
t.column("deviceId", .text).notNull()
Expand Down Expand Up @@ -887,6 +879,10 @@ extension WhoopStore {
t.add(column: "skinTempC", .double)
}
}
// Retire the bounded, write-only legacy cache. Session-owned IMU now lives in the file-backed store.
migrator.registerMigration("v41-drop-raw-imu-sample") { db in
try db.drop(table: "rawImuSample")
}
return migrator
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ public struct DeviceRegistryStore: Sendable {
"ppgWaveformSample",
// v28-raw-imu (#423): the opt-in 5/MG raw-IMU offload capture is deviceId-keyed too — "delete all
// of this device's data" must clear it, or the raw inertial samples survive deletion (same defect).
"rawImuSample",
// v31-deep-capture-channels: the banked 5/MG v18 auxiliary fields are deviceId-keyed per-second
// rows like every stream above, so a "delete all of this device's data" must clear them too.
"v18AuxSample",
Expand Down
2 changes: 1 addition & 1 deletion Packages/WhoopStore/Sources/WhoopStore/Reads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ extension WhoopStore {
let rawTables = ["hrSample", "rrInterval", "event", "battery",
"spo2Sample", "skinTempSample", "respSample", "gravitySample",
"stepSample", "ppgHrSample", "sleepStateSample", "ppgWaveformSample",
"rawImuSample", "v18AuxSample"]
"v18AuxSample"]
var decoded = 0
for t in rawTables {
decoded += try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(t)") ?? 0
Expand Down
33 changes: 3 additions & 30 deletions Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,9 @@ extension WhoopStore {
return out
}

/// #423 rolling retention for the raw-IMU capture table (twin of Kotlin `RAW_IMU_RETENTION_ROWS`):
/// ~1 h at 1 row/strap-second (~4 MB) hard-caps the table during a multi-day offload replay.
public static let rawImuRetentionRows = 3600

/// v31 rolling retention for the v18 aux-slot table (twin of Kotlin `V18_AUX_RETENTION_ROWS`).
///
/// `rawImuSample` is the closest precedent — raw instrumentation banked as a blob, capped rather than
/// unbounded — and the same reasoning applies here: nothing reads these rows yet, so a cap is far
/// Raw instrumentation must be capped rather than unbounded. Nothing reads these rows yet, so a cap is far
/// cheaper to RELAX later than to impose once users have a year of history. Unbounded, this table is
/// the one genuinely new source of row growth in v31 (the four named channels only WIDEN rows that
/// were already being written: ~14 bytes on a `gravitySample`/`skinTempSample`/`sleepStateSample` row
Expand Down Expand Up @@ -102,27 +97,6 @@ extension WhoopStore {
}
}

/// #423: persist decoded 5/MG raw-IMU offload buffers (one row per strap-second, packed i16 BLOB),
/// then bound the table to the newest `retentionRows` for the device (rolling retention). Written from
/// the deep-buffer capture seam, not the normal stream path, so it inserts directly (idempotent by ts).
/// Twin of Kotlin `WhoopRepository.insertRawImu`.
public func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws {
guard !rows.isEmpty else { return }
try syncWrite { db in
let ins = try db.cachedStatement(sql: """
INSERT INTO rawImuSample (deviceId, ts, samples) VALUES (?, ?, ?)
ON CONFLICT(deviceId, ts) DO NOTHING
""")
// Pack the raw i16 columns to the LE BLOB HERE (packImuColumns is module-internal), so the
// caller passes plain [Int16] and never needs the packer. Mirrors how `insert` packs ppgWaveform.
for r in rows { try ins.execute(arguments: [deviceId, r.ts, WhoopStore.packImuColumns(r.cols)]) }
try db.execute(sql: """
DELETE FROM rawImuSample WHERE deviceId = ? AND ts < (
SELECT MIN(ts) FROM (SELECT ts FROM rawImuSample WHERE deviceId = ? ORDER BY ts DESC LIMIT ?))
""", arguments: [deviceId, deviceId, retentionRows])
}
}

/// Idempotent upsert of decoded streams by natural key. Returns the number of rows
/// ACTUALLY inserted per stream (0 for rows that already existed).
///
Expand Down Expand Up @@ -350,9 +324,8 @@ extension WhoopStore {
return (hr, rr, ev, bat, spo2, skin, resp, grav)
}

// Rolling retention (the `insertRawImu` shape, #423) but AMORTISED. The delete finds the
// Nth-newest row by rank, so it walks up to `v18AuxRetentionRows` index entries; `insertRawImu`
// keeps 3,600 so that is free, this keeps 604,800 and an offload inserts once per chunk. Swept
// Rolling retention is amortised. The delete finds the Nth-newest row by rank, so it walks up to
// `v18AuxRetentionRows` index entries. The 604,800-row cap is swept
// once per `v18AuxPruneEveryRows` rows instead, which keeps newest-N-rows exactly (a time window
// would not — a sporadically-worn strap's rows span far more than a week, and the census wants
// that). Counter is per device because the delete is.
Expand Down
2 changes: 1 addition & 1 deletion Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ extension WhoopStore {
let rawTables = ["hrSample", "rrInterval", "event", "battery",
"spo2Sample", "skinTempSample", "respSample",
"gravitySample", "stepSample", "ppgHrSample",
"sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample"]
"sleepStateSample", "ppgWaveformSample", "v18AuxSample"]
var rawDeleted = 0
for table in rawTables {
try db.execute(sql: "DELETE FROM \(table) WHERE ts < ? OR ts > ?",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,6 @@ final class DeepCaptureChannelsTests: XCTestCase {
/// The shipped cap is a real bound, and documented in row terms.
func testShippedRetentionConstant() {
XCTAssertEqual(WhoopStore.v18AuxRetentionRows, 604_800) // 7 x 86_400 strap-seconds
XCTAssertGreaterThan(WhoopStore.v18AuxRetentionRows, WhoopStore.rawImuRetentionRows)
}

/// Deleting a device's data must clear the aux rows too — the same privacy rule every other
Expand Down
1 change: 1 addition & 0 deletions Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,5 @@ final class PruneTests: XCTestCase {
maxUnsyncedBytes: 1_000_000)
XCTAssertEqual(pruned, 0)
}

}
9 changes: 3 additions & 6 deletions Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,19 +275,16 @@ final class ReadTests: XCTestCase {
ppgWaveform: [PpgWaveformSample(ts: 400, samples: [1, 2, 3])],
v18Aux: [V18AuxSample(ts: 400, slotValues: [1, 2])]),
deviceId: "dev1")
_ = try await store.insertRawImu(deviceId: "dev1",
rows: [(ts: 400, cols: [Int16(1), Int16(2)])],
retentionRows: 1000)
try await store.enqueueRawBatch(
RawBatchMeta(batchId: "b1", deviceId: "dev1",
clockRef: ClockRef(device: 0, wall: 0), capturedAt: 1,
startTs: 0, endTs: 0, frameCount: 1, byteSize: 4),
frames: [[0xAA, 0x00, 0x01, 0x02]])
let stats = try await store.storageStats()
// dev1: 3 hr + 2 rr + 1 event + 1 battery + 1 spo2 + 1 skinTemp + 1 resp + 1 gravity
// + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux + 1 rawImu = 17
// other: 1 hr = 1 → 18 decoded rows across all 14 raw tables.
XCTAssertEqual(stats.decodedRows, 18)
// + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux = 16
// other: 1 hr = 1 → 17 decoded rows.
XCTAssertEqual(stats.decodedRows, 17)
XCTAssertEqual(stats.rawBatches, 1)
XCTAssertEqual(stats.rawBytes, 4)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"v37-daily-avg-sdnn",
"v38-apple-step-hour",
"v39-ppg-burst-index",
"v40-daily-skin-temp-absolute"
"v40-daily-skin-temp-absolute",
"v41-drop-raw-imu-sample"
],
"divergenceReasons": {
"android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android — the per-row upload flag belongs to an upload path that does not exist there — so it is dead width, not divergent data. Removing it needs a Room table rebuild.",
Expand Down Expand Up @@ -1259,34 +1260,6 @@
],
"indices": []
},
"rawImuSample": {
"platform": "both",
"columns": [
{
"name": "deviceId",
"affinity": "TEXT",
"notNull": true,
"default": null
},
{
"name": "ts",
"affinity": "INTEGER",
"notNull": true,
"default": null
},
{
"name": "samples",
"affinity": "BLOB",
"notNull": true,
"default": null
}
],
"primaryKey": [
"deviceId",
"ts"
],
"indices": []
},
"respSample": {
"platform": "both",
"columns": [
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<a href="https://www.reddit.com/r/NoopBand/">👽&nbsp;Reddit</a> ·
<a href="#features">Features</a> ·
<a href="docs/PROTOCOL.md">Protocol</a> ·
<a href="docs/RAW_DATA_CAPTURE.md">Raw data capture</a> ·
</p>

<p align="center">
Expand Down
Loading
Loading