Skip to content
Merged
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
53 changes: 52 additions & 1 deletion lib/src/onehz/workout/auto_detect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<int> ts, List<int> 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
Expand Down
78 changes: 67 additions & 11 deletions test/onehz/workout_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <int>[];
final bpm = <int>[];
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 = <int>[for (var t = 0; t < 900; t++) t];
final bpm = <int>[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', () {
Expand Down
Loading