diff --git a/lib/src/onehz/workout/auto_detect.dart b/lib/src/onehz/workout/auto_detect.dart index 7d4964f..b7cc441 100644 --- a/lib/src/onehz/workout/auto_detect.dart +++ b/lib/src/onehz/workout/auto_detect.dart @@ -113,6 +113,32 @@ class AutoWorkoutDetector { /// Ignored in HR-only mode. static const double motionConfirmMean = 0.15; + /// Lookback immediately before a span's start, used to test for an + /// exercise-like HR ONSET as a motion-gate bypass — see [onsetRiseBpm]. + static const int onsetLookbackS = 180; + + /// The onset check's "post" window: the first stretch of the span itself. + static const int onsetWindowS = 180; + + /// Bypass the motion-confirmation gate when it fails BUT the HR shows a + /// genuine exercise ONSET: mean bpm over [onsetWindowS] at the START of the + /// span must rise by at least this many bpm versus mean bpm over + /// [onsetLookbackS] immediately BEFORE it. + /// + /// Real aerobic effort produces a fast (~1-2 min time-constant) phase-II HR + /// kinetic response right as exertion begins (Whipp & Wasserman 1972) — + /// exactly what low-limb-swing cardio (cycling, rowing) shows even though + /// the wrist stays still on a handlebar/oar and [motionConfirmMean] (tuned + /// for arm-swing activities) never clears. A slow-drifting elevation — + /// fever, heat, anxiety climbing over many minutes, or a plateau with no + /// visible start because the data begins mid-elevation — shows no such rise + /// and stays gated: this is what keeps the bypass from turning "every + /// sustained heart-rate elevation" into a suggested activity. No usable + /// pre-window (nothing in [onsetLookbackS], e.g. the day/recording starts + /// mid-span) → the onset can't be evaluated → abstain, motion gate stays in + /// force (never fabricate an onset that isn't there). + static const double onsetRiseBpm = 25.0; + /// Resting-HR fallback when the caller has no nightly RHR. static const int defaultRestingHR = 60; @@ -264,7 +290,18 @@ class AutoWorkoutDetector { } } meanMotion = cnt == 0 ? 0.0 : sum / cnt; - if (meanMotion < motionConfirmMean) continue; + if (meanMotion < motionConfirmMean) { + // Low-motion but a genuine exercise-like onset — see [onsetRiseBpm]. + final preMean = + _meanBpmInRange(ts, bpm, start - onsetLookbackS, start); + final earlyMean = _meanBpmInRange( + ts, bpm, start, math.min(end + 1, start + onsetWindowS)); + if (preMean == null || + earlyMean == null || + (earlyMean - preMean) < onsetRiseBpm) { + continue; + } + } } var sum = 0; @@ -304,6 +341,20 @@ class AutoWorkoutDetector { /// Closed-interval overlap (touching endpoints count). static bool _overlaps(int aStart, int aEnd, int bStart, int bEnd) => aStart <= bEnd && bStart <= aEnd; + + /// Mean bpm for samples with `lo <= ts < hi`. Null when nothing falls in + /// range (can't evaluate — caller must abstain, not substitute a default). + static double? _meanBpmInRange( + List ts, List bpm, int lo, int hi) { + var sum = 0, cnt = 0; + for (var k = 0; k < ts.length; k++) { + if (ts[k] >= lo && ts[k] < hi) { + sum += bpm[k]; + cnt++; + } + } + return cnt == 0 ? null : sum / cnt; + } } /// Wrap a list of [DetectedWorkout] in the honesty envelope. Always present diff --git a/test/onehz/workout_test.dart b/test/onehz/workout_test.dart index 699c26e..5bb45d9 100644 --- a/test/onehz/workout_test.dart +++ b/test/onehz/workout_test.dart @@ -79,30 +79,86 @@ void main() { expect(out, isEmpty); }); - test('motion confirmation gate: low motion drops it, high keeps it', () { + test('motion confirmation gate: high motion keeps it', () { + final d = _hrDay(); + final highMotion = [ + for (var t = 600; t <= 1499; t++) MotionPoint(t, 0.5), + ]; + final kept = AutoWorkoutDetector.detect( + hrTs: d.ts, + hrBpm: d.bpm, + restingBpm: 60, + motion: highMotion, + ); + expect(kept, hasLength(1)); + }); + + test('motion gate + onset bypass: sharp HR onset from rest keeps a ' + 'low-motion bout (cycling/rowing — wrist stays still)', () { + // Same shape as _hrDay(): 60 bpm rest for 600 s, then a sharp step to + // 140 bpm — a genuine exercise onset even though the wrist motion stays + // near-zero throughout (handlebar/oar grip). final d = _hrDay(); - // Low motion over the bout → below motionConfirmMean (0.15). final lowMotion = [ for (var t = 600; t <= 1499; t++) MotionPoint(t, 0.01), ]; - final dropped = AutoWorkoutDetector.detect( + final out = AutoWorkoutDetector.detect( hrTs: d.ts, hrBpm: d.bpm, restingBpm: 60, motion: lowMotion, ); - expect(dropped, isEmpty); + expect(out, hasLength(1)); + expect(out.first.startSec, 600); + }); - final highMotion = [ - for (var t = 600; t <= 1499; t++) MotionPoint(t, 0.5), + test('motion gate + onset bypass: a slow-drifting elevation with no ' + 'discernible start (fever/heat/anxiety) stays dropped even at low ' + 'motion', () { + // Ramp gradually from 60 to 140 over 10 min (well under onsetRiseBpm's + // 25 bpm/3 min bar at any point), then hold — no sharp onset anywhere, + // so the low-motion gate must still reject it. + final ts = []; + final bpm = []; + var t = 0; + for (; t < 600; t++) { + ts.add(t); + bpm.add(60); + } + // 600 s ramp: +8 bpm/min over 10 min => 60 -> 140. + for (var i = 0; i < 600; i++, t++) { + ts.add(t); + bpm.add(60 + (i * 80 / 600).round()); + } + for (var i = 0; i < 900; i++, t++) { + ts.add(t); + bpm.add(140); + } + final lowMotion = [ + for (var tt = 0; tt < t; tt++) MotionPoint(tt, 0.01), ]; - final kept = AutoWorkoutDetector.detect( - hrTs: d.ts, - hrBpm: d.bpm, + final out = AutoWorkoutDetector.detect( + hrTs: ts, + hrBpm: bpm, restingBpm: 60, - motion: highMotion, + motion: lowMotion, ); - expect(kept, hasLength(1)); + expect(out, isEmpty); + }); + + test('motion gate + onset bypass: no pre-window (span starts at the ' + 'first sample) cannot evaluate onset — stays dropped', () { + // Elevated from t=0 with no preceding rest data at all. + final ts = [for (var t = 0; t < 900; t++) t]; + final bpm = [for (var t = 0; t < 900; t++) 140]; + final lowMotion = [for (final t in ts) MotionPoint(t, 0.01)]; + final out = AutoWorkoutDetector.detect( + hrTs: ts, + hrBpm: bpm, + restingBpm: 60, + motion: lowMotion, + ); + expect(out, isEmpty); }); test('brief dip ≤ maxDipS does not break the span', () {