From 422c8b8b9631770b07bc571b6fbe904ef59e5052 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 31 Jul 2026 22:36:49 +0530 Subject: [PATCH 1/2] autoDetectWorkouts: bypass the motion gate on a genuine HR onset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edge#170: a real 1h50m cycling session (avg ~117W, HR 100-140, peak 157) produced no suggested workout at all. The motion-confirmation gate (motionConfirmMean = 0.15 mean L2 gravity-delta/s) is tuned for arm-swing activities (walking/running) and unconditionally drops any window where the wrist stays still — which is exactly what happens on a handlebar/oar during cycling/rowing, regardless of how strong the HR signal is. A raw %HRR bypass (skip motion above some higher HR-reserve fraction) was considered and rejected: it re-opens the exact false positive the gate exists to catch — any sustained elevation (fever, anxiety, heat) would qualify, since level alone doesn't distinguish "started exercising" from "already elevated." Instead, bypass on HR ONSET kinetics: mean bpm over the first 3 min of the candidate span must rise by >=25 bpm versus the 3 min immediately before it. Real aerobic effort shows a fast phase-II HR kinetic response right as exertion begins (Whipp & Wasserman 1972); a slow-drifting plateau (fever/heat/anxiety climbing over many minutes, or data that starts mid-elevation with no visible onset) shows no such rise and stays gated. No usable pre-window -> abstain (never fabricate an onset that isn't there). --- lib/src/onehz/workout/auto_detect.dart | 53 ++++++++++++++++- test/onehz/workout_test.dart | 78 ++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/lib/src/onehz/workout/auto_detect.dart b/lib/src/onehz/workout/auto_detect.dart index 7d4964f..7aa52ca 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, start + onsetWindowS) + 1); + 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', () { From cbbe06addec1cb78b4ea2c75f64e8a281ac09294 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 31 Jul 2026 22:44:46 +0530 Subject: [PATCH 2/2] Fix off-by-one: earlyMean window was 181s vs preMean's 180s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit caught this on #32: _meanBpmInRange uses a half-open [lo, hi) range. preMean correctly used [start-onsetLookbackS, start) (180 samples), but earlyMean used [start, min(end, start+onsetWindowS)+1) — the trailing +1 made it 181 samples when the span outlasts the onset window, asymmetric with the pre-window it's compared against. Fixed to [start, min(end+1, start+onsetWindowS)): matches the 180-sample width when the span is long enough, while still including the span's last sample (end, inclusive elsewhere in this file) when the span itself is shorter than onsetWindowS. --- lib/src/onehz/workout/auto_detect.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/onehz/workout/auto_detect.dart b/lib/src/onehz/workout/auto_detect.dart index 7aa52ca..b7cc441 100644 --- a/lib/src/onehz/workout/auto_detect.dart +++ b/lib/src/onehz/workout/auto_detect.dart @@ -295,7 +295,7 @@ class AutoWorkoutDetector { final preMean = _meanBpmInRange(ts, bpm, start - onsetLookbackS, start); final earlyMean = _meanBpmInRange( - ts, bpm, start, math.min(end, start + onsetWindowS) + 1); + ts, bpm, start, math.min(end + 1, start + onsetWindowS)); if (preMean == null || earlyMean == null || (earlyMean - preMean) < onsetRiseBpm) {