Skip to content

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

Description

@dannymcc

Summary

For the same period, the workout summary reports max HR = 160 bpm while the Heart-rate day page reports peak = 143 bpm. The 143 value is the correct one; the workout max HR is inflated. (Reported by a user, 22 Jul 2026.)

Both numbers are derived from the same underlying 1 Hz HR stream — they diverge only because the two screens reduce it differently, and the workout path has no artifact/spike rejection.

Root cause

Heart-rate page (143) reduces over a per-minute-mean curve:

  • getDayHeart reads series.hr_curve and takes _maxHr(hrCurve)lib/data/local_repository_impl.dart:456,464 and helper :2338.
  • hr_curve is built by _downsampleHr(...) which averages each minute's raw sampleslib/compute/onehz_pipeline.dart:436 / def :1023-1034.
  • So the page's "peak" is the highest minute-average, which absorbs any single-second spike.

Workout summary/detail (160) reduces over the raw 1 Hz samples, unfiltered:

  • getWorkout pulls raw samples via hrSamplesInRange(startTs, endTs) and takes peak = hr.reduce(math.max), then w['max_hr'] = math.max(stored, peak)lib/data/local_repository_impl.dart:1748-1757.
  • The workout list path independently computes MAX(d.hr) in SQL over raw samples — lib/data/db.dart:3710 (sessionHrStats), applied at local_repository_impl.dart:1678.
  • The live path stores a running raw max: if (w.currentHr > w.maxHrSeen) w.maxHrSeen = w.currentHr (lib/state/app_state.dart:3164), persisted as max_hr at :3066.
  • Displayed as "max bpm" on the workout detail — lib/ui/workouts/workouts_screen.dart:1269, and as peakHr at :422.

There is no spike/artifact rejection or physiological clamp on any of the three workout producers (only hr > 0). A single transient sample — typical of optical-PPG motion artefacts during a workout — therefore defines max_hr. The Heart-rate page's minute-averaging silently removes that same spike, so the two screens can never agree, and the workout figure is the one that surfaces the artefact. Hence 160 (raw one-second peak) vs 143 (peak minute-mean).

Which value is correct

143 is correct by construction for this report: a lone 160 that vanishes under minute-averaging is an artefact, not a sustained heartbeat. (Note the general case: a true instantaneous workout peak is legitimately higher than a peak-minute-mean, so the fix should suppress spikes, not simply floor the workout figure to the minute-mean.)

Recommended fix

Make the workout peak robust to single-sample artefacts, and apply the same reduction across all three producers (live maxHrSeen, getWorkout enrichment, sessionHrStats):

  • Compute max_hr as the max over a short rolling median (~5-10 s) of the raw samples, instead of reduce(math.max). This kills 1-2 s spikes while preserving a genuine sustained peak (truer than a full 60 s minute-mean).
  • Also reject non-physiological jumps / apply an age-based ceiling before the reduction.
  • Important: getWorkout currently does math.max(stored, peak) — so a spiked value stored by the live path (maxHrSeen) will persist even after the query path is fixed. The fix must recompute from smoothed samples rather than max()-ing with the stored value, and the live running-max must be smoothed too.

Confidence: High that this is the mechanism and location (raw-sample max vs minute-mean max, no spike rejection). Medium on whether the specific 160 is an artefact vs a brief genuine peak — that needs the on-device raw 1 Hz samples for the window (see below).

Needs on-device confirmation

  • Inspect the raw 1 Hz decoded_onehz HR around the workout window: confirm 160 is a 1-2 s outlier (its neighbours ~140s) rather than a sustained climb.
  • Confirm which producer wrote the 160 (live maxHrSeen vs getWorkout/sessionHrStats enrichment) to size the fix.

Investigated read-only from source; no Flutter/Dart SDK or device available in this environment, so runtime behaviour was not executed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions