Repository navigation
Description
TL;DR: Of every resting-HR approach in use across the industry, #2358 picked the one that least isolates rest: an unweighted mean of the whole sleep session. The previous definition (lowest 5-min bin, which is Oura's definition) was one of the established ones. It may not be the best, since a smoothed or percentile nadir is more robust, but the current one is the worst of the lot. It raises every user's headline RHR by ~9–10 bpm, lowers Effort by ~10 %, moves Fitness Age by ~5 years, changes which nights Charge reads as recovered, shifts every other metric built on daily RHR, and fails this project's own validation rules by its own merge note. The nap fix in #2358 is genuine and should stay. The definition should go back to the lowest 5-minute bin, now taken from the primary session.
1. It no longer measures resting HR
Resting HR is HR in a steady state of minimal activity: a short, stable window at the quietest point. The pre-#2358 value was exactly that, the lowest 5-minute bin. The new value is the unweighted mean of every sample across the whole session: every sleep stage, every arousal, every wake minute inside the session boundaries. That is mean sleeping HR, a legitimate metric with its own name. Shipping it under the label "resting HR", and feeding it into recovery, is a category error, not a calibration choice.
2. No major wearable does this, and it ranks last among the approaches in use
| Vendor | Resting HR definition |
|---|---|
| Oura | Lowest HR of the night. The API field is "Heart rate lowest (5-min window)", the exact definition #2358 removed. The night average is reported separately, not as RHR. |
| Garmin | Lowest 30-min average in 24 h |
| WHOOP | Dynamic average weighted towards the last slow-wave sleep |
| Polar | Nightly Recharge: average of the first ~4 h of sleep. The official RHR is a manual supine morning measurement. |
| Apple | Lowest HR during awake rest, sleep excluded |
Every vendor either takes a nadir or restricts and weights the window towards the quietest state. None reports an unweighted whole-session mean as resting HR.
Ranked by how well they isolate the resting state: a smoothed or percentile nadir (Garmin; the 2025 methods study below) ≥ lowest 5-min bin (Oura; NOOP before #2358) > stage-weighted or window-restricted means (WHOOP, Polar) > unweighted whole-session mean (NOOP since #2358, used by no vendor). Apple measures awake rest, a different context, but it still targets the lowest resting value. #2358 moved NOOP from near the top of that list to the bottom.
3. No part of the night averages down to the resting floor, and #2284 would make it worse
Mean HR per stage of the primary session, relative to the same night's lowest 5-min bin (14 nights, one wearer):
| Stage (as staged by NOOP) | Mean HR | Above the night's 5-min nadir |
|---|---|---|
| Deep | 67.6 | +11.3 |
| Light | 64.4 | +8.1 |
| REM | 64.1 | +7.9 |
| Wake | 72.6 | +16.3 |
Every stage sits 8–16 bpm above the floor, so any average over sleep lands far above resting HR, by construction. Worse, segments staged as deep carry a higher mean HR than REM on 14 of 14 nights, which is physiologically backwards (staging error, or deep sleep clustering early before HR has fallen). A deep-weighted mean (#2284) would therefore push RHR further up. A nadir needs no staging and inherits none of its errors.
4. The evidence fails this project's own bar, by its own admission
- One participant, five nights, rounded MAE.
- The target was WHOOP's proprietary number. Agreeing with a black box shows the numbers look alike, not that the definition is valid.
- The merge note itself calls the evidence "thinner than the change it carries".
- AGENTS.md:
Prove the method tracks a varying input … Until it does, land it as instrumentation … or behind a default-off Experimental toggle — never make it the default or feed it a downstream gate (recovery, illness) on thin evidence.
#2358 did the opposite on every count: it made it the default, fed it into recovery, and shifted every existing user's headline RHR on their next score.
5. It changes which nights read as recovered
Charge scores RHR as a z-score against the personal baseline (W_RHR = 0.20), so the level shift largely cancels. The ranking of nights does not. Recomputed with RecoveryScorer over the last 14 nights (the recomputation reproduces the stored Charge exactly): with a strap-only baseline, individual nights move by up to 9 Charge points in either direction depending solely on the definition. Which mornings read as recovered is now decided by a definition that measures the wrong thing.
6. It also deflates Effort
Effort scores the day's HR as a share of heart-rate reserve, using that day's resting HR directly (restForStrain = restingHRDaily in AnalyticsEngine), with no baseline in between. Raising RHR by ~10 bpm shrinks the reserve, so the same activity counts as less intense. Example with HRmax 195: HR 120 is 46 % of reserve at RHR 56, but only 42 % at RHR 66. Low-intensity activity just above resting HR loses the most.
Recomputed with StrainScorer.strain (Edwards, the default) on identical day HR for 13 days: Effort is lower on every day under the #2358 definition, by 4.3 points on average (range 0.9 to 9.8) on the 0–100 scale. With typical daily Effort of 25–68 here, that is roughly 10 %, and over 20 % on single days. (This recomputation matches the stored Effort only within a few points, because the app assembles day HR slightly differently. Both variants run on the same input, so the difference itself is solid.)
7. Everything built on daily resting HR moves with it
Daily RHR is an input, not just a display value. Every consumer below reads the per-day value that #2358 redefined (checked on current main):
| Consumer | How it uses daily RHR | Effect of the switch |
|---|---|---|
Fitness Age / VO₂max estimate (IntelligenceEngine.fitnessAgeRows, HUNT model, Uth fallback) |
median RHR of the last 7 days | +5.3 years from the RHR shift alone (same activity index, recomputed with FitnessAgeEngine). The stored value jumped +7.5 years in the week the change landed; the lower Effort also shrinks the activity index, so the two compound. The VO₂max estimate drops with it. |
Effort (StrainScorer) |
heart-rate reserve | see section 6 |
Charge (RecoveryScorer) |
z-score vs baseline | see section 5 |
Day active-energy estimate (Calories.estimateDayCalories) |
Keytel fitness-adjusted, VO₂max from Uth (15.3 · HRmax / RHR) |
the Uth VO₂max falls by ~15 % for this shift, so energy estimates move with it |
Workout detection (WorkoutDetector.detect(restingHR:)) |
thresholds relative to RHR | a higher RHR raises the bar for what counts as a workout |
Live sessions (repo.today?.restingHr) |
live zones and live Effort | zones shift up |
| Vitality | median RHR | shifts |
Apple Health write-back (HealthKitBridge) |
writes the day's RHR to Health | ~9–10 bpm higher values exported to Health and visible to every other app |
Coach context (AICoach) |
daily RHR and 30-day average in the prompt | the coach reasons about an inflated RHR |
| Illness signal, Readiness, cycle phase | z-scores vs personal baselines | level largely cancels once baselines catch up; individual days shift |
One caveat, to be precise: the HUNT model behind Fitness Age was built on an awake seated resting HR (NTNU's calculator: sit quietly for ten minutes, take the lowest pulse), which neither nocturnal estimate is exactly. That is a reason to choose that input deliberately, not to let a redefinition move Fitness Age by five years as a side effect.
Data (one wearer, WHOOP 5.0, last 14 nights; an illustration, not validation)
- Paired, night by night: the new value is higher on every night, by +9.4 bpm on average (median +9.5, range +6 to +12).
| Estimator (primary session) | Mean | Night-to-night SD |
|---|---|---|
| Whole-session mean (#2358) | 65.7 | 3.3 |
| Lowest 5-min bin (pre-#2358) | 56.0 | 2.9 |
| Lowest 30-min average (Garmin-style) | 58.8 | 2.9 |
| 1st percentile of 30-s means | 54.8 | 2.7 |
| 5th percentile of 30-s means | 57.4 | 2.9 |
Every nadir-type estimator lands within ~4 bpm of the others. The mean sits alone, ~9–10 bpm above all of them.
"But the minimum is artifact-prone"
That objection applies to a raw minimum. A 2025 methods study (Digital Health, 10 participants, chest ECG) found that minima of 15–60 s windows produced non-physiological values (≤ 30 bpm) for 40 % of participants, and it recommends the 1st percentile of 30-s means. A 5-minute bin already averages ~300 samples. The answer to a noisy nadir is a robust nadir, not abandoning the nadir for the mean.
Recommendation
- Keep the primary-session selection from fix(analytics): wire primary-session resting HR to fix nap floor dist… #2358 (the nap fix).
- Keep the lowest 5-min bin as the resting-HR definition, now taken from the primary session, i.e. revert the definition part of fix(analytics): wire primary-session resting HR to fix nap floor dist… #2358. A smoothed or percentile nadir is a reasonable later refinement; the mean is not.
- Keep the session mean as a separately labelled "average sleeping HR" (instrumentation or display), never as RHR.
- Any future redefinition, fix(rhr): report resting HR as the deep-sleep mean, not the night's lowest 5-min bin #2284 included, ships behind a default-off toggle until it is shown to track a varying input across several people, as AGENTS.md requires.
Credit to @moyasserr for finding the nap bug. That part is a real fix.
Sources: Oura API metrics · Garmin RHR · WHOOP RHR · Polar Nightly Recharge · Polar manual RHR · Apple Watch HR · RHR methods study, Digital Health 2025 · Wearable nocturnal RHR validation · NTNU fitness calculator (HUNT)
Edited: rewritten; data restricted to the last 14 nights (the wearer's fitness changed, so earlier nights aren't comparable), device corrected to WHOOP 5.0, per-stage, Charge and Effort analysis and the downstream-metrics table added, and an earlier claim about a mixed baseline withdrawn after checking how the baseline is built.
Activity
[-]#2358: keep the primary-session fix, but restore a nadir definition of resting HR[/-][+]#2358 replaced resting HR with mean sleeping HR on n=1 evidence: revert the definition, keep the nap fix[/+]analytics: keep the daily resting HR as the primary session's 5-min n…
analytics: restore primary-night RHR floor (Refs #2522)
Merge upstream/main into the fork (sync 2026-09-29)
DX23876 commentedon Sep 30, 2026
Fixed by #2557 (thanks @kavemang): the daily resting HR is the primary night's five-minute floor again, as this issue proposed. Closing as completed.
merge: integrate RyanBR upstream through 7f396e9
fork: keep the deep-sleep mean as the day's resting HR after the upst…