diff --git a/README.md b/README.md index f9be7a6..c7b6cf2 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,22 @@ dart test # run from the repo root — some fixtures resolve paths relative to 624 passed / 6 skipped (630 total), nothing mocked — pure functions, fixture in, assertion out. +## Validation + +`tool/` has four harnesses that score shipped detectors against labelled corpora, not +synthetic fixtures — run one before touching the logic it covers: + +- `dart run tool/oxwalk_validate.dart ` — the pedometer against + OxWalk (Oxford, CC BY): 39 subjects, camera-annotated heel strikes. +- `dart run tool/stager_harness.dart ` — the sleep-staging decision layer + against a PSG-labelled corpus (e.g. DREAMT), reporting Cohen's kappa. +- `dart run tool/nap_harness.dart ` — the nap detector against hand-labelled + days. +- `dart run tool/whoop_proportions.dart ` — sweeps sleep-stage cutoffs + against normative stage proportions on real device captures. + +Each file's header comment has the full usage, flags, and fixture schema. + ## If you want to add a metric Write a function that takes the 1 Hz substrate (or a derived series like an RR stream) diff --git a/lib/src/onehz/clinical/load_trimp.dart b/lib/src/onehz/clinical/load_trimp.dart index 38a5f47..c0eac17 100644 --- a/lib/src/onehz/clinical/load_trimp.dart +++ b/lib/src/onehz/clinical/load_trimp.dart @@ -420,18 +420,6 @@ class StrainScorer { // its own 190 for the "did you work out?" prompt, which is not a published // number. - /// Linear-interpolated percentile of an ALREADY-SORTED sequence (numpy-style). - static double _percentileSorted(List sortedValues, double pct) { - final n = sortedValues.length; - if (n == 0) return 0; - if (n == 1) return sortedValues[0]; - final position = (pct / 100.0) * (n - 1); - final lower = position.toInt(); - final upper = math.min(lower + 1, n - 1); - final frac = position - lower; - return sortedValues[lower] + frac * (sortedValues[upper] - sortedValues[lower]); - } - /// Estimate a personalized HRmax from a trailing HR series. /// Returns (hrmax bpm, source ∈ {"observed","tanaka","unknown"}). static (double, String) estimateHRmax(List hrHistory, double? age) { @@ -440,7 +428,7 @@ class StrainScorer { if (n >= hrmaxMinSamples) { final sorted = [...hrHistory]..sort(); - final observed = _percentileSorted(sorted, hrmaxPercentile); + final observed = percentileSorted(sorted, hrmaxPercentile)!; if (tanaka == null) return (observed, 'observed'); return observed >= tanaka ? (observed, 'observed') : (tanaka, 'tanaka'); } diff --git a/lib/src/onehz/sleep/advanced_stager.dart b/lib/src/onehz/sleep/advanced_stager.dart index 272d062..dde5f4b 100644 --- a/lib/src/onehz/sleep/advanced_stager.dart +++ b/lib/src/onehz/sleep/advanced_stager.dart @@ -548,7 +548,7 @@ class AdvancedSleepStager { static double? _hrBaseline(List hr) { if (hr.isEmpty) return null; - return _median([for (final h in hr) h.bpm]); + return median([for (final h in hr) h.bpm]); } static bool _hrSleepBandAcross(int a, int b, List hr, double? baseline) { @@ -1056,7 +1056,7 @@ class AdvancedSleepStager { final lo = math.max(0, i - halfW); final hi = math.min(n, i + halfW + 1); final winDog = dogHR.isEmpty ? [0.0] : dogHR.sublist(lo, hi); - final hrVar = winDog.length >= 2 ? _populationStd(winDog) : double.nan; + final hrVar = winDog.length >= 2 ? stddevPop(winDog)! : double.nan; final winRR = []; final winResp = []; @@ -1068,7 +1068,7 @@ class AdvancedSleepStager { final rmssd = filteredRR.length >= 5 ? (_rmssdRaw(filteredRR) ?? double.nan) : double.nan; final sdnn = - filteredRR.length >= 5 ? (_sdnnRaw(filteredRR) ?? double.nan) : double.nan; + filteredRR.length >= 5 ? (stddev(filteredRR) ?? double.nan) : double.nan; // BUG FIX (2026-07): `winResp` is fed from `resp:`/`RespTs`, a raw 1 Hz // respiration-ADC channel — but the WHOOP 4 R24 record has no such // channel (an early candidate field was dropped as constant/mirror @@ -1116,7 +1116,7 @@ class AdvancedSleepStager { final mean = respRaw.reduce((a, b) => a + b) / respRaw.length; final x = [for (final v in respRaw) v - mean]; if (x.every((v) => v.abs() < 1e-12)) return [double.nan, double.nan]; - final std = _populationStd(x); + final std = stddevPop(x)!; if (std <= 0) return [double.nan, double.nan]; final minDistance = math.max(2, (2.0 / dtS).round()); final peaks = _findPeaks(x, minDistance, 0.0); @@ -1127,8 +1127,8 @@ class AdvancedSleepStager { if (iv >= 1.5 && iv <= 12.0) intervals.add(iv); } if (intervals.length < 2) return [double.nan, double.nan]; - final rate = 60 / _median(intervals)!; - final rrv = _populationStd(intervals); + final rate = 60 / median(intervals)!; + final rrv = stddevPop(intervals)!; return [rate, rrv]; } @@ -1151,7 +1151,7 @@ class AdvancedSleepStager { final mean = rrMs.reduce((a, b) => a + b) / rrMs.length; final x = [for (final v in rrMs) v - mean]; if (x.every((v) => v.abs() < 1e-9)) return [double.nan, double.nan]; - final std = _populationStd(x); + final std = stddevPop(x)!; if (std <= 0) return [double.nan, double.nan]; // Peaks must be >=2 beats apart — a beat-to-beat RR series has ~1 sample // per beat, so a distance-1 peak would just be beat-to-beat noise, not a @@ -1164,8 +1164,8 @@ class AdvancedSleepStager { if (iv >= 1.5 && iv <= 12.0) intervalsS.add(iv); } if (intervalsS.length < 2) return [double.nan, double.nan]; - final rate = 60 / _median(intervalsS)!; - final rrv = _populationStd(intervalsS); + final rate = 60 / median(intervalsS)!; + final rrv = stddevPop(intervalsS)!; return [rate, rrv]; } @@ -2015,34 +2015,6 @@ class AdvancedSleepStager { return math.sqrt(sumSq / (nn.length - 1)); } - static double? _sdnnRaw(List nn) { - if (nn.length < 2) return null; - final mean = nn.reduce((a, b) => a + b) / nn.length; - var ss = 0.0; - for (final v in nn) { - ss += (v - mean) * (v - mean); - } - return math.sqrt(ss / (nn.length - 1)); - } - - static double? _median(List values) { - if (values.isEmpty) return null; - final s = [...values]..sort(); - final n = s.length; - if (n.isOdd) return s[n ~/ 2]; - return (s[n ~/ 2 - 1] + s[n ~/ 2]) / 2.0; - } - - static double _populationStd(List xs) { - if (xs.isEmpty) return 0; - final m = xs.reduce((a, b) => a + b) / xs.length; - var ss = 0.0; - for (final v in xs) { - ss += (v - m) * (v - m); - } - return math.sqrt(ss / xs.length); - } - /// numpy-style linear-interp percentile of an ALREADY-SORTED list. static double _percentileSorted(List sortedValues, double pct) { final n = sortedValues.length; diff --git a/lib/src/onehz/workout/hr_zones.dart b/lib/src/onehz/workout/hr_zones.dart index 61fb9b2..16c66e3 100644 --- a/lib/src/onehz/workout/hr_zones.dart +++ b/lib/src/onehz/workout/hr_zones.dart @@ -146,9 +146,7 @@ class HeartRateZones { ]; if (valid.length < minDays) return null; valid.sort(); - final rhr = valid.length.isOdd - ? valid[valid.length ~/ 2] - : (valid[valid.length ~/ 2 - 1] + valid[valid.length ~/ 2]) / 2.0; + final rhr = percentileSorted(valid, 50)!; if (!(maxHr > rhr)) return null; final reserve = maxHr - rhr; final built = []; diff --git a/lib/src/onehz/workout/observed_max_hr.dart b/lib/src/onehz/workout/observed_max_hr.dart index 7e208a6..bfa0dff 100644 --- a/lib/src/onehz/workout/observed_max_hr.dart +++ b/lib/src/onehz/workout/observed_max_hr.dart @@ -145,10 +145,16 @@ Metric sessionHrCeiling( final gapMs = maxGapSeconds * 1000.0; // A real held effort's corroborating motion is not necessarily even across // the hold (e.g. a couple of seconds of arm swing at each end of a quiet - // middle), so a start that fails the motion gate on the MINIMAL qualifying - // window still gets to extend further before giving up — capped, so one - // quiet start can't turn this into an O(n²) scan of a whole day. + // middle), so corroboration is judged on a short trailing sub-window, not + // the whole hold's average — a real burst anywhere in the hold would get + // diluted back below the gate by an otherwise-quiet average, which is + // exactly the edges-quiet-middle case this exists for. As the window + // extends past the minimal holdSeconds, that trailing sub-window sweeps + // across the rest of the candidate span, so a burst anywhere in it still + // gets found — capped, so one quiet start can't turn this into an O(n²) + // scan of a whole day. final maxSpanMs = holdMs * 4; + const corrobMs = 3000.0; // "a couple of seconds of arm swing" HrCeiling? best; // ponytail: O(n · holdSeconds) — one session at 1 Hz, so a bounded number of // passes over a few thousand samples. A monotonic-deque sliding minimum if @@ -157,25 +163,35 @@ Metric sessionHrCeiling( var lo = rows[i].hr; var motionSum = 0.0; var count = 0; + var trailStart = i; + var trailSum = 0.0; + var trailCount = 0; for (var j = i; j < rows.length; j++) { if (j > i && rows[j].ts - rows[j - 1].ts > gapMs) break; // stream broke lo = math.min(lo, rows[j].hr); motionSum += rows[j].motion; count++; + trailSum += rows[j].motion; + trailCount++; + while (rows[j].ts - rows[trailStart].ts > corrobMs) { + trailSum -= rows[trailStart].motion; + trailCount--; + trailStart++; + } final span = rows[j].ts - rows[i].ts; if (span < holdMs) continue; if (span > maxSpanMs) break; // gave this start its fair shot // The window qualifies on duration. `lo` is the bpm sustained across - // all of it. Only stop once the motion actually corroborates — that's - // the earliest point extending further can only lower `lo` for no gain. - final motion = motionSum / count; - if (motion >= gate) { + // all of it. Only stop once a short burst of real motion actually + // corroborates it, checked against the trailing few seconds rather + // than the whole hold's average. + if (trailSum / trailCount >= gate) { if (best == null || lo > best.bpm) { best = HrCeiling( bpm: lo, tsMs: rows[i].ts, heldSeconds: (span / 1000).round(), - motionG: motion, + motionG: motionSum / count, ); } break; diff --git a/test/onehz/observed_max_hr_test.dart b/test/onehz/observed_max_hr_test.dart index dc576fc..d9609ea 100644 --- a/test/onehz/observed_max_hr_test.dart +++ b/test/onehz/observed_max_hr_test.dart @@ -66,6 +66,23 @@ void main() { } }); + test('motion at both edges of a quiet middle still corroborates', () { + // 30s hold at 170 bpm: a couple seconds of arm swing at each end, + // quiet in between. Averaging motion over the whole window dilutes + // the edges away; corroboration has to find the burst. + final hr = []; + final accel = []; + for (var i = 0; i < 30; i++) { + final t = i * 1000.0; + final edge = i < 3 || i >= 27; + hr.add(HrSample(t, 170)); + accel.add(AccelSample(t, 0, 0, 1.0 + (edge ? 0.25 : 0.005))); + } + final m = sessionHrCeiling(hr, accel, deviceFamily: 'gen4'); + expect(m.present, isTrue); + expect(m.value!.bpm, 170); + }); + test('a gap in the stream breaks the hold', () { final a = _run(0, 10, 180, 0.20); final b = _run(60000, 10, 180, 0.20); // 50 s later