I am writing this with a fever. Sore throat and a cough started on 6 November, the fever arrived on the 8th, and it is still here on the 9th.

On the second night of it I got curious and asked a question I had never asked my own health data: did you see this coming, and if so, when?

It did. By about two days. Nothing was watching.

What the numbers actually did

Here are the seven nights around onset. z is standard deviations from my own rolling baseline, and for HRV and SpO2 I have flipped the sign so that positive always means "worse".

-2 -1 0 +1 +2 +3 alarm threshold symptoms start fever 3 Nov 4 Nov 5 Nov 6 Nov 7 Nov 8 Nov 9 Nov Respiratory rate, 3 Nov: z = -0.03 Respiratory rate, 4 Nov: z = +2.38 Respiratory rate, 5 Nov: z = +1.77 Respiratory rate, 6 Nov: z = +0.09 Respiratory rate, 7 Nov: z = -0.20 Respiratory rate, 8 Nov: z = +2.14 Respiratory rate, 9 Nov: z = +2.32 Respiratory rate Resting HR, 3 Nov: z = +1.26 Resting HR, 4 Nov: z = +1.19 Resting HR, 5 Nov: z = +1.09 Resting HR, 6 Nov: z = +0.05 Resting HR, 7 Nov: z = +1.64 Resting HR, 8 Nov: z = +3.37 Resting HR, 9 Nov: z = +2.08 Resting HR HRV (suppressed), 3 Nov: z = -0.51 HRV (suppressed), 4 Nov: z = +0.43 HRV (suppressed), 5 Nov: z = +1.62 HRV (suppressed), 6 Nov: z = -1.55 HRV (suppressed), 7 Nov: z = -0.05 HRV (suppressed), 8 Nov: z = +2.54 HRV (suppressed), 9 Nov: z = +1.37 HRV (suppressed) standard deviations from my own rolling 21-night baseline; positive is always worse

The same nights as numbers:

night    resting HR   HRV    resp rate   skin temp   sleep   z_resp   z_rhr   z_hrv
         bpm          ms     breaths/m   °C dev      h
-------------------------------------------------------------------------------------
Nov 03   55           51.7   15.6        -0.46       7.65     -0.03    1.26   -0.51
Nov 04   55           41.7   17.2        +0.28       7.98     +2.38    1.19    0.43
Nov 05   55           28.4   17.0        -0.34       7.48     +1.77    1.10    1.62
Nov 06   52           63.9   15.8        -0.88       7.32     +0.09    0.05   -1.55   <- symptoms start
Nov 07   57           48.2   15.6        +0.14       6.32     -0.20    1.64   -0.05
Nov 08   63           18.4   17.4        +0.89       4.88     +2.14    3.37    2.54   <- fever
Nov 09   61           28.4   17.8        -0.65       6.18     +2.32    2.09    1.37

Respiratory rate is the only thing that moved early. It went to +2.4 SD on 4 November and stayed at +1.8 SD on the 5th. Symptoms turned up on the 6th. That is two nights of warning from a single channel.

Resting heart rate and HRV did nothing useful in that window. They broke out on the night of the 8th, resting HR at +3.4 SD and HRV collapsed to 18.4 ms, which is the fever night. By then I did not need a wearable to tell me I was ill. A signal that fires once you already feel terrible is not an early warning, it is a thermometer.

Two more things that did not work, and I want them on the record because the failures are more instructive than the hit:

Skin temperature inverted. Fitbit's temperature deviation read +0.89 °C on the fever night, which looks like exactly the signal you would want. The very next night, still feverish, it read −0.65. One night positive, one night negative, same illness. I deliberately left it out of the alarm rule.

Activity was worse than useless. On 6 November, the day the sore throat started, I walked 12,555 steps and the activity score came back 100 out of 100. Today, flat in bed, it is 427 steps and a score of 18. The activity data describes what I did, not what was happening to me. On day zero, what I did was go about my life.

The rule, and why I am not calling it a model

The rule I ended up with is embarrassingly simple:

respiratory-rate z ≥ 1.2 on two consecutive scored nights

Across 127 scored nights it has fired exactly twice: 5 November, and today. The first was the day before symptoms. So on the single episode available, it is two for two.

And it is worth almost nothing as evidence, because I chose it knowing the answer.

This is the part where a post like this normally shows a ROC curve. I do not get one. I have one labelled illness. A threshold picked after looking at the episode it is supposed to catch is not a validated detector, it is a description of the episode wearing a detector's clothes. I would be fooling myself to call it anything else, and the honest thing to do with a rule like this is state the n and move on.

What I can say is that the looser versions are definitively bad. A single-night composite score ≥ 1.2, the kind of thing that sounds reasonable, fires on 13% of all nights. That is roughly one alarm every eight days, forever. Any alert I would actually keep has to be rarer than my illnesses, and most obvious variants are not.

The confound, and the variable I had all along

The broader "acute" rule in the same job has fired three times in those 127 nights. One is the fever. The other two are 30 June and 13 September.

Neither was an illness. 30 June was the night after a gig in Matera, an hour's drive each way, with a flight to Girona the next morning. 13 September was the night after a padel match and a gym session.

So the physiological signature of "I am fighting something" and the signature of "I thrashed myself yesterday" look close enough that a crude threshold cannot separate them. That is not a surprise in hindsight: elevated respiration, high resting HR and suppressed HRV are what recovery looks like too.

The missing variable was obvious and I already had it: the previous day's load. The calendar knows I had a concert. The padel table knows I played a match. So I wired all three sources in, and every scored night now carries what the day before it asked of me.

It separates the cases cleanly. 30 June reads Gig Dino. 13 September reads Gym 7; Padel match; sport 95min. All three fires in this illness read nothing at all: no gig, no training, no travel.

Two details were load-bearing. Walking had to be thrown out, because 721 of my roughly 1,100 workout rows are walking at a 24-minute average and that is me crossing Bari, not training. And the intensity field was no help whatsoever: it reads moderate or blank on everything, including a 95-minute match.

The thing I did not do is let it silence an alert. 64 of 127 nights follow a day with load on it, so "no load" is a coin flip, and three clean fires out of three could happen by chance one time in eight. That is a hint, not a filter. So the alert states it and leaves the judgement to me:

Day before: no gig, training or travel on record. Nothing to explain this away.

or, on the nights that would have fooled me:

Day before: Gym 7; Padel match; sport 95min. Recovery from that looks like this too, so weigh the alert down.

Which is the whole trick with a dataset this small. The covariate is not strong enough to make a decision for me, but it is strong enough to stop me having to reconstruct last Tuesday from memory while running a temperature.

What I actually built

Not a predictor. A daily job that:

  • scores last night against the rolling baseline and writes one row per night to a feature store
  • evaluates the two rules and stays completely silent unless one fires
  • sends a single message when one does, with a seven-day dedupe so one illness cannot generate seven alerts
  • annotates that message with yesterday's gigs, training and travel, so the "or you just had a big day" question is answered in the alert itself
  • and writes to a second table that did not exist before this week: an episode log, one row per illness, with onset date, symptoms, severity, and my best guess at exposure

That second table is the actual output of this whole exercise. It has exactly one row in it: this illness, onset 6 November, moderate, fever yes, suspected exposure a crowded indoor ceremony on the 4th, which is squarely in the incubation window.

One row is not a dataset. But it is the first one, and it exists so that the next time this happens I can ask whether the tripwire fired, instead of reconstructing the answer from scratch while running a temperature.

The bit worth generalising

The instinct, when you have years of health data and you get sick, is to go looking for the model. I went looking for the model. What I found was that I had four and a half months of the metrics that mattered, three weeks of baseline, and not a single label, and that the one genuinely useful signal was a channel I had never once looked at.

If you are sitting on wearable data and thinking about doing this: check which fields actually survived your device changes before you plan anything. Then start writing down when you feel ill, today, in whatever form you will keep up. The physiology is already being recorded for you. The labels are the part only you can produce, and without them the rest is just a nice-looking chart of a thing that already happened.

I will know whether any of this works the next time I catch something. Which, given that I apparently walk 12,000 steps on day one of an infection, will probably not take long.