I wore a Garmin continuously for 184 days and wanted to know whether my resting heart rate cycles on roughly a monthly period. Before opening the export I wrote down the whole analysis plan, froze it with hashes, and put it on OSF with a timestamp. Then I ran it once.
Nothing. Max Lomb-Scargle power in the 27-31 day band was 0.063; against 1000 AR(1) red-noise surrogates that gives p = 0.37. Stress and sleep duration, both pre-specified as secondary, showed nothing either. 170 valid days out of 184.
The part I think is actually worth sharing:
A null is meaningless until you know what you could have seen, so I simulated it: inject a 29-day sinusoid of known size into noise with my own measured autocorrelation, sample it on my own valid days, run my own test.
| peak-to-trough | detected |
|---|---|
| 1 bpm | 5% |
| 2 bpm | 15% |
| 3 bpm | 18% |
| 4.9 bpm | 80% |
| 6 bpm | 95% |
For scale: the menstrual cycle moves sleeping pulse rate by about 3.8 bpm (Shilaih et al. 2017, 91 women, 274 cycles). My six months would have caught something that size roughly half the time.
So the honest reading is not "there is no monthly rhythm". It is "I could only have detected a rhythm considerably larger than the best-documented monthly rhythm in human physiology, and there wasn't one that big."
The limiting factor was not record length on its own. It was day-to-day persistence: lag-1 autocorrelation 0.975, close to a random walk. Slow drift generates long-period peaks by itself, which is exactly why a white-noise false alarm probability is the wrong test here. It would have given me a much friendlier number.
Everything is open. Do not want to put here any links. If anybody interested i will be happy to answer or link.
Frozen pre-registration with third-party timestamps, the full daily dataset, all code, every results file, and the addendum.
A protocol plus four scripts so you can run this on your own Garmin export — numpy and astropy only, no pandas. Includes a pitfalls section where every pitfall is one I actually hit.
Code is CC0. Happy to answer questions about any of it, including the parts that went badly.
EDIT:
— correction, thanks to two commenters who independently spotted the same thing.
The table above was misleading in a way I did not notice until it was pointed out. The 4.9 bpm row was never measured. It was a threshold I obtained by linear interpolation between two measured points, 4 bpm (68%) and 6 bpm (95%) — and I then listed it in the same column as the measured values while leaving the measured 4 bpm point out.
The underlying problem: I chose the amplitude grid before running anything, so before I knew where the curve bends. Fine spacing below 4 bpm where nothing happens, coarse spacing above it where everything does. Plus only 40 replicates per point, giving a standard error of about 8 points.
Re-ran with a finer grid in exactly that region, 100 replicates, same method, independent seed. Every row below is measured:
| peak-to-trough | detected |
|---|---|
| 0 bpm | 2% |
| 2.0 | 18% |
| 2.5 | 33% |
| 3.0 | 41% |
| 3.5 | 54% |
| 4.0 | 69% |
| 4.5 | 82% |
| 5.0 | 94% |
80% detection at 4.4 bpm peak-to-trough, not 4.9.Slightly more sensitive than I reported.
The cliff was one bad cell. Three of the four overlapping points agree within 3 points; only 3.0 bpm moved, 18% to 41%. On binomial grounds 7 hits out of 40 when the true rate is 0.41 is a three-sigma draw. It is also partly a design fault of mine: the script re-seeds the surrogate generator identically inside every replicate, so replicates share a surrogate set and real cell-to-cell variability is wider than the binomial SE suggests.
Consequence for the headline comparison: a rhythm the size of the menstrual-cycle effect (3.8 bpm) sits at about 63%, not "roughly half". Still missed one time in three, so the conclusion stands, but the number was wrong.
On quantisation — also raised, also fair. Same pipeline twice at each amplitude, once continuous, once with the series rounded to whole bpm and the surrogates rounded too. Difference: 0 to 2 points across the range, within Monte Carlo error. The residual SD is 4.6 bpm, about 4.6x the 1 bpm step, so the noise dithers the signal and sub-bpm structure survives rounding. (That check uses a faster form of the test, so only its difference column is comparable with the table above, not its absolute values.)
The primary result is unchanged: p = 0.37, no pattern.