Skip to content

[Feature]: Readiness / recovery score from HRV, resting HR, sleep and skin temperature #103

Description

@ak710

What problem does this solve?

The roadmap lists readiness under Metrics you can trust"Performance & recovery: readiness, training/cardio load, HRV and resting-HR trends, VO₂max" and "Personal baselines: learn what's normal for you and flag meaningful changes" — but there's nothing in the codebase yet.

Today PulseLoop shows me the inputs to recovery (HRV, resting HR, sleep score, skin temperature, yesterday's activity) on separate cards, and leaves me to integrate them in my head every morning. Every ring I could buy this for — Colmi, jring, the YCBT family — already reports enough to answer "should I train hard today?" directly. This is also the single biggest thing paid competitors (Oura, Whoop) put behind a subscription, and PulseLoop already has all the data locally.

Opening this per CONTRIBUTING before writing much code, so the algorithm can be agreed first. I'd like to implement it. I have a Colmi ring to validate against.

Proposed solution

A daily Readiness score (0–100) with a band label, shown as a Today tile and a tap-through detail screen that always shows which contributors drove it.

The framing I care about most: it must not be a black box. Per the project's principles the tile shows a number and its biggest drag ("HRV 12% below your baseline"), the detail screen breaks out every contributor with its earned/possible points, and the weights live in a committed docs/project/readiness.md rather than only in code.

Contributors — 5 signals, 100 points. Each is a one-sided deviation from the user's own baseline, except sleep (absolute, since SleepScore already encodes population ranges).

Contributor Pts Measured Compared against ideal soft hard
HRV 30 overnight mean (ms) 30d BaselineStats.median ≥0% −15% −40%
Resting HR 25 overnight p10 (bpm) UserProfile.hrRestingBaseline ≤0 +5 +12
Sleep 30 SleepScore.calculate().score absolute ≥88 65 30
Skin temperature 10 overnight mean (°C) 30d BaselineStats.median ≤0.2 0.6 1.2
Training load 5 yesterday's active/workout minutes trailing 7d mean ≤1.2× 1.8× 3.0×

Band-scored between the knots (same shape as SleepScore.bandScore, but one-sided and with a harsher 0.55 knee — a recovery score that never drops below 65 isn't telling you anything). HRV above baseline and RHR below baseline are never penalized; parasympathetic-overshoot logic isn't falsifiable from a ring.

Honest degradation is the core design rule. Following the doctrine already stated at the top of SleepInsights.swift"missing nights are never treated as zero" — a signal that wasn't captured is excluded from the denominator, never scored as zero:

score = 100 × earned / available     // `available` sums only computable contributors

Gate: available ≥ 50 and at least one of {HRV, sleep} present. That falls out correctly across the supported hardware:

  • Colmi, baselines established → all 100 pts
  • Colmi, no temperature that night → 90 pts, coverage 0.90
  • jring (sleep + HR, no HRV) → 55 pts, still scored
  • Any ring in its first week → unavailable(.baselineLearning), tile shows "Day N of 14" using the existing calibration state
  • Ring with neither HRV nor sleep → tile never appears

Reuse, not new machinery. BaselineStats.compute (VitalsModelBridge.swift) supplies the HRV/temperature baselines and its existing isEstablished gate (≥7 days, ≥20 samples) decides when a contributor is trustworthy. Resting HR reuses UserProfile.hrRestingBaseline, which RestingHRBaselineService already learns, persists and throttles — no second baseline pipeline. ReadinessService is modeled directly on that service's shape.

Scores persist as a ReadinessDaily model (with the contributor breakdown), so history keeps its why and the trend chart doesn't recompute 90 days on the render path. Rows carry an algorithmVersion, so old scores are never silently reinterpreted under new weights.

Suggested PR sequence, so nothing lands as one huge diff:

  1. Algorithm + baselines + storage + docs page + unit tests — no UI
  2. Today tile (+ Settings)
  3. Detail screen with contributor breakdown and trend chart
  4. Coach tool get_readiness (returns the contributor array, so the coach cites the real reason instead of inventing one)
  5. Widget metric
  6. (optional) check-in mention + an anomaly rule for a sharp drop

Questions I'd like your call on before I start

  1. The weights above — 30/25/30/10/5. Happy to adjust; this is the main thing I want agreed.
  2. Default on or off? I'm proposing on by default: unlike nutrition it adds no new permission, no network egress, and stores nothing the user didn't already have, and it self-gates on capability + baseline establishment. Easy to flip if you'd rather it be opt-in.
  3. Resting HR source. Reusing hrRestingBaseline is an all-day p10 over 30 days rather than an overnight-only baseline. It's dominated by sleep values anyway (your lowest HR of the day is during sleep), and reusing it avoids a parallel pipeline — but an overnight-only variant is a clean follow-up if you'd prefer it from the start.
  4. Naming — "Readiness" vs "Recovery"? The roadmap says readiness; happy either way.
  5. Anything here that should land on Android too, so I can keep the docs cross-platform.

Area

Data / decoding / sync (with UI and Coach follow-ups)

Alternatives considered

  • Compute on demand instead of persisting. Rejected: the trend chart wants 30–90 days, which is incompatible with the TodayStore signature architecture that exists to keep work off the render path — and recomputing an old day against today's baseline would give a different, wrong answer.
  • Score missing signals at a neutral 50% instead of excluding them. Rejected: it silently invents data and would drag a perfectly good night down just because the ring dropped a temperature reading.
  • Nightly aggregates as the baseline unit rather than raw samples. Rejected: it would turn BaselineStats.isEstablished (≥20 samples) into a 20-night gate. A Colmi samples HRV every ~30 min overnight, so raw samples clear it in about a week of wear.
  • A single composite "recovery" number with no breakdown. Rejected on principle — "documented metrics, no black boxes."

Would you be willing to work on this?

Yes, I'd like to implement it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions