Skip to content

fix(workout): suppress PPG spikes in the workout max HR (#127)#133

Merged
abdulsaheel merged 3 commits into
OpenStrap:mainfrom
dannymcc:fix/workout-max-hr-spike
Jul 23, 2026
Merged

fix(workout): suppress PPG spikes in the workout max HR (#127)#133
abdulsaheel merged 3 commits into
OpenStrap:mainfrom
dannymcc:fix/workout-max-hr-spike

Conversation

@dannymcc

@dannymcc dannymcc commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

Closes #127. The workout summary shows max HR 160 bpm while the Heart-rate page peak is 143 (correct). The HR page peak is the max of per-minute-averaged HR (transient spikes averaged away), but the workout max was a raw hr.reduce(math.max) over the 1 Hz samples with no artefact rejection — so a single 1–2 s PPG motion spike defines the whole session's max.

Fix

New shared smoother lib/compute/hr_max.dart:

  • Physiological reject — drop samples outside a plausible band (floor 30 bpm; ceiling = 220 − age + headroom, hard-capped at 220).
  • Rolling median over a short odd window (~5 s at 1 Hz) — a 1–2 s spike can never be the median of five samples, so the median steps over it; a genuine sustained peak survives (most of the window sits at the raised level). This deliberately does not floor to the minute-mean — a real brief peak can legitimately exceed a minute average.

Applied consistently to all three producers that write the workout max, so the detail screen, the workout list, and the live session agree:

  1. getWorkout (local_repository_impl.dart) — RECOMPUTE from the smoothed raw instead of math.max(stored, peak). Flooring against the stored column would preserve a spike the old live path had already written. Time-to-peak follows the smoothed peak's index.
  2. getWorkouts list (local_repository_impl.dart + db.dart) — fill max_hr from the batched raw 1 Hz via the shared smoother (one indexed join), so the list matches the detail recompute. The SQL MAX(d.hr) is now physiologically ceiling-bounded and used only as a coarse last fallback when raw has been pruned (14-day retention).
  3. Live tick (app_state.dart) — maxHrSeen now accrues through a rolling-median accumulator (RollingMaxHr) rather than > maxHrSeen, so a spike can't set the session max or fire a spurious "new max!" callout. The live-session "NEW MAX" moment keys off the smoothed value.

The HR-page minute-mean path is unchanged.

Tests

test/workout_max_hr_spike_test.dart:

  • Pure smoother: a 1–2 s spike is excluded, a genuine sustained peak is preserved, time-to-peak index lands on the real peak, the physiological reject drops impossible readings, short-series fallback, ceiling boundaries.
  • Live accumulator (RollingMaxHr / LiveWorkoutState.accrueHr) matches the batch recompute.
  • Repo seam over the real LocalDb (sqflite_ffi): getWorkout and getWorkouts both report the smoothed peak (a persisted spiked max_hr of 160 is overridden), and the two agree.

Not verified without a device

No Flutter/Dart SDK in this environment, so flutter analyze/flutter test were not run — the change is verified by inspection and the new tests are written to the existing workout_enrichment_test.dart harness. Please run the suite on a machine with the SDK.

On-device confirmation still wanted: inspect the raw decoded_onehz HR over the reported session to confirm the 160 was in fact a 1–2 s outlier (not a genuine short burst), and eyeball that the summary now matches the HR-page 143.

Summary by CodeRabbit

  • Bug Fixes
    • Improved max heart-rate detection by suppressing short sensor spikes and filtering implausible values using an age-aware physiological ceiling (and floor for minima).
    • Live sessions and completed workout summaries now compute smoothed max/min HR and more accurately determine time-to-peak from sustained plateaus, reducing false “NEW MAX” milestones.
    • Database session HR stats and samples are now bounded/filtered to avoid artifact-driven extrema.
  • Tests
    • Added Flutter tests validating spike rejection, rolling live behavior, physiological bounds, and correct max/time-to-peak results via unit and integration coverage.

The workout summary took a raw `hr.reduce(math.max)` over the 1 Hz
samples with no artefact rejection, so a single 1-2 s PPG motion spike
defined the session max (160 bpm) while the HR page's minute-mean peak
showed the true 143.

Add a shared smoother (compute/hr_max.dart): a physiological reject
(age-derived ceiling, hard-capped at 220) plus a short rolling median
(~5 s) that steps over 1-2 s transients while preserving a genuine brief
effort peak. Apply it consistently to all three producers of the max:

- getWorkout: RECOMPUTE from the smoothed raw instead of flooring against
  the stored column (which may already hold a spiked live value); the
  time-to-peak index follows the smoothed peak.
- getWorkouts list: fill max_hr from the batched raw via the shared
  smoother so the list agrees with the detail screen; the SQL MAX(d.hr)
  is now physiologically ceiling-bounded and used only as a last fallback
  when raw is pruned.
- live tick: maxHrSeen now accrues through a rolling-median accumulator
  so a spike can't set the session max or fire a spurious "new max!".

Keep the HR-page minute-mean path unchanged.

Add unit + repo-seam tests: a 1-2 s spike is excluded, a sustained peak
is preserved, the live accumulator matches the batch recompute, and
getWorkout/getWorkouts report the same smoothed peak.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5b209a9d-ad35-424b-8b7a-ade936cdf941

📥 Commits

Reviewing files that changed from the base of the PR and between eef7a88 and 2d3cb57.

📒 Files selected for processing (4)
  • lib/compute/hr_max.dart
  • lib/data/db.dart
  • lib/data/local_repository_impl.dart
  • test/workout_max_hr_spike_test.dart
🚧 Files skipped from review as they are similar to previous changes (4)
  • lib/data/db.dart
  • test/workout_max_hr_spike_test.dart
  • lib/compute/hr_max.dart
  • lib/data/local_repository_impl.dart

📝 Walkthrough

Walkthrough

Adds age-aware physiological filtering and rolling-median heart-rate peak computation across repository enrichment and live workout tracking, including peak-index reporting, updated milestone gating, database sample retrieval, and spike-focused tests.

Changes

Heart-rate peak pipeline

Layer / File(s) Summary
Heart-rate filtering and smoothing
lib/compute/hr_max.dart
Adds age-based ceilings, plausibility filtering, rolling-median peak selection, source-index reporting, and the RollingMaxHr streaming helper.
Persisted session peak enrichment
lib/data/db.dart, lib/data/local_repository_impl.dart
Adds bounded session statistics and ordered per-session HR sample retrieval, then recomputes workout peaks, troughs, and peak times from smoothed samples.
Live workout peak integration
lib/state/app_state.dart, lib/ui/activity/live_session_screen.dart
Routes live samples through rolling smoothing and updates new-max milestone gating to use the smoothed peak.
Peak behavior and repository validation
test/workout_max_hr_spike_test.dart
Tests spike rejection, sustained peaks, bounds, source indexes, live accumulation, and repository results.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AppState
  participant LiveWorkoutState
  participant RollingMaxHr
  participant LiveSessionScreen
  AppState->>LiveWorkoutState: accrueHr(currentHr)
  LiveWorkoutState->>RollingMaxHr: add(hr)
  RollingMaxHr-->>LiveWorkoutState: update maxHrSeen
  LiveSessionScreen->>LiveWorkoutState: evaluate maxHrSeen
  LiveSessionScreen-->>LiveSessionScreen: emit NEW MAX when gated
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and clearly describes the main change: suppressing workout HR spikes.
Linked Issues check ✅ Passed The changes add spike rejection, rolling-median smoothing, age-based ceilings, and apply them to live tracking, workout detail, and list stats as requested.
Out of Scope Changes check ✅ Passed No clearly unrelated code changes appear; the added min-HR smoothing stays within the same workout HR correction flow.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/workout_max_hr_spike_test.dart`:
- Around line 188-192: Make the getWorkouts list test self-contained by
extracting the w-spike session and sample setup from the preceding test into a
shared fixture helper, then invoke that helper from both tests (or seed it in
setUp). Ensure the test still verifies the same smoothed max_hr value without
relying on execution order.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ba75a92-4a1a-4351-a9a0-d34135627c62

📥 Commits

Reviewing files that changed from the base of the PR and between f8a1c1d and bcc8600.

📒 Files selected for processing (6)
  • lib/compute/hr_max.dart
  • lib/data/db.dart
  • lib/data/local_repository_impl.dart
  • lib/state/app_state.dart
  • lib/ui/activity/live_session_screen.dart
  • test/workout_max_hr_spike_test.dart

Comment thread test/workout_max_hr_spike_test.dart
dannymcc added 2 commits July 22, 2026 14:33
Seed the w-spike session + samples once in setUpAll via a shared helper
rather than inside the first test, so the getWorkouts-list test no longer
depends on the preceding getWorkout test having created the fixture. Each
test is now self-contained and passes when run alone.
)

Symmetric to the max fix: a single 1-2 s low PPG dropout could define
the workout min via the raw `reduce(math.min)`. Apply the same rolling
median + physiological reject to the trough.

- hr_max.dart: factor the max helper into a shared core and add
  smoothedMinHr / smoothedMinHrAt (rolling median over the same window so
  a 1-2 s low can't be the median; the floor/ceiling reject drops garbage
  such as a stray sub-30 bpm reading before the min).
- getWorkout: min_hr now uses smoothedMinHr instead of reduce(math.min).
- getWorkouts list: fill min_hr from the batched raw via smoothedMinHr so
  the list agrees with the detail recompute; the SQL MIN(d.hr) is now
  floor-bounded (minHrFloor) and used only when raw is pruned.

The live path tracks no running min, so no accumulator was added there.
Keep the HR-page path untouched.

Extend the tests: a 1-2 s low dropout is excluded, a genuine sustained
low is preserved, the physiological floor drops sub-30 garbage, and the
repo-seam fixture now carries a 40 bpm dropout + 20 bpm garbage that
neither getWorkout nor getWorkouts lets define the min.
@abdulsaheel
abdulsaheel merged commit 533d110 into OpenStrap:main Jul 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workout max HR inflated vs Heart-rate page peak (raw-sample max, no artefact rejection)

2 participants