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 samples — lib/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.
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:
getDayHeartreadsseries.hr_curveand takes_maxHr(hrCurve)—lib/data/local_repository_impl.dart:456,464and helper:2338.hr_curveis built by_downsampleHr(...)which averages each minute's raw samples —lib/compute/onehz_pipeline.dart:436/ def:1023-1034.Workout summary/detail (160) reduces over the raw 1 Hz samples, unfiltered:
getWorkoutpulls raw samples viahrSamplesInRange(startTs, endTs)and takespeak = hr.reduce(math.max), thenw['max_hr'] = math.max(stored, peak)—lib/data/local_repository_impl.dart:1748-1757.MAX(d.hr)in SQL over raw samples —lib/data/db.dart:3710(sessionHrStats), applied atlocal_repository_impl.dart:1678.if (w.currentHr > w.maxHrSeen) w.maxHrSeen = w.currentHr(lib/state/app_state.dart:3164), persisted asmax_hrat:3066.lib/ui/workouts/workouts_screen.dart:1269, and aspeakHrat: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 definesmax_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,getWorkoutenrichment,sessionHrStats):max_hras the max over a short rolling median (~5-10 s) of the raw samples, instead ofreduce(math.max). This kills 1-2 s spikes while preserving a genuine sustained peak (truer than a full 60 s minute-mean).getWorkoutcurrently doesmath.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 thanmax()-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
decoded_onehzHR around the workout window: confirm 160 is a 1-2 s outlier (its neighbours ~140s) rather than a sustained climb.maxHrSeenvsgetWorkout/sessionHrStatsenrichment) 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.