From 72019700dd4f0d0b3fd47fd241abbd1398e407ec Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:19:13 +0200 Subject: [PATCH 1/9] fix(gen5): lenient v18 decode for real WHOOP 5 hardware captures Protocol's strict gravity gate rejected every v18 record in a hardware export (fw 50.40.1.0) while v20/v26 deep buffers dominated offload. Recover HR/RR via a hardware fallback with alternate unix offset and honest absent accel when gravity fails validation. --- lib/ble/ble_engine.dart | 89 +++++++++++++++++++++++- test/gen5_v18_hardware_lenient_test.dart | 84 ++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 test/gen5_v18_hardware_lenient_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 774cb45..232597e 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -84,6 +84,82 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive); /// trigger now that listening is continuous and there's no discrete sync end. typedef DataStoredSink = void Function(); +/// Plausible unix window for gen5 historical records (2020-01-01 .. 2030-01-01). +@visibleForTesting +bool gen5V18UnixPlausible(int unix) => + unix >= 1577836800 && unix < 1893456000; + +/// Read v18 unix from inner bytes. Protocol's shared header uses inner[7:11]; +/// real WHOOP 5 hardware exports (fw 50.40.x) occasionally land unix one byte +/// earlier when the record is padded to 112 B instead of the fixture's 116 B. +@visibleForTesting +int? gen5V18UnixFromInner(Uint8List inner) { + if (inner.length < 11) return null; + final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); + final at7 = view.getUint32(7, Endian.little); + if (gen5V18UnixPlausible(at7)) return at7; + if (inner.length >= 10) { + final at6 = view.getUint32(6, Endian.little); + if (gen5V18UnixPlausible(at6)) return at6; + } + return null; +} + +/// Hardware lenient v18 decode when [parseGen5Historical] returns null because +/// the protocol decoder's gravity/dynamic-accel gates reject real captures +/// (off-wrist / motion-heavy seconds still carry valid HR/RR). Never fabricates +/// gravity — ax/ay/az stay null when the vector fails the magnitude gate. +@visibleForTesting +Sample? sampleFromGen5V18Lenient(Uint8List inner) { + if (inner.length < kGen5V18MinInnerLen || inner[1] != 18) return null; + final unix = gen5V18UnixFromInner(inner); + if (unix == null) return null; + final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); + final counter = view.getUint32(3, Endian.little); + final hr = inner[14]; + if (hr != 0 && (hr < 25 || hr > 230)) return null; + + const minRrMs = 200; + const maxRrMs = 2500; + final declaredRr = inner[15]; + final rr = []; + if (declaredRr <= 4) { + for (int i = 0; i < declaredRr && 16 + 2 * i + 2 <= inner.length; i++) { + final val = view.getInt16(16 + 2 * i, Endian.little); + if (val >= minRrMs && val <= maxRrMs) rr.add(val); + } + } + + double? ax; + double? ay; + double? az; + if (inner.length >= 49) { + final gx = view.getFloat32(37, Endian.little); + final gy = view.getFloat32(41, Endian.little); + final gz = view.getFloat32(45, Endian.little); + if (gx.isFinite && gy.isFinite && gz.isFinite) { + final magSq = gx * gx + gy * gy + gz * gz; + // Same gate as protocol's Gen5V18Decoder — but abstain on ax/ay/az + // instead of rejecting the whole record (HR/RR are independent fields). + if (magSq >= 0.25 && magSq <= 2.25) { + ax = gx; + ay = gy; + az = gz; + } + } + } + + return Sample( + tsEpoch: unix, + counter: counter, + hr: hr, + rrIntervalsMs: rr, + ax: ax, + ay: ay, + az: az, + ); +} + /// Map a decoded gen5 historical record onto the band-agnostic `Sample` type, /// or null when this record kind has no `Sample` equivalent (yet). /// @@ -113,6 +189,17 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { ); } +/// Decode a gen5 historical inner frame to a band-agnostic [Sample], or null. +@visibleForTesting +Sample? decodeGen5HistoricalSample(Uint8List inner) { + final strict = sampleFromGen5Historical(parseGen5Historical(inner)); + if (strict != null) return strict; + if (inner.length > 1 && inner[1] == 18) { + return sampleFromGen5V18Lenient(inner); + } + return null; +} + @visibleForTesting int countHistoricalBurstPackets({ required Map dataPacketCountsByRevision, @@ -1959,7 +2046,7 @@ class BleEngine { // own raw-buffer storage (a future db table), not a 1Hz Sample, so they // fall through to the undecodable archive below — that is honest // (correctly-identified-but-not-yet-stored), not a decode failure. - sample = sampleFromGen5Historical(parseGen5Historical(frame.inner)); + sample = decodeGen5HistoricalSample(frame.inner); } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart new file mode 100644 index 0000000..de7daa0 --- /dev/null +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -0,0 +1,84 @@ +// Pins the WHOOP 5 hardware v18 lenient decode path against real captures +// from openstrap_export_1785863370590.db (fw 50.40.1.0). Protocol's strict +// Gen5V18Decoder rejects every one of these on gravity/dyn gates; the lenient +// path must still recover HR + unix without fabricating accel. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +Uint8List hex(String s) { + final clean = s.replaceAll(' ', ''); + final out = Uint8List(clean.length ~/ 2); + for (int i = 0; i < out.length; i++) { + out[i] = int.parse(clean.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} + +void main() { + group('gen5V18UnixFromInner — hardware export', () { + final inner = hex( + '2f1280540df701737c6a915c2f004d0000000000000000000021fb608c4d0000' + 'c330ebf63ecd4ad43f854b653e5298863da3017400000000000000000039014401' + '040d6003010c020c3100000000000000000000000000000000000000000000011f' + 'bed68080000000a80372c0000000', + ); + + test('strict protocol decode returns null (gravity gate)', () { + expect(parseGen5Historical(inner), isNull); + }); + + test('unix lands at inner[6] on 112-byte padded captures', () { + expect(gen5V18UnixFromInner(inner), 1786540801); + }); + + test('lenient path recovers HR without fabricating gravity', () { + final sample = sampleFromGen5V18Lenient(inner); + expect(sample, isNotNull); + expect(sample!.tsEpoch, 1786540801); + expect(sample.hr, 77); + expect(sample.ax, isNull); + expect(sample.ay, isNull); + expect(sample.az, isNull); + }); + + test('decodeGen5HistoricalSample prefers strict then lenient', () { + final sample = decodeGen5HistoricalSample(inner); + expect(sample, isNotNull); + expect(sample!.hr, 77); + }); + }); + + group('sampleFromGen5V18Lenient — all hardware-export v18 fixtures', () { + // One row per undecodable_rec_v18 in the export DB (12 total). + final fixtures = [ + '2f1280540df701737c6a915c2f004d0000000000000000000021fb608c4d0000c330ebf63ecd4ad43f854b653e5298863da3017400000000000000000039014401040d6003010c020c3100000000000000000000000000000000000000000000011fbed68080000000a80372c0000000', + '2f1280fa2af70142716af39939004700000000000000000000606f0781470000d820abee3e52c0ac3e00a0b93dcdcc9abe8702dd0000000000000000003e0149013b0d6003010c020c0000000000000000000000000000000000000000000000010081b780800000006c5c6fc0000000', + '2f12805a3ef701a2846af35158004100000000000000000000008e0788410000ff80ba953e5220863e9a59163e33bbbf3ec403d3000000000000000000490154018b0d6003010c020c00000000000000000000000000000000000000000000000100c2fa80800000006b34a0c0000000', + '2f1280494af80140b26a9d8f02003a00000000000000000000217c4a833c000077a2902d3f661e96be14ae103c3d0ad4bd001c6e0000000000000000004c015101760d6003010c020c21000000000000000000000000000000000000000000000100b6fc8080000000fd4a97c0000000', + '2f1280924ff80189b76a9deb11003d0000000000000000000020f6468e3f00005de3ddef3f5caf89bde1ba29bec345463e091c6e0000000000000000004c015401920d6003010c020c20000000000000000000000000000000000000000000000100b0d78080000000d4c765c0000000', + '2f12806d62f801aa826a64e17a004e0000000000000000000071f40f884e0000d72a29613e854b70beec91d5beec515b3d4d1da60000000000000000003b014601260d6003010c020c0100000000000000000000000000000000000000000000010092e38080000000612677c0000000', + '2f1280877ff801f0746aa9cc4c004f0000000000000000000270d808874f0000cbd67d02414879723f852ba83ec305013e8d1e84000000000000000000460151017b0d6003010c020c0000000000000000000000000000000000000000000000010098d08080000000091711c0000000', + '2f12809ca1f80114886a974721005b000000000000000000006176078d5b0000bff0d53b3e29dc2d3e9aa19c3e33f3b53e4c207b0000000000000000003e014901480d6003010c020c01000000000000000000000000000000000000000000000100a1f58080000000f9b43ac0000000', + '2f128070d2f801e8b86a97146e00750000000000000000000078520883750000d8e038b23e1fb541becd6c953dc3f5483e1e25ff00000001000000000057015701600d6004010c020c08000000000000000000000000000000000000000000000100617d8080000000bf4c66c0000000', + '2f128069d4f801e1ba6a97eb71005e00000000000000000000615403805e0000d1fc60963ee17a053e0ae7753e52b8213e2027bd00000001000000000036014101f70c6004010c020c01000000000000000000000000000000000000000000000100617d808000000090aaa9bf000000', + '2f12809bd9f80113c06a97997900580000000000000000000061e10b86580000dad8ea093f9a49d5be00000bbcec01623ef427620000000100000000003b014901340d6004010c020c01000000000000000000000000000000000000000000000100689380800000001e8753c0000000', + '2f128071dbf801e9c16a97287c00650000000000000000000071420e87650000daa86c493f52b83cbe9a693b3e858b7f3e6d28ff00000000000000000041014c01340d6004010c020c01000000000000000000000000000000000000000000000100ecf88080000000af6201c0000000', + ]; + + test('every export v18 decodes to a sample with plausible unix + hr', () { + for (final h in fixtures) { + final inner = hex(h); + expect(parseGen5Historical(inner), isNull, + reason: 'strict decode should still reject these fixtures'); + final sample = sampleFromGen5V18Lenient(inner); + expect(sample, isNotNull, reason: 'lenient decode failed for $h'); + expect(gen5V18UnixPlausible(sample!.tsEpoch), isTrue); + expect(sample.hr == 0 || (sample.hr >= 25 && sample.hr <= 230), isTrue); + } + }); + }); +} From 638c16d75d845e3cc113d6f3f565363a0e338b93 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:15:02 +0200 Subject: [PATCH 2/9] fix(gen5): honest v18 unix + SET_CLOCK revision byte Drop the misaligned unix@6 fallback that could invent timestamps, and prepend revision1 on gen5 SET_CLOCK/GET_CLOCK only (WHOOP 4 path unchanged). Hardware showed Invalid revision when body[0] was the epoch low byte; a later connect with the 9-byte form correlated with drift=0. --- lib/ble/ble_engine.dart | 88 ++++++++++++------- test/gen5_v18_hardware_lenient_test.dart | 103 ++++++++++++----------- 2 files changed, 114 insertions(+), 77 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 232597e..0d99538 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -89,26 +89,26 @@ typedef DataStoredSink = void Function(); bool gen5V18UnixPlausible(int unix) => unix >= 1577836800 && unix < 1893456000; -/// Read v18 unix from inner bytes. Protocol's shared header uses inner[7:11]; -/// real WHOOP 5 hardware exports (fw 50.40.x) occasionally land unix one byte -/// earlier when the record is padded to 112 B instead of the fixture's 116 B. +/// Read v18 unix from the protocol shared header at inner[7:11]. +/// +/// Do **not** fall back to a misaligned offset-6 read: that overlaps the +/// record-index high byte and can invent a year-window-plausible timestamp +/// that is not monotonic with `recordIndex` (honesty violation — observed on +/// the fw 50.40.1.0 export where unix@7 was garbage because SET_CLOCK never +/// latched). Absent a plausible unix@7, abstain. @visibleForTesting int? gen5V18UnixFromInner(Uint8List inner) { if (inner.length < 11) return null; final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); final at7 = view.getUint32(7, Endian.little); - if (gen5V18UnixPlausible(at7)) return at7; - if (inner.length >= 10) { - final at6 = view.getUint32(6, Endian.little); - if (gen5V18UnixPlausible(at6)) return at6; - } - return null; + return gen5V18UnixPlausible(at7) ? at7 : null; } /// Hardware lenient v18 decode when [parseGen5Historical] returns null because /// the protocol decoder's gravity/dynamic-accel gates reject real captures /// (off-wrist / motion-heavy seconds still carry valid HR/RR). Never fabricates /// gravity — ax/ay/az stay null when the vector fails the magnitude gate. +/// Never fabricates time — requires a plausible unix@7. @visibleForTesting Sample? sampleFromGen5V18Lenient(Uint8List inner) { if (inner.length < kGen5V18MinInnerLen || inner[1] != 18) return null; @@ -160,6 +160,30 @@ Sample? sampleFromGen5V18Lenient(Uint8List inner) { ); } +/// Build gen5 SET_CLOCK_MAVERICK (0x92) / GET_CLOCK_GEN5 (0x93) payloads. +/// +/// Hardware evidence (fw 50.40.1.0 console): an empty GET_CLOCK body logs +/// `Invalid revision for get clock: 0`, and SET_CLOCK without a leading +/// form/revision byte logs `Invalid revision ` — i.e. the strap +/// treats body[0] as the command revision (same role as HELLO's `[0x01]` / +/// Alec's gen5 `b3`). Prepend [revision1] so the 8-byte time field starts at +/// body[1]. +@visibleForTesting +List gen5SetClockPayload({required int sec, required int subsec}) => [ + revision1, + sec & 0xff, + (sec >> 8) & 0xff, + (sec >> 16) & 0xff, + (sec >> 24) & 0xff, + subsec & 0xff, + (subsec >> 8) & 0xff, + 0, + 0, + ]; + +@visibleForTesting +List gen5GetClockPayload() => const [revision1]; + /// Map a decoded gen5 historical record onto the band-agnostic `Sample` type, /// or null when this record kind has no `Sample` equivalent (yet). /// @@ -2977,26 +3001,26 @@ class BleEngine { final ms = DateTime.now().millisecondsSinceEpoch; final sec = ms ~/ 1000; final subsec = ((ms % 1000) * 32768) ~/ 1000; // 0..32767, 1/32768 s units - final payload = [ - sec & 0xff, - (sec >> 8) & 0xff, - (sec >> 16) & 0xff, - (sec >> 24) & 0xff, - subsec & 0xff, - (subsec >> 8) & 0xff, - 0, - 0, - ]; - // gen5 ("Maverick") uses a DIFFERENT opcode for SET_CLOCK than gen4 — the - // 8-byte payload shape is unchanged, only the opcode value differs (see - // Cmd.setClockMaverick's doc in protocol/constants.dart). Sending gen4's - // opcode 0x0A to a gen5 strap here would silently fail to latch the RTC, - // which then refuses to serve type-47 history — the exact symptom fixed. + // gen5 ("Maverick") uses a DIFFERENT opcode for SET_CLOCK than gen4, and + // (per fw 50.40.1.0 console) also needs a leading revision/form byte — see + // [gen5SetClockPayload]. Gen4 keeps the hardware-verified 8-byte body. final isGen5 = _session?.band.isGen5 ?? false; + final payload = isGen5 + ? gen5SetClockPayload(sec: sec, subsec: subsec) + : [ + sec & 0xff, + (sec >> 8) & 0xff, + (sec >> 16) & 0xff, + (sec >> 24) & 0xff, + subsec & 0xff, + (subsec >> 8) & 0xff, + 0, + 0, + ]; final opcode = isGen5 ? Cmd.setClockMaverick : Cmd.setClock; await _send(opcode, payload); _log('SET_CLOCK${isGen5 ? " (gen5 Maverick)" : ""} → sec=$sec ' - 'subsec=$subsec (WHOOP-exact 8B).'); + 'subsec=$subsec (${payload.length}B${isGen5 ? ", rev=$revision1" : ""}).'); // Read the RTC back so the GET_CLOCK response handler can confirm it latched // (and re-issue SET_CLOCK if the strap clock is still off — see _onDecoded). await getClock(); @@ -3004,11 +3028,15 @@ class BleEngine { /// Read the strap RTC. The response carries `clock_epoch`, handled where we /// verify drift and re-correlate the strap-RTC ↔ wall clock. gen5 uses its - /// own GET_CLOCK opcode (147) — see [setClock]. - Future getClock() => _send( - (_session?.band.isGen5 ?? false) ? Cmd.getClockGen5 : Cmd.getClock, - const [], - ); + /// own GET_CLOCK opcode (147) and needs a leading revision byte — see + /// [gen5GetClockPayload] / [setClock]. + Future getClock() { + final isGen5 = _session?.band.isGen5 ?? false; + return _send( + isGen5 ? Cmd.getClockGen5 : Cmd.getClock, + isGen5 ? gen5GetClockPayload() : const [], + ); + } /// On-device wake alarm (SET_ALARM_TIME = 0x42) — the RICH 20-byte form that /// actually FIRES on WHOOP 4.0: diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index de7daa0..8959b97 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -1,7 +1,5 @@ -// Pins the WHOOP 5 hardware v18 lenient decode path against real captures -// from openstrap_export_1785863370590.db (fw 50.40.1.0). Protocol's strict -// Gen5V18Decoder rejects every one of these on gravity/dyn gates; the lenient -// path must still recover HR + unix without fabricating accel. +// Pins WHOOP 5 hardware v18 + clock payload behaviour against real evidence +// from openstrap_export_1785863370590.db / openstrap_sync.log (fw 50.40.1.0). import 'dart:typed_data'; @@ -19,7 +17,30 @@ Uint8List hex(String s) { } void main() { - group('gen5V18UnixFromInner — hardware export', () { + group('gen5 clock payloads — fw 50.40.1.0 Invalid revision evidence', () { + test('SET_CLOCK prepends revision1 before the 8-byte time', () { + final p = gen5SetClockPayload(sec: 1785710096, subsec: 16351); + expect(p, hasLength(9)); + expect(p[0], revision1); + // Seconds LE start at body[1] — not body[0] (that was read as "revision"). + expect(p[1], 1785710096 & 0xff); + expect(p[2], (1785710096 >> 8) & 0xff); + expect(p[3], (1785710096 >> 16) & 0xff); + expect(p[4], (1785710096 >> 24) & 0xff); + expect(p[5], 16351 & 0xff); + expect(p[6], (16351 >> 8) & 0xff); + expect(p[7], 0); + expect(p[8], 0); + }); + + test('GET_CLOCK sends revision1 (empty body logged revision 0)', () { + expect(gen5GetClockPayload(), [revision1]); + }); + }); + + group('gen5V18UnixFromInner — no fabricated misaligned unix', () { + // Real archive blob #1: unix@7 is garbage; offset-6 is a year-plausible + // false positive that is NOT monotonic with recordIndex. final inner = hex( '2f1280540df701737c6a915c2f004d0000000000000000000021fb608c4d0000' 'c330ebf63ecd4ad43f854b653e5298863da3017400000000000000000039014401' @@ -27,58 +48,46 @@ void main() { 'bed68080000000a80372c0000000', ); - test('strict protocol decode returns null (gravity gate)', () { + test('strict protocol decode returns null (gravity/dyn gate)', () { expect(parseGen5Historical(inner), isNull); }); - test('unix lands at inner[6] on 112-byte padded captures', () { - expect(gen5V18UnixFromInner(inner), 1786540801); + test('unix@7 garbage → abstain (do not invent offset-6 time)', () { + expect(gen5V18UnixFromInner(inner), isNull); + expect(sampleFromGen5V18Lenient(inner), isNull); + expect(decodeGen5HistoricalSample(inner), isNull); }); + }); - test('lenient path recovers HR without fabricating gravity', () { + group('sampleFromGen5V18Lenient — gravity-only abstention needs good unix', () { + // Synthetic: valid shared header unix@7 + HR, gravity out of gate range. + // Layout matches Gen5HistoricalHeader + HR @14. + test('recovers HR when unix@7 plausible and gravity fails gate', () { + final inner = Uint8List(112); + inner[0] = 0x2f; + inner[1] = 18; + inner[2] = 0x80; + // recordIndex = 1 + inner[3] = 1; + // unix = 1785801600 (2026-08-04 00:00:00 UTC) + const unix = 1785801600; + inner.buffer.asByteData().setUint32(7, unix, Endian.little); + inner[14] = 72; // HR + inner[15] = 0; // no RR + // dynAccel = 0.5 (ok), gravity magnitude ~0.1 (fails 0.5..1.5 gate) + inner.buffer.asByteData().setFloat32(33, 0.5, Endian.little); + inner.buffer.asByteData().setFloat32(37, 0.05, Endian.little); + inner.buffer.asByteData().setFloat32(41, 0.05, Endian.little); + inner.buffer.asByteData().setFloat32(45, 0.05, Endian.little); + + expect(parseGen5Historical(inner), isNull); final sample = sampleFromGen5V18Lenient(inner); expect(sample, isNotNull); - expect(sample!.tsEpoch, 1786540801); - expect(sample.hr, 77); + expect(sample!.tsEpoch, unix); + expect(sample.hr, 72); expect(sample.ax, isNull); expect(sample.ay, isNull); expect(sample.az, isNull); }); - - test('decodeGen5HistoricalSample prefers strict then lenient', () { - final sample = decodeGen5HistoricalSample(inner); - expect(sample, isNotNull); - expect(sample!.hr, 77); - }); - }); - - group('sampleFromGen5V18Lenient — all hardware-export v18 fixtures', () { - // One row per undecodable_rec_v18 in the export DB (12 total). - final fixtures = [ - '2f1280540df701737c6a915c2f004d0000000000000000000021fb608c4d0000c330ebf63ecd4ad43f854b653e5298863da3017400000000000000000039014401040d6003010c020c3100000000000000000000000000000000000000000000011fbed68080000000a80372c0000000', - '2f1280fa2af70142716af39939004700000000000000000000606f0781470000d820abee3e52c0ac3e00a0b93dcdcc9abe8702dd0000000000000000003e0149013b0d6003010c020c0000000000000000000000000000000000000000000000010081b780800000006c5c6fc0000000', - '2f12805a3ef701a2846af35158004100000000000000000000008e0788410000ff80ba953e5220863e9a59163e33bbbf3ec403d3000000000000000000490154018b0d6003010c020c00000000000000000000000000000000000000000000000100c2fa80800000006b34a0c0000000', - '2f1280494af80140b26a9d8f02003a00000000000000000000217c4a833c000077a2902d3f661e96be14ae103c3d0ad4bd001c6e0000000000000000004c015101760d6003010c020c21000000000000000000000000000000000000000000000100b6fc8080000000fd4a97c0000000', - '2f1280924ff80189b76a9deb11003d0000000000000000000020f6468e3f00005de3ddef3f5caf89bde1ba29bec345463e091c6e0000000000000000004c015401920d6003010c020c20000000000000000000000000000000000000000000000100b0d78080000000d4c765c0000000', - '2f12806d62f801aa826a64e17a004e0000000000000000000071f40f884e0000d72a29613e854b70beec91d5beec515b3d4d1da60000000000000000003b014601260d6003010c020c0100000000000000000000000000000000000000000000010092e38080000000612677c0000000', - '2f1280877ff801f0746aa9cc4c004f0000000000000000000270d808874f0000cbd67d02414879723f852ba83ec305013e8d1e84000000000000000000460151017b0d6003010c020c0000000000000000000000000000000000000000000000010098d08080000000091711c0000000', - '2f12809ca1f80114886a974721005b000000000000000000006176078d5b0000bff0d53b3e29dc2d3e9aa19c3e33f3b53e4c207b0000000000000000003e014901480d6003010c020c01000000000000000000000000000000000000000000000100a1f58080000000f9b43ac0000000', - '2f128070d2f801e8b86a97146e00750000000000000000000078520883750000d8e038b23e1fb541becd6c953dc3f5483e1e25ff00000001000000000057015701600d6004010c020c08000000000000000000000000000000000000000000000100617d8080000000bf4c66c0000000', - '2f128069d4f801e1ba6a97eb71005e00000000000000000000615403805e0000d1fc60963ee17a053e0ae7753e52b8213e2027bd00000001000000000036014101f70c6004010c020c01000000000000000000000000000000000000000000000100617d808000000090aaa9bf000000', - '2f12809bd9f80113c06a97997900580000000000000000000061e10b86580000dad8ea093f9a49d5be00000bbcec01623ef427620000000100000000003b014901340d6004010c020c01000000000000000000000000000000000000000000000100689380800000001e8753c0000000', - '2f128071dbf801e9c16a97287c00650000000000000000000071420e87650000daa86c493f52b83cbe9a693b3e858b7f3e6d28ff00000000000000000041014c01340d6004010c020c01000000000000000000000000000000000000000000000100ecf88080000000af6201c0000000', - ]; - - test('every export v18 decodes to a sample with plausible unix + hr', () { - for (final h in fixtures) { - final inner = hex(h); - expect(parseGen5Historical(inner), isNull, - reason: 'strict decode should still reject these fixtures'); - final sample = sampleFromGen5V18Lenient(inner); - expect(sample, isNotNull, reason: 'lenient decode failed for $h'); - expect(gen5V18UnixPlausible(sample!.tsEpoch), isTrue); - expect(sample.hr == 0 || (sample.hr >= 25 && sample.hr <= 230), isTrue); - } - }); }); } From 0ab4e4a09e032037a0ff90a74feb100c14264ded Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:58:40 +0200 Subject: [PATCH 3/9] fix(gen5): persist v18 samples to decoded_onehz + align unix gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gen5 v18/lenient samples lack gen4 optics so R24 decode fails and decoded_onehz stayed empty — fall back to the BLE-preferred Sample when tsEpoch > 0, excluding gen4 R10-lite hr-only records. Align gen5V18UnixFromInner with RecordGate's isPlausibleUnix(wallNow) so implausible timestamps archive instead of silently dropping after decode. Restore git-sourced analytics lock (cbbe06a). --- lib/ble/ble_engine.dart | 22 +- lib/data/db.dart | 56 +++-- test/gen5_decoded_onehz_persistence_test.dart | 213 ++++++++++++++++++ test/gen5_v18_hardware_lenient_test.dart | 10 +- 4 files changed, 266 insertions(+), 35 deletions(-) create mode 100644 test/gen5_decoded_onehz_persistence_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 0d99538..ac37816 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -84,11 +84,6 @@ typedef ArchiveSink = Future Function(ArchiveRecord archive); /// trigger now that listening is continuous and there's no discrete sync end. typedef DataStoredSink = void Function(); -/// Plausible unix window for gen5 historical records (2020-01-01 .. 2030-01-01). -@visibleForTesting -bool gen5V18UnixPlausible(int unix) => - unix >= 1577836800 && unix < 1893456000; - /// Read v18 unix from the protocol shared header at inner[7:11]. /// /// Do **not** fall back to a misaligned offset-6 read: that overlaps the @@ -97,11 +92,11 @@ bool gen5V18UnixPlausible(int unix) => /// the fw 50.40.1.0 export where unix@7 was garbage because SET_CLOCK never /// latched). Absent a plausible unix@7, abstain. @visibleForTesting -int? gen5V18UnixFromInner(Uint8List inner) { +int? gen5V18UnixFromInner(Uint8List inner, int wallNow) { if (inner.length < 11) return null; final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); final at7 = view.getUint32(7, Endian.little); - return gen5V18UnixPlausible(at7) ? at7 : null; + return isPlausibleUnix(at7, wallNow) ? at7 : null; } /// Hardware lenient v18 decode when [parseGen5Historical] returns null because @@ -110,9 +105,9 @@ int? gen5V18UnixFromInner(Uint8List inner) { /// gravity — ax/ay/az stay null when the vector fails the magnitude gate. /// Never fabricates time — requires a plausible unix@7. @visibleForTesting -Sample? sampleFromGen5V18Lenient(Uint8List inner) { +Sample? sampleFromGen5V18Lenient(Uint8List inner, int wallNow) { if (inner.length < kGen5V18MinInnerLen || inner[1] != 18) return null; - final unix = gen5V18UnixFromInner(inner); + final unix = gen5V18UnixFromInner(inner, wallNow); if (unix == null) return null; final view = inner.buffer.asByteData(inner.offsetInBytes, inner.lengthInBytes); final counter = view.getUint32(3, Endian.little); @@ -215,11 +210,11 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { /// Decode a gen5 historical inner frame to a band-agnostic [Sample], or null. @visibleForTesting -Sample? decodeGen5HistoricalSample(Uint8List inner) { +Sample? decodeGen5HistoricalSample(Uint8List inner, int wallNow) { final strict = sampleFromGen5Historical(parseGen5Historical(inner)); if (strict != null) return strict; if (inner.length > 1 && inner[1] == 18) { - return sampleFromGen5V18Lenient(inner); + return sampleFromGen5V18Lenient(inner, wallNow); } return null; } @@ -2061,6 +2056,7 @@ class BleEngine { // backfill (all received in one sync) splits into correct per-real-day // buckets instead of collapsing into one "today". Sample? sample; + final wallNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; final isGen5 = _session?.band.isGen5 ?? false; if (isGen5) { // gen5 (WHOOP 5): `parseGen5Historical` dispatches across all four real @@ -2070,7 +2066,7 @@ class BleEngine { // own raw-buffer storage (a future db table), not a 1Hz Sample, so they // fall through to the undecodable archive below — that is honest // (correctly-identified-but-not-yet-stored), not a decode failure. - sample = decodeGen5HistoricalSample(frame.inner); + sample = decodeGen5HistoricalSample(frame.inner, wallNow); } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. @@ -2134,7 +2130,7 @@ class BleEngine { // Past this point [sample] is non-null — undecodable records returned above. if (!_recordGate.admit( sample.tsEpoch, - wallNow: DateTime.now().millisecondsSinceEpoch ~/ 1000, + wallNow: wallNow, sessionOldestUnix: _sessionOldestUnix, sessionNewestUnix: _sessionNewestUnix, )) { diff --git a/lib/data/db.dart b/lib/data/db.dart index 33d11f7..efa76ca 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -11,6 +11,7 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:openstrap_analytics/onehz.dart' as ana; import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; @@ -1856,32 +1857,51 @@ class LocalDb { static String _localDayLabelFromEpoch(int epochSec) => _localDayLabel(DateTime.fromMillisecondsSinceEpoch(epochSec * 1000)); + /// Gen4 historical R10-lite (hr-only, no accel/optical) must stay out of + /// `decoded_onehz` — they belong in the legacy `samples` table only. + static bool _isGen4R10LiteHistorical(Uint8List inner) => + inner.isNotEmpty && + inner[0] == proto.PacketType.historicalData && + inner.length > 1 && + inner[1] == proto.Record.r10; + static Sample? _decodeOneHzSample(RawRecord raw, {Sample? preferred}) { if (preferred != null && preferred.hasDecodedOneHz) return preferred; + Uint8List bytes; + try { + bytes = proto.hexToBytes(raw.hex); + } catch (_) { + return null; + } try { // Legacy decoder first, firmware-fallback chain second — see // FirmwareAwareR24Decoder. This path only runs when no pre-decoded // `preferred` sample was supplied (e.g. a raw-hex import/merge), so a // fresh per-call instance is fine — no session state to preserve. - final r = proto.FirmwareAwareR24Decoder().decode( - proto.hexToBytes(raw.hex), - ); - if (r == null || r.tsEpoch <= 0) return null; - return Sample( - tsEpoch: r.tsEpoch, - counter: r.counter, - hr: r.hr, - rrIntervalsMs: List.from(r.rrIntervalsMs), - ax: r.accelG.isNotEmpty ? r.accelG[0] : 0, - ay: r.accelG.length > 1 ? r.accelG[1] : 0, - az: r.accelG.length > 2 ? r.accelG[2] : 0, - spo2RedRaw: r.spo2RedRaw, - spo2IrRaw: r.spo2IrRaw, - skinTempRaw: r.skinTempRaw, - ); - } catch (_) { - return null; + final r = proto.FirmwareAwareR24Decoder().decode(bytes); + if (r != null && r.tsEpoch > 0) { + return Sample( + tsEpoch: r.tsEpoch, + counter: r.counter, + hr: r.hr, + rrIntervalsMs: List.from(r.rrIntervalsMs), + ax: r.accelG.isNotEmpty ? r.accelG[0] : 0, + ay: r.accelG.length > 1 ? r.accelG[1] : 0, + az: r.accelG.length > 2 ? r.accelG[2] : 0, + spo2RedRaw: r.spo2RedRaw, + spo2IrRaw: r.spo2IrRaw, + skinTempRaw: r.skinTempRaw, + ); + } + } catch (_) {} + // Gen5 v18 / lenient samples carry HR/RR/gravity but lack gen4 optics — + // `hasDecodedOneHz` stays false, yet they are honest 1 Hz substrate rows. + if (preferred != null && + preferred.tsEpoch > 0 && + !_isGen4R10LiteHistorical(bytes)) { + return preferred; } + return null; } /// THE orphan guard for an INSERT-OR-REPLACE into `decoded_onehz`. diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart new file mode 100644 index 0000000..e724007 --- /dev/null +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -0,0 +1,213 @@ +// Gen5 v18 samples must land in `decoded_onehz` via the preferred-sample +// fallback in LocalDb._decodeOneHzSample — they lack gen4 optics so R24 +// decode fails, but they are honest 1 Hz substrate rows. R10-lite hr-only +// records must stay excluded. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/models.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +String _bytesToHex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + +Uint8List _buildR10LiteInner({required int ts, required int counter, required int hr}) { + final inner = Uint8List(18); + inner[0] = PacketType.historicalData; + inner[1] = Record.r10; + inner.buffer.asByteData().setUint32(3, counter, Endian.little); + inner.buffer.asByteData().setUint32(7, ts, Endian.little); + inner[17] = hr; + return inner; +} + +/// Synthetic gen5 v18 lenient inner: valid unix@7 + HR, gravity fails gate. +Uint8List _buildGen5V18LenientInner({ + required int unix, + required int counter, + required int hr, + List rrMs = const [], +}) { + final inner = Uint8List(112); + inner[0] = PacketType.historicalData; + inner[1] = 18; + inner[2] = 0x80; + inner[3] = counter & 0xff; + inner[4] = (counter >> 8) & 0xff; + inner[5] = (counter >> 16) & 0xff; + inner[6] = (counter >> 24) & 0xff; + inner.buffer.asByteData().setUint32(7, unix, Endian.little); + inner[14] = hr; + inner[15] = rrMs.length.clamp(0, 4); + final view = inner.buffer.asByteData(); + for (var i = 0; i < rrMs.length && i < 4; i++) { + view.setInt16(16 + 2 * i, rrMs[i], Endian.little); + } + view.setFloat32(33, 0.5, Endian.little); + view.setFloat32(37, 0.05, Endian.little); + view.setFloat32(41, 0.05, Endian.little); + view.setFloat32(45, 0.05, Endian.little); + return inner; +} + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_gen5_onehz_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + group('gen5 → decoded_onehz persistence', () { + test('gen5 v18-shaped sample persists via preferred fallback (+ RR)', () async { + // Real fixture inner — same bytes as gen5_sample_mapping_test.dart. + final frameHex = + 'aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000' + '000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000' + '000000000000f7000901f10b0007010c020c000000000000000000000000000' + '00000000000000000000100656f1e1e0000009d61a7c00000003e862817'; + final frame = Uint8List.fromList( + List.generate(frameHex.length ~/ 2, (i) { + return int.parse(frameHex.substring(i * 2, i * 2 + 2), radix: 16); + }), + ); + final parsed = parseFrame(frame, profile: BandProfile.gen5)!; + final inner = parsed.inner; + final sample = sampleFromGen5Historical(parseGen5Historical(inner)); + expect(sample, isNotNull); + + const recTs = 1780916150; + final raw = RawRecord( + counter: sample!.counter, + packetType: PacketType.historicalData, + hex: _bytesToHex(inner), + capturedAt: recTs * 1000, + recTs: recTs, + ); + + await LocalDb.commitSyncBatch([raw], [sample]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [recTs], + ); + expect(rows.length, 1); + expect(rows.first['hr'], 102); + expect(rows.first['counter'], sample.counter); + + final rr = await db.query( + 'decoded_rr', + where: 'counter = ?', + whereArgs: [sample.counter], + ); + expect(rr.length, 2); + expect([for (final r in rr) r['rr_ms']], containsAll([602, 613])); + }); + + test('lenient v18 with null accel still persists (stored as 0)', () async { + const unix = 1785801600; + const counter = 42; + final inner = _buildGen5V18LenientInner(unix: unix, counter: counter, hr: 72); + final sample = sampleFromGen5V18Lenient(inner, unix); + expect(sample, isNotNull); + expect(sample!.ax, isNull); + + final raw = RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: _bytesToHex(inner), + capturedAt: unix * 1000, + recTs: unix, + ); + await LocalDb.commitSyncBatch([raw], [sample]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [unix], + ); + expect(rows.length, 1); + expect(rows.first['hr'], 72); + expect(rows.first['ax'], 0); + expect(rows.first['ay'], 0); + expect(rows.first['az'], 0); + }); + + test('R10-lite hr-only + preferred → no decoded_onehz row', () async { + const ts = 1780000100; + const counter = 99; + final inner = _buildR10LiteInner(ts: ts, counter: counter, hr: 65); + final preferred = Sample(tsEpoch: ts, counter: counter, hr: 65); + final raw = RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: _bytesToHex(inner), + capturedAt: ts * 1000, + recTs: ts, + ); + + await LocalDb.commitSyncBatch([raw], [preferred]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [ts], + ); + expect(rows, isEmpty); + }); + + test('full gen4 R24 sample still persists', () async { + const ts = 1780000200; + const counter = 5001; + final sample = Sample( + tsEpoch: ts, + counter: counter, + hr: 70, + rrIntervalsMs: [800], + ax: 0.1, + ay: -0.2, + az: 0.95, + spo2RedRaw: 100, + spo2IrRaw: 200, + skinTempRaw: 300, + ); + // Minimal non-R10 historical hex — R24 decode won't match, but preferred + // has full gen4 optics so _decodeOneHzSample returns it immediately. + final raw = RawRecord( + counter: counter, + packetType: PacketType.historicalData, + hex: '2f18' '00' * 20, + capturedAt: ts * 1000, + recTs: ts, + ); + + await LocalDb.commitSyncBatch([raw], [sample]); + + final db = await LocalDb.instance; + final rows = await db.query( + 'decoded_onehz', + where: 'rec_ts = ?', + whereArgs: [ts], + ); + expect(rows.length, 1); + expect(rows.first['hr'], 70); + expect(rows.first['spo2_red_raw'], 100); + }); + }); +} diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index 8959b97..e64cf04 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -53,9 +53,10 @@ void main() { }); test('unix@7 garbage → abstain (do not invent offset-6 time)', () { - expect(gen5V18UnixFromInner(inner), isNull); - expect(sampleFromGen5V18Lenient(inner), isNull); - expect(decodeGen5HistoricalSample(inner), isNull); + const wallNow = 1785863370; // export capture era + expect(gen5V18UnixFromInner(inner, wallNow), isNull); + expect(sampleFromGen5V18Lenient(inner, wallNow), isNull); + expect(decodeGen5HistoricalSample(inner, wallNow), isNull); }); }); @@ -81,7 +82,8 @@ void main() { inner.buffer.asByteData().setFloat32(45, 0.05, Endian.little); expect(parseGen5Historical(inner), isNull); - final sample = sampleFromGen5V18Lenient(inner); + const wallNow = unix; // plausible vs the synthetic timestamp + final sample = sampleFromGen5V18Lenient(inner, wallNow); expect(sample, isNotNull); expect(sample!.tsEpoch, unix); expect(sample.hr, 72); From 1f85b10a3c128113c7afd9ed7361035ae9a85cb9 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:35:33 +0200 Subject: [PATCH 4/9] fix(db): reject R10-lite before preferred hasDecodedOneHz short-circuit Parse raw hex and gate Gen4 R10-lite records before accepting a complete preferred sample into decoded_onehz. Exercise lenient v18 recovery through decodeGen5HistoricalSample with HR+RR in hardware tests. --- lib/data/db.dart | 7 +++---- test/gen5_v18_hardware_lenient_test.dart | 12 +++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index efa76ca..8bbb84c 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1866,13 +1866,14 @@ class LocalDb { inner[1] == proto.Record.r10; static Sample? _decodeOneHzSample(RawRecord raw, {Sample? preferred}) { - if (preferred != null && preferred.hasDecodedOneHz) return preferred; Uint8List bytes; try { bytes = proto.hexToBytes(raw.hex); } catch (_) { return null; } + if (_isGen4R10LiteHistorical(bytes)) return null; + if (preferred != null && preferred.hasDecodedOneHz) return preferred; try { // Legacy decoder first, firmware-fallback chain second — see // FirmwareAwareR24Decoder. This path only runs when no pre-decoded @@ -1896,9 +1897,7 @@ class LocalDb { } catch (_) {} // Gen5 v18 / lenient samples carry HR/RR/gravity but lack gen4 optics — // `hasDecodedOneHz` stays false, yet they are honest 1 Hz substrate rows. - if (preferred != null && - preferred.tsEpoch > 0 && - !_isGen4R10LiteHistorical(bytes)) { + if (preferred != null && preferred.tsEpoch > 0) { return preferred; } return null; diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index e64cf04..a323f9e 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -60,10 +60,10 @@ void main() { }); }); - group('sampleFromGen5V18Lenient — gravity-only abstention needs good unix', () { - // Synthetic: valid shared header unix@7 + HR, gravity out of gate range. + group('decodeGen5HistoricalSample — lenient v18 production path', () { + // Synthetic: valid shared header unix@7 + HR + RR, gravity out of gate range. // Layout matches Gen5HistoricalHeader + HR @14. - test('recovers HR when unix@7 plausible and gravity fails gate', () { + test('recovers HR/RR when strict decode fails gravity gate only', () { final inner = Uint8List(112); inner[0] = 0x2f; inner[1] = 18; @@ -74,7 +74,8 @@ void main() { const unix = 1785801600; inner.buffer.asByteData().setUint32(7, unix, Endian.little); inner[14] = 72; // HR - inner[15] = 0; // no RR + inner[15] = 1; // one RR interval + inner.buffer.asByteData().setInt16(16, 820, Endian.little); // dynAccel = 0.5 (ok), gravity magnitude ~0.1 (fails 0.5..1.5 gate) inner.buffer.asByteData().setFloat32(33, 0.5, Endian.little); inner.buffer.asByteData().setFloat32(37, 0.05, Endian.little); @@ -83,10 +84,11 @@ void main() { expect(parseGen5Historical(inner), isNull); const wallNow = unix; // plausible vs the synthetic timestamp - final sample = sampleFromGen5V18Lenient(inner, wallNow); + final sample = decodeGen5HistoricalSample(inner, wallNow); expect(sample, isNotNull); expect(sample!.tsEpoch, unix); expect(sample.hr, 72); + expect(sample.rrIntervalsMs, [820]); expect(sample.ax, isNull); expect(sample.ay, isNull); expect(sample.az, isNull); From 3a9de5b6ee338b895bb12f7ee949714ec5470f14 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:36:01 +0200 Subject: [PATCH 5/9] test(gen5): add dynamic-gate lenient v18 recovery case Cover strict dynamic-accel rejection with valid gravity through the production decodeGen5HistoricalSample path. --- test/gen5_v18_hardware_lenient_test.dart | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index a323f9e..f22019b 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -93,5 +93,33 @@ void main() { expect(sample.ay, isNull); expect(sample.az, isNull); }); + + test('strict dynamic-accel rejection still recovers HR via lenient path', () { + final inner = Uint8List(112); + inner[0] = 0x2f; + inner[1] = 18; + inner[2] = 0x80; + inner[3] = 1; + const unix = 1785801600; + inner.buffer.asByteData().setUint32(7, unix, Endian.little); + inner[14] = 80; + inner[15] = 0; + final view = inner.buffer.asByteData(); + // High dynamic accel fails strict gate; gravity is valid ~1g. + view.setFloat32(33, 5.0, Endian.little); + view.setFloat32(37, 0.0, Endian.little); + view.setFloat32(41, 0.0, Endian.little); + view.setFloat32(45, 1.0, Endian.little); + + expect(parseGen5Historical(inner), isNull); + const wallNow = unix; + final sample = decodeGen5HistoricalSample(inner, wallNow); + expect(sample, isNotNull); + expect(sample!.hr, 80); + // Lenient path keeps gravity when magnitude gate passes. + expect(sample.ax, closeTo(0.0, 1e-6)); + expect(sample.ay, closeTo(0.0, 1e-6)); + expect(sample.az, closeTo(1.0, 1e-6)); + }); }); } From cc53fe1ca85b8fe3e7377a8cd1be7d93ac3386a6 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:36:17 +0200 Subject: [PATCH 6/9] test(gen5): drop inaccurate dynamic-only lenient case Strict parseGen5Historical accepts high dynamic accel when gravity is valid; lenient fallback is exercised via the gravity-gate case only. --- test/gen5_v18_hardware_lenient_test.dart | 28 ------------------------ 1 file changed, 28 deletions(-) diff --git a/test/gen5_v18_hardware_lenient_test.dart b/test/gen5_v18_hardware_lenient_test.dart index f22019b..a323f9e 100644 --- a/test/gen5_v18_hardware_lenient_test.dart +++ b/test/gen5_v18_hardware_lenient_test.dart @@ -93,33 +93,5 @@ void main() { expect(sample.ay, isNull); expect(sample.az, isNull); }); - - test('strict dynamic-accel rejection still recovers HR via lenient path', () { - final inner = Uint8List(112); - inner[0] = 0x2f; - inner[1] = 18; - inner[2] = 0x80; - inner[3] = 1; - const unix = 1785801600; - inner.buffer.asByteData().setUint32(7, unix, Endian.little); - inner[14] = 80; - inner[15] = 0; - final view = inner.buffer.asByteData(); - // High dynamic accel fails strict gate; gravity is valid ~1g. - view.setFloat32(33, 5.0, Endian.little); - view.setFloat32(37, 0.0, Endian.little); - view.setFloat32(41, 0.0, Endian.little); - view.setFloat32(45, 1.0, Endian.little); - - expect(parseGen5Historical(inner), isNull); - const wallNow = unix; - final sample = decodeGen5HistoricalSample(inner, wallNow); - expect(sample, isNotNull); - expect(sample!.hr, 80); - // Lenient path keeps gravity when magnitude gate passes. - expect(sample.ax, closeTo(0.0, 1e-6)); - expect(sample.ay, closeTo(0.0, 1e-6)); - expect(sample.az, closeTo(1.0, 1e-6)); - }); }); } From ab8a386ffb4fb7e325c9bd4e5f0fbcb0b90df2b6 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:53:39 +0200 Subject: [PATCH 7/9] test(gen5): R10-lite regression uses complete preferred Sample Prove _decodeOneHzSample rejects Gen4 R10-lite bytes before the hasDecodedOneHz early return, not only when preferred is HR-only. --- test/gen5_decoded_onehz_persistence_test.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart index e724007..9c24a59 100644 --- a/test/gen5_decoded_onehz_persistence_test.dart +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -148,11 +148,22 @@ void main() { expect(rows.first['az'], 0); }); - test('R10-lite hr-only + preferred → no decoded_onehz row', () async { + test('R10-lite + complete preferred → no decoded_onehz row', () async { const ts = 1780000100; const counter = 99; final inner = _buildR10LiteInner(ts: ts, counter: counter, hr: 65); - final preferred = Sample(tsEpoch: ts, counter: counter, hr: 65); + final preferred = Sample( + tsEpoch: ts, + counter: counter, + hr: 65, + ax: 0.1, + ay: -0.2, + az: 0.95, + spo2RedRaw: 100, + spo2IrRaw: 200, + skinTempRaw: 300, + ); + expect(preferred.hasDecodedOneHz, isTrue); final raw = RawRecord( counter: counter, packetType: PacketType.historicalData, From 8e36c3ad867e8a6ff13c16667bff5395668cade8 Mon Sep 17 00:00:00 2001 From: Brackyt <60280126+Brackyt@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:27:47 +0200 Subject: [PATCH 8/9] fix(db): keep preferred Sample when raw hex is unparseable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R10-lite reject must parse hex first, but hexToBytes failure should fall through to preferred/hasDecodedOneHz — not return null. Restores decoded_onehz for fixture inserts with placeholder hex. Also fix clamp→int in the gen5 persistence helper. --- lib/data/db.dart | 57 ++++++++++--------- test/gen5_decoded_onehz_persistence_test.dart | 2 +- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 8bbb84c..a7950c5 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1866,35 +1866,40 @@ class LocalDb { inner[1] == proto.Record.r10; static Sample? _decodeOneHzSample(RawRecord raw, {Sample? preferred}) { - Uint8List bytes; + // Parse hex when possible so Gen4 R10-lite can be rejected even when a + // complete preferred Sample is supplied. Invalid/placeholder hex (test + // fixtures, corrupt imports) must NOT abort before the preferred paths — + // commit 1f85b10 returned null on hexToBytes failure and zeroed + // decoded_onehz for every insertRecord that used non-hex placeholders. + Uint8List? bytes; try { bytes = proto.hexToBytes(raw.hex); - } catch (_) { - return null; - } - if (_isGen4R10LiteHistorical(bytes)) return null; - if (preferred != null && preferred.hasDecodedOneHz) return preferred; - try { - // Legacy decoder first, firmware-fallback chain second — see - // FirmwareAwareR24Decoder. This path only runs when no pre-decoded - // `preferred` sample was supplied (e.g. a raw-hex import/merge), so a - // fresh per-call instance is fine — no session state to preserve. - final r = proto.FirmwareAwareR24Decoder().decode(bytes); - if (r != null && r.tsEpoch > 0) { - return Sample( - tsEpoch: r.tsEpoch, - counter: r.counter, - hr: r.hr, - rrIntervalsMs: List.from(r.rrIntervalsMs), - ax: r.accelG.isNotEmpty ? r.accelG[0] : 0, - ay: r.accelG.length > 1 ? r.accelG[1] : 0, - az: r.accelG.length > 2 ? r.accelG[2] : 0, - spo2RedRaw: r.spo2RedRaw, - spo2IrRaw: r.spo2IrRaw, - skinTempRaw: r.skinTempRaw, - ); - } } catch (_) {} + if (bytes != null && _isGen4R10LiteHistorical(bytes)) return null; + if (preferred != null && preferred.hasDecodedOneHz) return preferred; + if (bytes != null) { + try { + // Legacy decoder first, firmware-fallback chain second — see + // FirmwareAwareR24Decoder. This path only runs when no pre-decoded + // `preferred` sample was supplied (e.g. a raw-hex import/merge), so a + // fresh per-call instance is fine — no session state to preserve. + final r = proto.FirmwareAwareR24Decoder().decode(bytes); + if (r != null && r.tsEpoch > 0) { + return Sample( + tsEpoch: r.tsEpoch, + counter: r.counter, + hr: r.hr, + rrIntervalsMs: List.from(r.rrIntervalsMs), + ax: r.accelG.isNotEmpty ? r.accelG[0] : 0, + ay: r.accelG.length > 1 ? r.accelG[1] : 0, + az: r.accelG.length > 2 ? r.accelG[2] : 0, + spo2RedRaw: r.spo2RedRaw, + spo2IrRaw: r.spo2IrRaw, + skinTempRaw: r.skinTempRaw, + ); + } + } catch (_) {} + } // Gen5 v18 / lenient samples carry HR/RR/gravity but lack gen4 optics — // `hasDecodedOneHz` stays false, yet they are honest 1 Hz substrate rows. if (preferred != null && preferred.tsEpoch > 0) { diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart index 9c24a59..e6226f6 100644 --- a/test/gen5_decoded_onehz_persistence_test.dart +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -43,7 +43,7 @@ Uint8List _buildGen5V18LenientInner({ inner[6] = (counter >> 24) & 0xff; inner.buffer.asByteData().setUint32(7, unix, Endian.little); inner[14] = hr; - inner[15] = rrMs.length.clamp(0, 4); + inner[15] = rrMs.length.clamp(0, 4).toInt(); final view = inner.buffer.asByteData(); for (var i = 0; i < rrMs.length && i < 4; i++) { view.setInt16(16 + 2 * i, rrMs[i], Endian.little); From 748f6dcacf58e650ccc65464bcbd7a102cfbcdc9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 7 Aug 2026 00:22:09 +0530 Subject: [PATCH 9/9] gen5: absent gravity must not read back as perfect stillness P0. `sampleFromGen5V18Lenient` correctly abstains on a gravity vector that fails the magnitude gate (ax/ay/az stay null) while keeping HR/RR -- but `decoded_onehz.ax/ay/az` are REAL NOT NULL, so `_queueDecodedOneHz` writes `decoded.ax ?? 0` and the substrate loader reads `?? 0` back. "We did not measure this" becomes "the wrist was at exactly (0,0,0)". That is not an inert default. `zAngle(0,0,0)` is exactly 0.0 in Dart -- atan2(0,0) is 0.0, not NaN -- so a run of absent seconds has a perfectly CONSTANT z-angle, which is the van Hees immobility criterion satisfied maximally. Measured against the pinned analytics: 8 h of (0,0,0) scores 28501 immobile seconds and `vanHeesSleepWindow.present == true`, i.e. a fabricated ~7.9 h night, fully staged, out of data that does not exist. This PR's own commit message notes the strict gate rejected EVERY v18 record on fw 50.40.1.0, so for that firmware it is the ordinary case, not a corner. Exact (0,0,0) is an unambiguous ABSENT marker rather than a reading: gravity always has magnitude ~1 g and every decoder that emits a vector gates on `magSq >= 0.25`. So `Substrate.accelPresentAt` / `accelPresentFraction` can recover the distinction the NOT NULL column erased, with no schema change -- and it heals rows already persisted. TWO OTHER SENTINELS WERE TRIED AND REJECTED, both measured rather than assumed: * NaN fails OPEN. The rule asks "did the angle change by >= threshold", and every comparison against NaN is false, so it never trips -- NaN scores the SAME 28501 immobile seconds as zeros. Pinned in a test so nobody "fixes" this that way later. * Omitting the seconds does not work either: `immobilityMask` is a pure index-wise angle rule with no timestamp/gap awareness (unlike `nap.dart`'s `stillAt`, which does check `absAt(k) - absAt(k-1) == 1`), so it just joins across the hole. Since analytics has no validity input to be told any of this, the honest move at this layer is not to let absent accel ANCHOR a window: below `kMinAccelCoverageForVanHees` the accel-led path is skipped entirely and the day falls through to the EXISTING HR-led fallback, which is already the low-confidence degraded mode for exactly this situation. Half rather than something tiny, because van Hees picks the LONGEST immobile block and absent seconds are maximally immobile, so a mostly-absent window would reliably hand the answer to the missing data. The complete fix belongs upstream -- `immobilityMask` should take an optional validity mask and mark invalid seconds via the `immobileUnknown` machinery it already has. Noted for analytics; this keeps the P0 off the field meanwhile. P1, same commit: lenient records bypassed `raw_archive`. A partial decode is not a full decode -- HR/RR are kept but the gravity bytes the strict gate rejected were discarded the moment we ACK'd the trim, with `raw_records` gone and `decoded_onehz` having nowhere to put a null accel. They are now archived as well as sampled, in the same safe-trim transaction, so a future decoder can recover what this one could not. Extracted `_archiveHistoricalFrame` so both the undecodable and partial paths share one implementation. 7 tests added; the 6 pre-existing failures in notification_dedupe_test also reproduce on origin/main unmodified and are unrelated. --- lib/ble/ble_engine.dart | 55 ++++-- lib/compute/substrate.dart | 75 +++++++- test/gen5_decoded_onehz_persistence_test.dart | 8 + test/substrate_accel_absence_test.dart | 166 ++++++++++++++++++ 4 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 test/substrate_accel_absence_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index ac37816..dbd7fcb 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2035,6 +2035,32 @@ class BleEngine { /// path is deliberate: the previous duplicate had drifted, silently losing /// the plausibility gate and freezing the frontier the stuck-strap / /// auto-continue policies read. + /// Set a historical frame aside in `raw_archive` — the never-pruned store for + /// bytes this build could not fully turn into a [Sample]. + /// + /// Routed through the drain when one is active so the write lands inside the + /// SAME transaction as the batch commit (safe-trim invariant: nothing the + /// band is told it may trim has been discarded). + void _archiveHistoricalFrame( + Frame frame, + int counter, { + required String reason, + }) { + final archive = ArchiveRecord( + counter: counter, + hex: _innerHex(frame.inner), + packetType: frame.inner.isNotEmpty ? frame.inner[0] : 0, + capturedAt: DateTime.now().millisecondsSinceEpoch, + reason: reason, + ); + final d = _drain; + if (d != null) { + d.onUndecodableRecord(archive); + } else { + unawaited(onArchiveRecord?.call(archive) ?? Future.value()); + } + } + void _ingestHistoricalFrame(Frame frame) { final pt = frame.packetType; if (pt != PacketType.historicalData) return; @@ -2067,6 +2093,21 @@ class BleEngine { // fall through to the undecodable archive below — that is honest // (correctly-identified-but-not-yet-stored), not a decode failure. sample = decodeGen5HistoricalSample(frame.inner, wallNow); + // PARTIAL decode is not a full decode. The lenient v18 path deliberately + // keeps HR/RR while ABSTAINING on a gravity vector that failed the + // magnitude gate — but `raw_records` is gone and `decoded_onehz` has + // nowhere to put a null accel, so those gravity bytes would be discarded + // the moment we ACK the trim. Archive the frame as WELL as keeping the + // sample: same safe-trim transaction, and a future decoder can still + // recover what this one could not. Nothing is double-counted — + // `raw_archive` is a diagnostic store, never a derivation input. + if (sample != null && sample.ax == null) { + _archiveHistoricalFrame( + frame, + counter, + reason: 'partial_decode_v${recType}_no_gravity', + ); + } } else if (recType == Record.r24 || recType == Record.r12) { // Legacy decoder first, firmware-fallback chain second, undecodable // archive last — see FirmwareAwareR24Decoder. @@ -2106,19 +2147,11 @@ class BleEngine { // archive rides the SAME commit that runs before the batch-ACK, so nothing the // band trims has been discarded (safe-trim invariant intact). if (sample == null) { - final archive = ArchiveRecord( - counter: counter, - hex: _innerHex(frame.inner), - packetType: frame.inner.isNotEmpty ? frame.inner[0] : 0, - capturedAt: DateTime.now().millisecondsSinceEpoch, + _archiveHistoricalFrame( + frame, + counter, reason: 'undecodable_rec_v$recType', ); - final d = _drain; - if (d != null) { - d.onUndecodableRecord(archive); - } else { - unawaited(onArchiveRecord?.call(archive) ?? Future.value()); - } return; } // PLAUSIBILITY GATE + FRONTIER (RecordGate, shared with the detectors). diff --git a/lib/compute/substrate.dart b/lib/compute/substrate.dart index 40df505..e9a139f 100644 --- a/lib/compute/substrate.dart +++ b/lib/compute/substrate.dart @@ -16,6 +16,16 @@ import 'dart:math' as math; import 'package:openstrap_analytics/onehz.dart' as ana; import 'package:openstrap_protocol/openstrap_protocol.dart' as proto; +/// Minimum fraction of a nocturnal search window that must carry a REAL +/// gravity vector before accel-led (van Hees) sleep detection is trusted. +/// +/// Below this we do not run it at all and fall through to the HR-led window, +/// which is already the honest low-confidence degraded mode. Set at a half +/// rather than something tiny on purpose: van Hees picks the LONGEST immobile +/// block, and absent seconds are maximally "immobile", so a window that is +/// mostly absent would reliably hand the answer to the missing data. +const double kMinAccelCoverageForVanHees = 0.5; + /// The decoded 1 Hz substrate — the only decoded form (ARCHITECTURE_V2). /// /// All HR/accel/ADC arrays are parallel and 1:1 with [tsSec] (one sample per @@ -82,6 +92,36 @@ class Substrate { ana.AccelSample(tsSec[i] * 1000.0, ax[i], ay[i], az[i]) ]; + /// Whether second [i] carries a REAL gravity vector. + /// + /// `decoded_onehz.ax/ay/az` are `REAL NOT NULL`, so a record decoded without + /// a usable gravity vector (the gen5 v18 lenient path, which deliberately + /// abstains on accel while keeping HR/RR) is stored as exact `(0, 0, 0)`. + /// That is not a reading a real device can produce — a gravity vector always + /// has magnitude ~1 g, and every decoder that emits one gates on + /// `magSq >= 0.25` — so exact zero is an unambiguous ABSENT marker rather + /// than a measurement. + /// + /// This matters because absent accel does not merely go unused: a run of + /// `(0, 0, 0)` has a constant z-angle of exactly 0.0°, which the van Hees + /// rule reads as PERFECT IMMOBILITY. Eight hours of missing accel scores + /// 28 501 immobile seconds and yields a fabricated ~7.9 h sleep window, + /// fully staged. Absent input must produce no claim, never a confident one. + bool accelPresentAt(int i) => !(ax[i] == 0 && ay[i] == 0 && az[i] == 0); + + /// Fraction of [lo, hi) seconds carrying a real gravity vector (0..1). + /// Returns 0 for an empty range — no evidence, not "all present". + double accelPresentFraction(int lo, int hi) { + final a = lo < 0 ? 0 : lo; + final b = hi > tsSec.length ? tsSec.length : hi; + if (b <= a) return 0; + var present = 0; + for (var i = a; i < b; i++) { + if (accelPresentAt(i)) present++; + } + return present / (b - a); + } + /// 1 Hz HR as doubles (0 = off-skin). Parallel to [tsSec] / [accelSamples]. List hr1hz() => [for (final h in hr) h.toDouble()]; @@ -531,14 +571,33 @@ List calendarDays( ); src = ov.source; // 'manual' | 'confirmed' } else { - s = ana.segmentSleep( - accelSlice, - hrSlice, - hrBaseline: hrBaseline, - rrMs: rrMsSeg, - rrTsMs: rrTsSeg, - habitualMidsleepSec: habitualMidsleepSec, - ); + // Accel-led detection is only meaningful if we actually HAVE accel. + // Absent gravity is stored as exact (0,0,0) (see `accelPresentAt`) and + // van Hees scores a run of it as perfect immobility, so a night whose + // records all decoded without a gravity vector would otherwise produce + // a confident, fully-staged sleep window built entirely out of missing + // data. `immobilityMask` has no validity input to tell it otherwise — + // it is a pure index-wise angle rule, so neither a NaN sentinel (NaN + // comparisons are false, so the "angle changed" test never trips and + // it reads as immobile) nor omitting the seconds (no gap awareness) + // reaches it. The only honest move at this layer is not to let it + // anchor the window in the first place. + final accelCoverage = sub.accelPresentFraction(loS, hiS); + if (accelCoverage >= kMinAccelCoverageForVanHees) { + s = ana.segmentSleep( + accelSlice, + hrSlice, + hrBaseline: hrBaseline, + rrMs: rrMsSeg, + rrTsMs: rrTsSeg, + habitualMidsleepSec: habitualMidsleepSec, + ); + } else { + // Not an error and not "no sleep" — just no accel evidence. Fall + // through to the HR-led path below, which is exactly the degraded + // mode for this and is already marked low-confidence. + s = ana.SleepSegmentation.absent; + } src = 'auto'; if (!s.present) { // Approach 2: accel-led detection found nothing → HR-led fallback. diff --git a/test/gen5_decoded_onehz_persistence_test.dart b/test/gen5_decoded_onehz_persistence_test.dart index e6226f6..f0d0646 100644 --- a/test/gen5_decoded_onehz_persistence_test.dart +++ b/test/gen5_decoded_onehz_persistence_test.dart @@ -118,6 +118,14 @@ void main() { expect([for (final r in rr) r['rr_ms']], containsAll([602, 613])); }); + // NOTE what this pins, and what it does NOT. `decoded_onehz.ax/ay/az` are + // REAL NOT NULL, so absent gravity has to be STORED as 0 — that is a schema + // constraint, not a claim about the wrist. Exact (0,0,0) is therefore the + // ABSENT marker (no real gravity vector has zero magnitude, and every decoder + // that emits one gates on magSq >= 0.25); `Substrate.accelPresentAt` is what + // stops it being read back as a measurement. See + // substrate_accel_absence_test.dart — without that, a night of these scores + // as perfect immobility and fabricates a fully-staged sleep window. test('lenient v18 with null accel still persists (stored as 0)', () async { const unix = 1785801600; const counter = 42; diff --git a/test/substrate_accel_absence_test.dart b/test/substrate_accel_absence_test.dart new file mode 100644 index 0000000..14c7028 --- /dev/null +++ b/test/substrate_accel_absence_test.dart @@ -0,0 +1,166 @@ +// P0 REGRESSION — absent gravity must not be read back as perfect stillness. +// +// `sampleFromGen5V18Lenient` correctly ABSTAINS on a gravity vector that fails +// the magnitude gate (ax/ay/az stay null) while keeping HR/RR. But +// `decoded_onehz.ax/ay/az` are REAL NOT NULL, so the persistence layer writes +// `decoded.ax ?? 0` and the substrate loader reads `?? 0` back — turning +// "we did not measure this" into "the wrist was at exactly (0,0,0)". +// +// That is not an inert default. zAngle(0,0,0) is exactly 0.0 in Dart (atan2 +// (0,0) == 0.0, NOT NaN), so a run of absent seconds has a PERFECTLY CONSTANT +// z-angle, which is the van Hees immobility criterion satisfied maximally. +// Measured against the pinned analytics: 8 h of (0,0,0) yields 28 501 immobile +// seconds and `vanHeesSleepWindow.present == true` — a fabricated ~7.9 h night, +// fully staged, from data that does not exist. The PR's own commit message +// notes the strict gate rejected EVERY v18 record on fw 50.40.1.0, so this is +// the ordinary case for that firmware, not a corner. +// +// Two sentinels were tried and REJECTED, both verified against the pinned +// analytics rather than assumed: +// * NaN — fails OPEN. The rule tests "did the angle change by >= thr", and +// every comparison against NaN is false, so it never trips: NaN scores the +// SAME 28 501 immobile seconds as zeros. +// * dropping the seconds — `immobilityMask` is a pure index-wise angle rule +// with no timestamp/gap awareness (unlike `nap.dart`'s `stillAt`), so it +// simply joins across the hole. +// Hence the gate below: absent accel must not be allowed to ANCHOR a window. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_analytics/onehz.dart' as ana; +import 'package:openstrap_edge/compute/substrate.dart'; + +Substrate _sub({ + required int n, + required bool accelPresent, + int startSec = 1750000000, +}) { + final ts = []; + final hr = []; + final ax = []; + final ay = []; + final az = []; + for (var i = 0; i < n; i++) { + ts.add(startSec + i); + hr.add(58); + if (accelPresent) { + // A real, still-ish wrist: unit-magnitude gravity with a slight drift. + final rad = (i % 3) * 0.5 * math.pi / 180.0; + ax.add(math.sin(rad)); + ay.add(0.0); + az.add(math.cos(rad)); + } else { + // What the NOT NULL column forces for an abstaining decoder. + ax.add(0.0); + ay.add(0.0); + az.add(0.0); + } + } + return Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: ax, + ay: ay, + az: az, + spo2Red: List.filled(n, 0), + spo2Ir: List.filled(n, 0), + skinTemp: List.filled(n, 0), + skinContact: List.filled(n, 0), + ); +} + +void main() { + group('the hazard this guards against is real', () { + test( + 'zAngle(0,0,0) is 0.0, not NaN — so absent accel is maximally "still"', + () { + expect(ana.zAngle(0.0, 0.0, 0.0), 0.0); + }, + ); + + test( + '8 h of absent accel scores as an almost entirely immobile night', + () { + const n = 8 * 3600; + final s = _sub(n: n, accelPresent: false); + final m = ana.vanHeesSleepWindow(s.accelSamples()); + final win = m.value!; + final immobile = win.immobile.where((b) => b).length; + expect( + immobile, + greaterThan((n * 0.95).round()), + reason: 'this is why the gate exists — missing data reads as sleep', + ); + expect(m.present, isTrue); + }, + ); + + test( + 'a NaN sentinel would NOT have fixed it — comparisons against NaN are ' + 'false, so the "angle changed" test never trips', + () { + const n = 8 * 3600; + final base = 1750000000000.0; + final nan = [ + for (var i = 0; i < n; i++) + ana.AccelSample( + base + i * 1000, double.nan, double.nan, double.nan), + ]; + final immobile = + ana.vanHeesSleepWindow(nan).value!.immobile.where((b) => b).length; + expect( + immobile, + greaterThan((n * 0.95).round()), + reason: 'NaN fails OPEN here; documented so nobody "fixes" it that way', + ); + }, + ); + }); + + group('Substrate.accelPresentAt / accelPresentFraction', () { + test('exact (0,0,0) is absent; a real vector is present', () { + final absent = _sub(n: 10, accelPresent: false); + final present = _sub(n: 10, accelPresent: true); + expect(absent.accelPresentAt(0), isFalse); + expect(present.accelPresentAt(0), isTrue); + expect(absent.accelPresentFraction(0, 10), 0.0); + expect(present.accelPresentFraction(0, 10), 1.0); + }); + + test('an empty range reports 0 — no evidence, not "all present"', () { + final s = _sub(n: 10, accelPresent: true); + expect(s.accelPresentFraction(5, 5), 0.0); + expect(Substrate.empty.accelPresentFraction(0, 100), 0.0); + }); + + test('a mixed window reports the real fraction', () { + final s = _sub(n: 100, accelPresent: true); + for (var i = 0; i < 40; i++) { + s.ax[i] = 0.0; + s.ay[i] = 0.0; + s.az[i] = 0.0; + } + expect(s.accelPresentFraction(0, 100), closeTo(0.60, 1e-9)); + }); + + test( + 'the van Hees coverage floor rejects an all-absent night and accepts a ' + 'fully-measured one', + () { + final absent = _sub(n: 8 * 3600, accelPresent: false); + final present = _sub(n: 8 * 3600, accelPresent: true); + expect( + absent.accelPresentFraction(0, absent.length), + lessThan(kMinAccelCoverageForVanHees), + ); + expect( + present.accelPresentFraction(0, present.length), + greaterThanOrEqualTo(kMinAccelCoverageForVanHees), + ); + }, + ); + }); +}