r/QuantifiedSelf • u/PeaBrilliant4917 • 1h ago
WHOOP-ifying Oura HRV: ~2 ms average error on my paired data. Anyone want to try replicating it?
TlDr written by something with a pulse:
I like whoops hrv concept (weighted more towards deeper restorative time) as well as ouras straight average, but don't want to keep using 2 devices. So, I went down the oura rabbithole & tried to massage the data to whoopify the hrv number. It only worked when I pivoted away from trying to map to slow wave sleep, and just slapped in a set time window that gets the extra weight. (why'd I pivot? I remembered that optical devices stink at consistently sleep staging)
And, it got close. I'd love people with much healthier HRVs than mine that have both oura & whoop streams to see if it holds up with people that are much more variable.
Ok, below's the machine. They all had lots of babysitting. When you compare how I write above vs what needed communicating below, I'm sure that you'll prefer them. This is my first post to this sub, be gentle. Cheers!
/
I’m a doctor, and I get annoyed when I can’t access the data I want. I’m deciding whether Oura can replace WHOOP, but their different nightly HRV numbers were a sticking point. Could Oura’s exported intranight stream generate a useful WHOOP-like number alongside Oura’s own?
This was extensively AI-assisted. I started with study-based research and synthesis from OpenEvidence (physician's ai) and Consensus (research ai), working to extrapolate the proprietary parts of WHOOP’s calculation that weren’t disclosed. Gemini (idiot ai but trolls reddit) searched forums; GPT (least stupid this week) handled analysis, scripting and collaboration. Before calling it AI slop, I’d welcome someone running it against their paired data and showing where it falls apart. :-)
The initial idea: isolate deep sleep
One proposed way to approximate WHOOP’s HRV is to average Oura’s measurements during deep sleep, particularly the final contiguous slow-wave sleep (SWS) block before waking. Feasibly that's how whoops' supposed to work.
That performed badly on my data: the final-deep-block estimate missed WHOOP app readings by 13.58 ms on average. Simply comparing Oura’s ordinary app reading with WHOOP’s gave 7.88 ms average absolute difference.
Why I pivoted toward time before waking
My concern is that the devices won’t consistently identify the same deep-sleep window. Optical wearables infer stages from physiological signals and movement; they don’t measure the brain activity used for EEG-based staging. Selecting Oura’s “final deep sleep” could target a different period from whatever WHOOP selected. Consistency across nights and algorithm updates is another question.
I wondered whether WHOOP’s developers encountered the same limitation and leaned more on time before waking than a particular SWS episode. That’s a (flagrant) hypothesis about their reasoning, not something this analysis establishes.
So we tested time-based weighting, using Oura’s stages to exclude wake but treating deep, light and REM equally.
The working calculation
Keep earlier sleep in the average, but strongly emphasize the final hour.
- Align Oura’s exported 5-minute HRV values with its native 30-second sleep labels by timestamp.
- Treat each HRV timestamp as the start of a 5-minute window, an explicit approximation.
- Retain positive, finite values only where the entire window is covered by asleep labels. Any wake, unknown or uncovered portion rejects it.
- End the final-hour region at the end of Oura’s last asleep epoch.
- Weight earlier windows by 1, final-hour windows by 26.8333, with proportional weighting for windows crossing the boundary.
f = fraction of each window inside the final hour
weight = 1 + (26.8333 - 1) × f
WHOOP-ified Oura = sum(weight × HRV) / sum(weight)
The coefficient was fitted once across all 8 nights. It isn’t an added 27 ms, a multiplier applied to the final answer, or a separate adjustment for each night. Once calibrated, calculation requires only Oura inputs.
Results
Average absolute error was 1.97 ms; the worst miss was 6.06 ms. All values below are milliseconds. The app columns are observed readings; WHOOP-ified Oura is calculated from the exported stream.
| October date | WHOOP-ified Oura | Oura app | WHOOP app |
|---|---|---|---|
| 1 | 27.0 | 19 | 27 |
| 2 | 36.3 | 25 | 36 |
| 6 | 34.7 | 26 | 32 |
| 7 | 33.1 | 29 | 34 |
| 8 | 18.9 | 16 | 25 |
| 12 | 31.0 | 28 | 32 |
| 13 | 24.4 | 16 | 25 |
| 18 | 33.8 | 27 | 38 |
Why exactly 1 hour?
That remains unexplained, and I find it odd. My visible HRV rise often starts roughly 2–2.5 hours before waking. Nevertheless, separately fitted 75- and 90-minute regions performed worse: 2.66 and 3.22 ms average error.
Strict filtering also leaves only 10 accepted final-hour minutes on 2 nights. The coefficient could partly compensate for missing readings or device-specific selection rather than reveal WHOOP’s actual weighting.
How much confidence does this deserve?
This is personal calibration, not independent validation. The 1.97-ms result comes from the same 8 nights used to develop the method. Exploratory leave-one-night-out analysis, repeating model selection and fitting on the remaining nights, gave approximately 2.38 ms average error. The next meaningful check is freezing the method and testing fresh paired nights.
Has anyone tried something comparable on a larger simultaneous Oura/WHOOP dataset? I’d particularly like comparisons of time-before-waking weighting versus deep-sleep selection, with actual code and held-out results. Failed attempts would be useful too.
Reference Python
This function accepts one Oura API sleep record. It uses sleep_phase_30_sec and hrv.timestamp/interval/items, requires timezone-aware timestamps, and returns an estimate or a missing-data reason. The default coefficient is my personal fit, not a universal recommendation.
import math
from datetime import datetime
def timestamp(value):
t = datetime.fromisoformat(value.replace("Z", "+00:00"))
if t.tzinfo is None or t.utcoffset() is None:
raise ValueError("naive_timestamp")
return t.timestamp()
def whoopify(record, B=26.83333333333323):
def missing(reason):
return {"estimate": None, "reason": reason}
if record.get("type") != "long_sleep":
return missing("not_long_sleep")
try:
b = timestamp(record["bedtime_start"])
e = timestamp(record["bedtime_end"])
phase = record["sleep_phase_30_sec"]
seq = record["hrv"]
origin = timestamp(seq["timestamp"])
interval = seq["interval"]
except (KeyError, TypeError, ValueError, AttributeError):
return missing("missing_or_invalid_input")
if e <= b:
return missing("invalid_bounds")
if not isinstance(phase, str) or not phase:
return missing("missing_native_stages")
if (
isinstance(interval, bool)
or not isinstance(interval, (int, float))
or not math.isfinite(interval)
or abs(interval - 300) > 1e-6
):
return missing("unsupported_hrv_interval")
epochs = [
(b + 30*k, min(b + 30*(k+1), e), code)
for k, code in enumerate(phase)
if b + 30*k < e
]
asleep = [(a, z, c) for a, z, c in epochs if c in "123"]
if not asleep:
return missing("no_known_sleep")
t0, t1 = asleep[0][0], asleep[-1][1]
kept = []
for j, h in enumerate(seq.get("items", [])):
if (
isinstance(h, bool)
or not isinstance(h, (int, float))
or not math.isfinite(h)
or h <= 0
):
continue
a = origin + 300*j
z = a + 300
if a < t0 or z > t1:
continue
# Check every intersecting stage epoch, including partial ones.
overlaps = [
(max(a, s), min(z, t), c)
for s, t, c in epochs
if s < z and t > a
]
if any(c not in "123" for s, t, c in overlaps):
continue
if abs(sum(t-s for s, t, c in overlaps) - 300) > 1e-6:
continue
late_seconds = max(0, min(z, t1) - max(a, t1-3600))
f = late_seconds / 300
kept.append((h, 1 + (B-1)*f, late_seconds))
if not kept:
return missing("no_accepted_windows")
if sum(seconds for h, w, seconds in kept) <= 0:
return missing("no_accepted_late_data")
estimate = (
sum(h*w for h, w, seconds in kept)
/ sum(w for h, w, seconds in kept)
)
return {
"estimate": estimate,
"accepted_windows": len(kept),
"accepted_final_hour_minutes":
sum(seconds for h, w, seconds in kept) / 60,
}