diff --git a/lib/src/onehz/workout/auto_detect.dart b/lib/src/onehz/workout/auto_detect.dart index 3ad37a8..9b83355 100644 --- a/lib/src/onehz/workout/auto_detect.dart +++ b/lib/src/onehz/workout/auto_detect.dart @@ -4,7 +4,9 @@ // DELIBERATELY conservative (low sensitivity): a sustained ≥12-min elevation of // HR ≥ max(RHR+[elevatedMarginBPM], RHR+[hrrFloorFraction]·HRR) — RHR+59 at // RHR 60 / HRmax 190 — brief (≤90 s) dips tolerated, near windows merged, -// optional motion confirmation, overlap-excluded against saved spans. +// observed rests of up to 15 min bridged when their mean HR stays above +// resting (see [restBridgeMaxS]), optional motion confirmation, overlap-excluded against +// saved spans. // (The header and the note used to quote "RHR+30", the margin this detector // deliberately abandoned: see [hrrFloorFraction] for the 30 false windows on a // sedentary week that motivated replacing it.) @@ -114,9 +116,36 @@ class AutoWorkoutDetector { /// normal. Absence of data is not evidence of continuity. static const int maxGapS = 90; - /// Two windows whose gap is strictly < this are merged (5 min). + /// Two windows whose gap is strictly < this are merged (5 min), + /// unconditionally — a gap this short is a dip, not a pause. static const int mergeGapS = 5 * 60; + /// A REST between two bouts up to this long (inclusive) is bridged into ONE + /// workout — but only when [_restBridgeHolds] says the pause was observed and + /// the heart never went back to rest. 15 min. + /// + /// [mergeGapS] alone split every sport with real breaks in it: tennis for an + /// hour with a 5-10 min sit-down after 20 min, set breaks in padel, a + /// half-time, a water stop on a long ride. During the sit-down HR falls + /// below the gate, the span closes after [maxDipS], and the two halves sat + /// 7-8 min apart — past the 5-min merge — so the user got two "did you work + /// out?" prompts for one match. Fifteen minutes covers a sit-down, + /// a changeover and a half-time; a morning and an evening run, or a session + /// after lunch, are hours apart and never come near it. + static const int restBridgeMaxS = 15 * 60; + + /// During a bridged rest the mean HR must stay at or above + /// RHR + this fraction of heart-rate reserve (≈ RHR+20 at RHR 60 / + /// HRmax 190). Recovery from a real effort holds HR well clear of resting + /// for many minutes (post-exercise HR recovery has a fast vagal phase in + /// the first minute and a slow phase lasting several more; Imai et al. + /// 1994), so a sit-down between games clears this; HR that has fallen back + /// to a seated baseline means the effort is over and the next bout is a new + /// one. The %HRR form mirrors + /// [hrrFloorFraction] so the bar scales with the user's range rather than + /// a fixed bpm offset. + static const double restBridgeHrrFraction = 0.15; + /// When a motion series is supplied, the window's mean per-second motion /// intensity (L2 gravity-vector change/s, in g) must be ≥ this to qualify. /// 0.05 was far too low — desk fidgeting/typing/gesturing while seated averages @@ -246,28 +275,62 @@ class AutoWorkoutDetector { if (spans.isEmpty) return const []; - // --- 4: merge spans whose gap is strictly < mergeGapS --- - final merged = >[]; - var curStart = spans[0][0]; - var curEnd = spans[0][1]; + // --- 4: merge spans whose gap is strictly < mergeGapS, and bridge a + // rest of up to restBridgeMaxS the heart never recovered from --- + // The bridge floor sits between resting and the gate: "still recovering + // from effort", not "working out". Never above the gate itself, so a low + // HRmax can't make bridging stricter than the bout definition. + final bridgeFloor = math.min( + floor, (rhr + restBridgeHrrFraction * (hrMax - rhr)).round()); + // Two passes. The unconditional < mergeGapS merge runs first, exactly as + // it always has; bridging then works on its output. Done the other way + // round, a short gap after a bridge would grow the window past + // maxWorkoutMin with no cap check and the whole window, efforts that used + // to stand on their own included, would be thrown away. + final joined = >[]; + var curStart = spans[0][0], curEnd = spans[0][1]; for (var k = 1; k < spans.length; k++) { final next = spans[k]; if (next[0] - curEnd < mergeGapS) { curEnd = math.max(curEnd, next[1]); } else { - merged.add([curStart, curEnd]); + joined.add([curStart, curEnd]); curStart = next[0]; curEnd = next[1]; } } - merged.add([curStart, curEnd]); + joined.add([curStart, curEnd]); + + final merged = <_Window>[]; + var cur = _Window(joined[0][0], joined[0][1]); + for (var k = 1; k < joined.length; k++) { + final next = joined[k]; + final gap = next[0] - cur.end; + if (gap <= restBridgeMaxS && + // Never bridge into a window maxWorkoutMin would then throw away + // whole: two honest suggestions beat none. + next[1] - cur.start <= maxWorkoutMin * 60 && + // Nor across a saved workout: the overlap check below would drop + // the bridged window whole, taking the effort the user never saved + // with it. Kept apart, only the saved one is excluded. + !savedSpans.any( + (s) => _overlaps(cur.start, next[1], s.startSec, s.endSec)) && + _restBridgeHolds(ts, bpm, cur.end, next[0], bridgeFloor)) { + cur.rests.add([cur.end, next[0]]); + cur.end = math.max(cur.end, next[1]); + } else { + merged.add(cur); + cur = _Window(next[0], next[1]); + } + } + merged.add(cur); // --- 5+6+7 --- final motionSeries = (motion == null || motion.isEmpty) ? null : motion; final results = []; for (final span in merged) { - final start = span[0], end = span[1]; + final start = span.start, end = span.end; // A workout is time-bounded — a multi-hour sustained elevation is lifestyle // activity, not a workout to suggest. @@ -278,18 +341,31 @@ class AutoWorkoutDetector { continue; } - // window HR samples + // window HR samples — all of them (the reported avg/peak describe the + // whole session, rests included, as a recorded workout would), and the + // EFFORT subset outside the bridged rests, which the gates judge. final winBpm = []; + var effortSum = 0, effortCnt = 0; for (var k = 0; k < n; k++) { - if (ts[k] >= start && ts[k] <= end) winBpm.add(bpm[k]); + if (ts[k] >= start && ts[k] <= end) { + winBpm.add(bpm[k]); + if (!span.inRest(ts[k])) { + effortSum += bpm[k]; + effortCnt++; + } + } } - if (winBpm.isEmpty) continue; + if (winBpm.isEmpty || effortCnt == 0) continue; // The WHOLE window must be genuinely elevated, not just brief floor // crossings that dip-bridging stitched together — require the mean HR to // clear the floor too. (A sedentary afternoon oscillating around the gate - // averaged below it; a real bout averages well above.) - final winMean = winBpm.reduce((a, b) => a + b) / winBpm.length; + // averaged below it; a real bout averages well above.) Judged on the + // effort minutes only: a bridged rest is by construction below the gate, + // and folding it in would turn one bridged match into NO suggestion where + // its two halves used to produce two. With no rests this is the whole + // window, exactly as before. + final winMean = effortSum / effortCnt; if (winMean < floor) continue; // 5: motion confirmation when a continuous motion series was supplied. @@ -298,7 +374,8 @@ class AutoWorkoutDetector { var sum = 0.0; var cnt = 0; for (final p in motionSeries) { - if (p.ts >= start && p.ts <= end) { + // Same effort-only rule as the HR mean: a sit-down is still. + if (p.ts >= start && p.ts <= end && !span.inRest(p.ts)) { sum += p.intensity; cnt++; } @@ -352,6 +429,49 @@ class AutoWorkoutDetector { return results; } + /// Whether the pause between two bouts — from [gapStart] (last sample of + /// the earlier span) to [gapEnd] (first sample of the later one) — may be + /// bridged as a rest inside one workout. Both must hold: + /// + /// * OBSERVED: no hole in the record longer than [maxGapS] anywhere in it. + /// The span builder's rule, for the same reason: absence of data is not + /// evidence of continuity — an off-wrist stretch between two efforts says + /// nothing about whether the user was still at it. + /// * NOT RECOVERED: the mean HR strictly inside the pause is at or above + /// [bridgeFloor] (see [restBridgeHrrFraction]). + /// + /// No samples strictly inside → can't tell → don't bridge. + static bool _restBridgeHolds( + List ts, List bpm, int gapStart, int gapEnd, int bridgeFloor) { + var k = _lowerBound(ts, gapStart); + var prev = gapStart; + var sum = 0, cnt = 0; + for (; k < ts.length && ts[k] <= gapEnd; k++) { + if (ts[k] - prev > maxGapS) return false; + prev = ts[k]; + if (ts[k] > gapStart && ts[k] < gapEnd) { + sum += bpm[k]; + cnt++; + } + } + if (gapEnd - prev > maxGapS) return false; + return cnt > 0 && sum / cnt >= bridgeFloor; + } + + /// First index with `ts[i] >= x` ([ts] sorted ascending). + static int _lowerBound(List ts, int x) { + var lo = 0, hi = ts.length; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (ts[mid] < x) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; + } + /// Closed-interval overlap (touching endpoints count). static bool _overlaps(int aStart, int aEnd, int bStart, int bEnd) => aStart <= bEnd && bStart <= aEnd; @@ -402,3 +522,16 @@ Metric> autoDetectWorkouts({ 'wrist-HR ESTIMATE, not medical advice', ); } + +/// One merged candidate: [start, end] plus the bridged rests inside it, each +/// an open interval (lastEffortSample, firstEffortSample) — see +/// [AutoWorkoutDetector.restBridgeMaxS]. +class _Window { + final int start; + int end; + final List> rests = []; + _Window(this.start, this.end); + + /// True when [t] falls strictly inside a bridged rest. + bool inRest(int t) => rests.any((r) => t > r[0] && t < r[1]); +} diff --git a/lib/src/onehz/workout/workout.dart b/lib/src/onehz/workout/workout.dart index 542c245..ea273c4 100644 --- a/lib/src/onehz/workout/workout.dart +++ b/lib/src/onehz/workout/workout.dart @@ -5,7 +5,9 @@ /// /// * [AutoWorkoutDetector] — opt-in "did you just work out?" SUGGESTION /// detector (HR ≥ max(RHR+40, RHR+0.45·HRR) sustained ≥12 min, brief dips -/// tolerated, optional motion confirmation, overlap-excluded). Never writes +/// tolerated, an observed rest ≤15 min bridged when its mean HR is at or +/// above RHR+0.15·HRR and the joined workout stays within 120 min, +/// optional motion confirmation, overlap-excluded). Never writes /// a row, and is the ONLY bout detector here. → auto_detect.dart /// * [Calories] — Keytel 2005 + Harris–Benedict per-bout energy. → calories.dart /// diff --git a/test/onehz/auto_detect_test.dart b/test/onehz/auto_detect_test.dart new file mode 100644 index 0000000..5dd7053 --- /dev/null +++ b/test/onehz/auto_detect_test.dart @@ -0,0 +1,232 @@ +// AutoWorkoutDetector — synthetic known-answer tests for how a REST inside a +// workout is handled. +// +// The report this covers: "I play tennis for an hour, after 20 min I sit down +// for 5-10 min and then start again, and it splits the workout into 2 +// workouts." The sit-down drops HR below the gate, the span closes after +// maxDipS, and the halves landed ~7.5 min apart — past the old 5-min merge. +// +// All series are 1 Hz, HR-only (no motion → no motion gate), RHR 60 / +// HRmax 190: gate = max(60+40, round(60 + 0.45·130)) = 119 bpm, rest-bridge +// floor = round(60 + 0.15·130) = 80 bpm. +import 'dart:math' as math; + +import 'package:test/test.dart'; +import 'package:openstrap_analytics/src/onehz/workout/auto_detect.dart'; + +const int _t0 = 1700000000; +const int _rhr = 60; +const int _hrMax = 190; + +/// Builds a 1 Hz HR series from consecutive segments. A segment is either a +/// constant level or a recovery curve; `null` bpm means "no samples" (strap +/// off the wrist) for that many seconds. +class _Series { + final ts = []; + final bpm = []; + var _t = _t0; + + int get now => _t; + + void flat(int minutes, int? hr) { + for (var i = 0; i < minutes * 60; i++, _t++) { + if (hr == null) continue; + ts.add(_t); + bpm.add(hr); + } + } + + /// Exponential recovery from [from] toward [to] (τ = 60 s) — what HR does + /// when you stop and sit down. + void recover(int minutes, int from, int to) { + for (var i = 0; i < minutes * 60; i++, _t++) { + ts.add(_t); + bpm.add((to + (from - to) * math.exp(-i / 60.0)).round()); + } + } + + List detect( + {List savedSpans = const []}) => + AutoWorkoutDetector.detect( + hrTs: ts, + hrBpm: bpm, + restingBpm: _rhr, + maxBpm: _hrMax, + savedSpans: savedSpans, + ); +} + +void main() { + group('AutoWorkoutDetector rest bridging', () { + test('tennis 20 min / 8 min sit-down / 30 min is ONE workout', () { + final s = _Series()..flat(10, 70); + final playStart = s.now; + s + ..flat(20, 140) + ..recover(8, 140, 90) // sitting: HR falls but stays well above rest + ..flat(30, 140); + final playEnd = s.now - 1; + s.flat(20, 70); + + final w = s.detect(); + expect(w, hasLength(1)); + expect(w.single.startSec, playStart); + expect(w.single.endSec, playEnd); + expect(w.single.durationMin, 57); // 58 min of samples, floor((58·60−1)/60) + // The reported average covers the whole session, the sit-down included: + // below the 140 of play, above the gate. + expect(w.single.avgBpm, lessThan(140)); + expect(w.single.avgBpm, greaterThan(119)); + }); + + test('the sit-down really is past the unconditional 5-min merge', () { + // Guards the test above against passing for the wrong reason: the two + // halves must come out of span-building > mergeGapS apart, so it is + // the rest bridge that joins them. + final s = _Series() + ..flat(10, 70) + ..flat(20, 140) + ..recover(8, 140, 90) + ..flat(30, 140) + ..flat(20, 70); + final secondHalfStart = _t0 + 38 * 60; + // Last at-gate second before play resumes: the tail of the first half + // plus the few seconds the recovery curve takes to cross the gate. + final firstHalfEnd = [ + for (var k = 0; k < s.ts.length; k++) + if (s.bpm[k] >= 119 && s.ts[k] < secondHalfStart) s.ts[k] + ].last; + expect(secondHalfStart - firstHalfEnd, + greaterThan(AutoWorkoutDetector.mergeGapS)); + expect(secondHalfStart - firstHalfEnd, + lessThanOrEqualTo(AutoWorkoutDetector.restBridgeMaxS)); + + // And the detector itself splits there: span-building only sees + // at-gate vs below-gate, so pulling the sit-down's below-gate samples + // under the bridge floor leaves the spans untouched and takes away + // only the bridge. That must give the two halves back. + for (var k = 0; k < s.ts.length; k++) { + if (s.ts[k] > firstHalfEnd && s.ts[k] < secondHalfStart) s.bpm[k] = 70; + } + expect(s.detect(), hasLength(2)); + }); + + test('two sessions 2 h apart stay TWO workouts', () { + // HR between them is held at 90 — above the bridge floor — so it is the + // time limit, not HR, that keeps them apart. + final s = _Series()..flat(10, 70); + final morningStart = s.now; + s.flat(30, 140); + final morningEnd = s.now - 1; + s.flat(120, 90); + final laterStart = s.now; + s + ..flat(30, 140) + ..flat(10, 70); + + final w = s.detect(); + expect(w, hasLength(2)); + expect(w[0].startSec, morningStart); + expect(w[0].endSec, morningEnd); + expect(w[1].startSec, laterStart); + }); + + test('a 20-min elevated pause is past the bridge limit → TWO', () { + final s = _Series() + ..flat(10, 70) + ..flat(20, 140) + ..flat(20, 95) + ..flat(30, 140) + ..flat(10, 70); + expect(s.detect(), hasLength(2)); + }); + + test('an 8-min pause where HR is back at rest → TWO', () { + // Done, sat down, fully recovered: the next effort is a new workout. + final s = _Series() + ..flat(10, 70) + ..flat(20, 140) + ..recover(8, 140, 65) + ..flat(30, 140) + ..flat(10, 70); + expect(s.detect(), hasLength(2)); + }); + + test('an 8-min pause with the strap off the wrist → TWO', () { + // No samples is not evidence the user kept going (see maxGapS). + final s = _Series() + ..flat(10, 70) + ..flat(20, 140) + ..flat(8, null) + ..flat(30, 140) + ..flat(10, 70); + expect(s.detect(), hasLength(2)); + }); + + test('a bridge that would exceed maxWorkoutMin is refused → TWO', () { + // 70 + 8 + 50 = 128 min > 120: merged it would be dropped whole. + final s = _Series() + ..flat(10, 70) + ..flat(70, 140) + ..recover(8, 140, 90) + ..flat(50, 140) + ..flat(10, 70); + expect(s.detect(), hasLength(2)); + }); + + test('a short pause after a bridge cannot push it past maxWorkoutMin', () { + // 20 play / 8 sit / 60 play / 3 off / 35 play. The 3-min pause joins + // the last two efforts (98 min) as it always has; bridging the first + // one on as well would make 126 min, so it is refused and both + // suggestions survive instead of the whole window being dropped. + final s = _Series()..flat(10, 70); + final firstStart = s.now; + s.flat(20, 140); + final firstEnd = s.now - 1; + s.recover(8, 140, 90); + final secondStart = s.now; + s + ..flat(60, 140) + ..flat(3, 65) + ..flat(35, 140); + final secondEnd = s.now - 1; + s.flat(10, 70); + + final w = s.detect(); + expect(w, hasLength(2)); + expect(w[0].startSec, firstStart); + // The first effort ends where the sit-down's recovery crosses the gate. + expect(w[0].endSec, inInclusiveRange(firstEnd, firstEnd + 60)); + expect(w[1].startSec, secondStart); + expect(w[1].endSec, secondEnd); + }); + + test('a saved workout over one half does not hide the other half', () { + final s = _Series()..flat(10, 70); + final firstStart = s.now; + s.flat(20, 140); + final firstEnd = s.now - 1; + s.recover(8, 140, 90); + final secondStart = s.now; + s.flat(30, 140); + final secondEnd = s.now - 1; + s.flat(20, 70); + + final w = s.detect( + savedSpans: [SavedWorkoutSpan(firstStart, firstEnd)]); + expect(w, hasLength(1)); + expect(w.single.startSec, secondStart); + expect(w.single.endSec, secondEnd); + }); + + test('a short (< 5 min) pause still merges as before', () { + final s = _Series() + ..flat(10, 70) + ..flat(20, 140) + ..flat(3, 65) + ..flat(20, 140) + ..flat(10, 70); + expect(s.detect(), hasLength(1)); + }); + }); +}