Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
340 changes: 300 additions & 40 deletions lib/ble/ble_engine.dart

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,12 @@ import 'substrate.dart';
// (fever/heat/anxiety) that have no discernible onset — so this changes which
// suggestions autoDetectWorkouts emits without loosening the false-positive
// gate it exists to protect.
const int kAlgoVersion = 51;
// v52: WHOOP 5 — gen5 v26 PPG bursts can contribute a derived per-second HR
// (analytics `deriveHrFromGen5PpgWaveform` @ b3e7b88624e4cbb6a0ab2dee6715446f19feb775,
// ACF on ≥10 s of 24 Hz samples) when measured v18 is absent. Empty RR by design (no HRV claim).
// Abstains on thin/noisy windows. Bump so days re-derive once v26-backed onehz
// rows land.
const int kAlgoVersion = 52;

/// Raw is kept this many days past derivation, then pruned (derived stays).
const int rawRetentionDays = 3;
Expand Down
86 changes: 68 additions & 18 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int>.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<int>.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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return null;
}

/// THE orphan guard for an INSERT-OR-REPLACE into `decoded_onehz`.
Expand Down Expand Up @@ -1915,6 +1935,36 @@ class LocalDb {
final decoded = _decodeOneHzSample(raw, preferred: sample);
if (decoded == null) return 0;
final recTs = raw.recTs ?? decoded.tsEpoch;
// PROVENANCE BEATS RECENCY — for DERIVED HR only.
//
// "Newest wins" below is right for two MEASURED records of the same second
// (the counter resets after a reboot), but it is wrong when the newcomer's
// HR was INFERRED from the PPG waveform. That REPLACE evicts the measured
// row, and `_queueOrphanGuard` then deletes the evicted counter's
// `decoded_rr` beats — the durable RR store — while a derived sample brings
// no beats of its own. A measured HR plus a whole second of beat-to-beat
// intervals, traded for an inferred bpm, unrecoverably.
//
// The engine's in-memory `measuredRecTs` set already prevents this WITHIN a
// connection, but it is cleared on teardown and never seeded from
// `decoded_onehz`, so after a reconnect a re-delivered v26 burst walks
// straight past it. Enforcing it here makes the guarantee durable instead
// of session-scoped: INSERT OR IGNORE and NO orphan guard, so any existing
// row for this second simply stands.
if (decoded.derived) {
batch.insert('decoded_onehz', {
'counter': raw.counter,
'rec_ts': recTs,
'hr': decoded.hr,
'ax': decoded.ax ?? 0,
'ay': decoded.ay ?? 0,
'az': decoded.az ?? 0,
'spo2_red_raw': decoded.spo2RedRaw ?? 0,
'spo2_ir_raw': decoded.spo2IrRaw ?? 0,
'skin_temp_raw': decoded.skinTempRaw ?? 0,
}, conflictAlgorithm: ConflictAlgorithm.ignore);
return 1;
}
// TIME-KEYED, NEWEST-WINS (noop/WHOOP-4 model: dedupe records by their
// embedded timestamp, not by a counter). decoded_onehz has a UNIQUE(rec_ts)
// index and decoded_rr a UNIQUE(rr_ts_ms, beat_index). We use REPLACE, not
Expand Down
17 changes: 17 additions & 0 deletions lib/data/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ class Sample {
final int? spo2IrRaw;
final int? skinTempRaw;

/// True when [hr] was INFERRED rather than reported by the strap — today only
/// the gen5 v26 PPG-waveform path (autocorrelation over concatenated bursts).
///
/// Provenance, not quality: a derived sample must never EVICT a measured row
/// for the same second. `decoded_onehz` is INSERT-OR-REPLACE keyed on
/// UNIQUE(rec_ts), and that eviction also deletes the losing counter's
/// `decoded_rr` beats — the durable RR store, unrecoverable once gone. A
/// derived sample carries no RR of its own, so letting it win trades a
/// measured HR *and* its whole beat series for an inferred bpm.
///
/// The in-memory `measuredRecTs` set is the fast path for this within one
/// connection; this flag is what makes the guarantee survive a RECONNECT,
/// where that set has been cleared but the rows are still on disk.
final bool derived;

Sample({
required this.tsEpoch,
required this.counter,
Expand All @@ -27,6 +42,7 @@ class Sample {
this.spo2RedRaw,
this.spo2IrRaw,
this.skinTempRaw,
this.derived = false,
});

/// Copy with an overridden [tsEpoch] — used by the clock-offset salvage path
Expand All @@ -44,6 +60,7 @@ class Sample {
spo2RedRaw: spo2RedRaw,
spo2IrRaw: spo2IrRaw,
skinTempRaw: skinTempRaw,
derived: derived,
);

bool get wristOn => hr > 0;
Expand Down
4 changes: 2 additions & 2 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -964,8 +964,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294
resolved-ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294
ref: b3e7b88624e4cbb6a0ab2dee6715446f19feb775
resolved-ref: b3e7b88624e4cbb6a0ab2dee6715446f19feb775
url: "https://github.com/OpenStrap/analytics.git"
source: git
version: "1.0.0"
Expand Down
5 changes: 4 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ dependencies:
# Moved to the branch's new head (cbbe06a) after CodeRabbit caught a real
# off-by-one in the onset window on analytics#32 (earlyMean was 181s vs
# preMean's 180s) — fixed there, not worth a separate edge changelog line.
ref: cbbe06addec1cb78b4ea2c75f64e8a281ac09294
# b3e7b88: gen5 v26 PPG→HR (`deriveHrFromGen5PpgWaveform`, OpenStrap/
# analytics#37). Requires ≥240 samples (~10 s @ 24 Hz) for resting BPM.
# Repin to the merge commit on main once #37 lands.
ref: b3e7b88624e4cbb6a0ab2dee6715446f19feb775

# BLE — flutter_blue_plus is the maintained cross-platform GATT client.
flutter_blue_plus: ^1.36.8
Expand Down
Loading