Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b623369
Merge pull request #1 from OpenStrap/analytics-accuracy-fixes
abdulsaheel Jun 13, 2026
b1dae5d
Merge pull request #2 from OpenStrap/analytics-accuracy-fixes
abdulsaheel Jun 14, 2026
db19f4c
Merge branch 'analytics-accuracy-fixes'
abdulsaheel Jun 14, 2026
3383b87
docs: ALGORITHMS.md — add step counting (AN-2554 wrist pedometer over…
abdulsaheel Jun 14, 2026
faa298e
analytics: own the steps algorithm (move AN-2554 pedometer here from …
abdulsaheel Jun 14, 2026
b82d62b
feat: multi-period sleep (calcSleepPeriods) — naps as shorter sleeps
abdulsaheel Jun 15, 2026
e831271
Merge pull request #3 from OpenStrap/feat/naps-stepgoal-scaling
abdulsaheel Jun 15, 2026
c776802
fix: abstain from sleep detection without a resting-HR baseline (#4)
abdulsaheel Jun 16, 2026
61b3d71
docs: README — HRV is implemented now (R-R from V24); fix recovery/re…
abdulsaheel Jun 16, 2026
d87602f
feat: proven-method swaps (Tanaka HRmax, EWMA-ACWR, Task-Force HRV wi…
abdulsaheel Jun 16, 2026
29f0eea
docs: README — Tanaka maxHR, EWMA-ACWR (Williams 2017), Task-Force HR…
abdulsaheel Jun 16, 2026
17dc1fd
maxHR age-floor guard in calcBaselines; honest sleep-regularity relabel
abdulsaheel Jun 16, 2026
8448f98
Add CircaCP circadian module (cosinor + bounded change-point)
abdulsaheel Jun 18, 2026
3718080
circadian: robust wake (last sustained rise) + past-bathyphase cycle …
abdulsaheel Jun 19, 2026
5f4ccfc
circadian: consolidated main-sleep period + real WASO efficiency
abdulsaheel Jun 19, 2026
a2ac8e9
circadian: unified stageSleep (hypnogram + totals from one classifier)
abdulsaheel Jun 19, 2026
d23e068
feat(analytics): sleep/wake ensemble (wake.ts) — pluggable voters Col…
abdulsaheel Jun 19, 2026
1a8ecfe
staging: detect REM from RR autonomic axis (low RMSSD), not HR level
abdulsaheel Jun 20, 2026
74c9849
wake: HR-led 4-voter consensus so quiet wake fires the day-close
abdulsaheel Jun 20, 2026
32fd383
feat(spo2): RELATIVE blood-oxygen index from red/IR reflectance ratio
abdulsaheel Jun 20, 2026
05afc53
sleep: v1 Cole-Kripke per-minute stager + RR REM-tiebreaker; fractal …
abdulsaheel Jun 20, 2026
ee9ee22
feat(cycle): calcCycle — log-anchored menstrual cycle estimator
abdulsaheel Jun 20, 2026
ccf26e0
Merge pull request #5 from OpenStrap/feat/wake-trigger
abdulsaheel Jun 20, 2026
408ac3c
feat(analytics): wrist activity recognition + workout typing, plus re…
abdulsaheel Jun 21, 2026
a27e158
fix(sleep): window-relative HR-dip detection + absolute awake backstop
abdulsaheel Jun 22, 2026
c54be79
Merge pull request #6 from OpenStrap/fix/sleep-hr-dip-detection
abdulsaheel Jun 22, 2026
24938e3
feat: seed population/profile-prior baselines for new users
shoshiiiin Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion ALGORITHMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Every metric in this package is a **published, peer-reviewed algorithm** compute
| 24 | HRV stability | `calcHrvStability` | **CV** = SD/mean of nightly RMSSD over a window | HRV reliability/CV (Plews/Flatt) | RMSSD series → CV % (HIGH) |
| 25 | Irregular-beat screen | `calcIrregular` | **Poincaré** SD1/SD2 + ectopic-rejection fraction + pNN50 | Brennan et al., *IEEE TBME* 2001; AF-screening literature | RR stream → flag + SD1/SD2 (ESTIMATE) |
| 26 | Readiness (composite) | `calcReadinessIndex` | Transparent weighted blend: recovery·0.5 + sleep·0.2 + dip·0.15 + calm·0.15 | Composite (documented weights; abstains w/o HRV) | recovery/sleep/dip/arousal → 0–100 (ESTIMATE) |
| 27 | Steps | `calcSteps` / `pedometer` (`steps.ts`); runner `runStepsImu` (backend) | **AN-2554** wrist pedometer: dynamic-threshold peak pairs + 8-step confirm, ×gain | Analog Devices AN-2554 (2023); Zhao, *Analog Dialogue* 2010 | wrist IMU accel (R10+0x33, ~100 Hz) → steps (ESTIMATE) |
| – | Max HR helper | `resolveMaxHr` | measured session max → 220−age → observed → 190 | Fox 1971 (age fallback) | minutes/baseline/age → HRmax + source |

> `buildCoach` (deterministic plan) and `buildNotifications` (nudges) are rule engines over
Expand Down Expand Up @@ -234,6 +235,31 @@ the old heuristic readiness was retired.)
> from the session's minute window, not in this package — they're descriptive aggregates of
> the same inputs, not new physiology.

### 27. Steps — `calcSteps` / `pedometer` (`steps.ts`); runner `runStepsImu` (backend)
**Where:** the pure AN-2554 math (`pedometer`, `calcSteps`, calibration gain) lives here in the
analytics package like every other metric; the backend `steps_imu.ts` is a thin **runner** that
re-decodes the IMU frames from R2 (`frameAccel` in `decode.ts`), dedups + groups them per minute,
and feeds the signals to `calcSteps` — mirroring the HRV/resp runners.
**Why:** the WHOOP 4.0 exposes **no step counter** over Bluetooth (the official app computes
steps in the cloud from raw accelerometer + ML; even the most complete community client falls
back to phone steps on 4.0). So we derive them ourselves from the wrist accelerometer with
Analog Devices' **AN-2554** time-domain pedometer — ~97% accurate on steady wrist gait.
**How:** the high-rate IMU (~100 Hz) arrives on two live channels — **R10** (pkt 0x2B, 100
accel samples/axis) and the **0x33 IMU stream** (10 accel + 10 gyro samples/frame: X/Y/Z
contiguous from byte 24, frame-index @14, scale 1/4096 g) — re-decoded from the raw frames in
R2, **deduped by (ts, frame-index)** (upload windows overlap), assembled into a contiguous
per-minute signal. Per minute: `sum(|x|+|y|+|z|)` → 4-tap low-pass → centered **33-sample
window** max/min peak detection → a **dynamic threshold** (running mean of recent max/min
midpoints) with a **0.1 g sensitivity** dead-zone — a `max > thr+s/2` paired with a
`min < thr−s/2` is a *possible step*, and only after **8 consecutive** possible steps does it
start counting (the regularity gate that rejects waving/typing/handling — verified to read 0
at rest; lowering it re-introduces false positives). A per-device **calibration gain (×1.11)**
corrects the typical ~10 % wrist undercount, locked against a 100-step ground-truth walk (raw
90 → 100). ESTIMATE. **Steps only accrue while the strap is connected** (the IMU is live-only;
the historical 1 Hz record carries no usable IMU). Owned by the cron (hourly: today+yesterday;
nightly: 2 days), written to `daily.steps` *after* analytics so the IMU value is authoritative.
*(Implemented in the backend, not this package, but documented here as the algorithm of record.)*

---

## What we deliberately do **not** do
Expand All @@ -258,4 +284,6 @@ Banister 1991 · Morton/Fitz-Clarke/Banister, *J Appl Physiol* 1990 · Cole & Kr
et al., *Sports Med* 2013 · Baevsky & Berseneva 2008 · Mahalanobis 1936 · Mishra et al.,
*Nat Biomed Eng* 2020 · Smarr et al., *Sci Rep* 2020 · Radin et al., *Lancet Digit Health*
2020 · Fox 1971 · Uth, Sørensen, Overgaard & Pedersen, *Eur J Appl Physiol* 2004 · Foster,
*Med Sci Sports Exerc* 1998 · Brennan, Palaniswami & Kamen (Poincaré HRV), *IEEE TBME* 2001.
*Med Sci Sports Exerc* 1998 · Brennan, Palaniswami & Kamen (Poincaré HRV), *IEEE TBME* 2001 ·
Analog Devices, *AN-2554: Step Counting Using the ADXL367* 2023 · Zhao, "Full-Featured
Pedometer Design Realized with 3-Axis Digital Accelerometer," *Analog Dialogue* 2010.
32 changes: 19 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,15 @@ so it just doesn't.
|----------|------|--------------|
| `calcRestingHR` | `resting.ts` | 5th percentile of heart rate across your sleep window. Falls back to your quietest 30 minutes if there's no sleep yet. |
| `calcStrain` | `strain.ts` | Banister TRIMP over heart-rate reserve, `ratio·0.64·e^(1.92·ratio)` summed per minute, squashed onto a 0–21 scale. |
| `calcHrZones` | `zones.ts` | Minutes spent in five zones by percent of max HR. |
| `calcHrZones` | `zones.ts` | Minutes spent in five zones by % of max HR. (Karvonen %HRR is more individualized in theory, but with an age-predicted max it adds no real accuracy and empties light-day zones, so %HRmax is kept deliberately.) |
| `calcCalories` | `calories.ts` | Keytel (2005), the active-kcal-per-minute equation, summed. Different formula for men and women; averages the two if it doesn't know. |
| `calcSleep` | `sleep.ts` | Cole-Kripke scores each epoch awake or asleep from motion, then I nudge it with the overnight HR dip. Gives onset, wake, efficiency, and a beta stage estimate. |
| `calcSleepRegularity` | `regularity.ts` | The Sleep Regularity Index, 0–100, from how much your bed and wake times wander night to night. |
| `calcSleepRegularity` | `regularity.ts` | Sleep-timing regularity, 0–100, from how much your bed and wake times wander night to night (circular variance of onset/wake clock-times). Honest scope: this is *not* the Phillips epoch-agreement Sleep Regularity Index — we don't plumb minute-level sleep/wake state across days, so we don't claim that name. |
| `detectSessions` | `sessions.ts` | Finds workouts: sustained stretches above 40% heart-rate reserve, then classifies them roughly as cardio, strength, or a walk. |
| `calcHrRecovery` | `recovery.ts` | HRR60, how many beats your heart drops in the minute after a peak. A real fitness signal. |
| `calcLoad`, `calcFitnessTrend` | `trends.ts` | ACWR (last 7 days over last 28) for load, and regression slopes on resting HR and HRR for whether you're getting fitter. |
| `calcReadiness`, `calcAnomaly` | `readiness.ts` | Readiness from resting-HR deviation, sleep debt, and sleep quality. Plus a flag for "your resting HR has been up two days, are you getting sick?" |
| `timeDomainHrv`, `freqDomainHrv` | `hrv.ts` | HRV from the beat-to-beat R-R stream: RMSSD/SDNN/pNN50 and LF/HF (Lomb–Scargle, gated to the Task Force 1996 window minimums — HF ≥~60 s, LF ≥~250 s — so short windows don't report spectral noise), plus the Baevsky stress index. |
| `calcRecovery`, `calcHrRecovery` | `recovery.ts` | Recovery from nightly HRV — ln-RMSSD z-scored against your own baseline (Plews). Plus HRR60, the beats your heart drops in the minute after a peak. |
| `calcLoad`, `calcFitnessTrend` | `trends.ts` | EWMA acute:chronic workload ratio (Williams 2017 — 7/28-day exponentially-weighted, fixes the rolling-average coupling), and regression slopes on resting HR and HRR for whether you're getting fitter. |
| `calcReadinessIndex`, `calcAnomaly` | `readiness_index.ts`, `readiness.ts` | An HRV-led readiness composite: recovery blended with sleep, the nocturnal dip, and arousal (abstains until there's HRV). Plus a flag for "your resting HR has been up two days, are you getting sick?" |
| `calcBaselines` | `baselines.ts` | Rolling 30-day medians, the anchors everything else compares against. |
| `calcStress`, `classifyArousal` | `stress.ts` | Arousal from heart rate sitting above resting while you're not moving. If you're moving it's exercise, not stress, so it's gated out. |
| `calcNocturnalHeart` | `nocturnal.ts` | Your sleeping HR, its low point, how far it dipped from daytime, and a flag if it's running high. |
Expand All @@ -71,15 +72,20 @@ so it just doesn't.

A couple of things worth calling out so you don't go looking for them:

**There's no HRV in here, and there won't be.** WHOOP builds recovery on heart-rate
variability, the beat-to-beat timing. The band doesn't hand that to us over the wire, at
least not in any form I've been able to recover. So readiness here is built from resting
HR, sleep debt, and sleep quality instead, and it says so right on the label. The
`hrv.ts` file is empty on purpose, as a reminder of what we don't have.
**HRV is in now** — this section used to say it never would be. WHOOP builds recovery on
heart-rate variability, the beat-to-beat timing, and for a long time it looked like the
band never handed that over. It turns out the R-R intervals are sitting right there in the
1 Hz historical (V24) records; they just don't ride the live stream, so the
[backend](https://github.com/OpenStrap/backend) re-decodes them from the raw bytes off the
request path and feeds them in. `hrv.ts` does the time- and frequency-domain measures,
`recovery.ts` turns nightly ln-RMSSD into a recovery z-score, and readiness is now an
HRV-led composite. It's labelled beta because recovering the field from the bytes is
empirical — but it's the real beat-to-beat signal, the same substrate WHOOP uses.

**Max heart rate** falls back gracefully: a real measured peak from your workouts if I've
seen one, otherwise the highest I've observed, otherwise `220 − age`, otherwise 190. The
worse the source, the lower the confidence on anything that depends on it.
seen one, otherwise the highest I've observed, otherwise Tanaka `208 − 0.7·age` (more
accurate than the old `220 − age`), otherwise 190. The worse the source, the lower the
confidence on anything that depends on it.

## Tests

Expand All @@ -98,5 +104,5 @@ Write a function that takes minutes (or history) plus the baseline and profile,
returns a `Metric<YourThing>`. Keep it pure, no side effects. Derive the confidence from
coverage and completeness like the others do, and return `null` with `0` confidence when
you don't have the inputs. Put the name of the method you used in a comment so the next
person can check your work. And if your idea needs HRV or some signal the band doesn't
person can check your work. And if your idea needs a signal the band genuinely doesn't
expose, it doesn't belong here, that's the line.
3 changes: 2 additions & 1 deletion src/__tests__/_harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function min(
ts: number,
hr: number,
activity = 0,
opts: Partial<{ steps: number; wrist_on: boolean; hr_max: number }> = {}
opts: Partial<{ steps: number; wrist_on: boolean; hr_max: number; act_class: import('../types').ActivityClass }> = {}
) {
return {
ts,
Expand All @@ -41,5 +41,6 @@ export function min(
activity,
steps: opts.steps ?? 0,
wrist_on: opts.wrist_on ?? hr > 0,
...(opts.act_class ? { act_class: opts.act_class } : {}),
};
}
Loading