Garmin Enduro 3 recovery analysis: low-HRV night classification, offline dashboard, vault note
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-02 12:16:53 +01:00
src Link a known-cause night in the vault note to the context log 2026-11-02 12:16:53 +01:00
test Link a known-cause night in the vault note to the context log 2026-11-02 12:16:53 +01:00
.gitignore Classify low-HRV nights and build an offline recovery dashboard 2026-10-20 11:04:34 +02:00
ISA.md Add a population reference for his age and sex to the vault note 2026-10-23 12:47:33 +02:00
package.json Classify low-HRV nights and build an offline recovery dashboard 2026-10-20 11:04:34 +02:00
README.org Add a context log that explains low-HRV nights 2026-11-02 12:12:33 +01:00

garmin-health

Turns GarminDB's local copy of Garmin Connect data into a classified record of low-HRV nights (illness-like, stress-like, short or late sleep, unexplained), a set of quantified findings, and one offline HTML dashboard. Everything runs on this machine; no health data is sent anywhere and none of it is tracked by git.

Run

bun test                 # unit tests; the real-data tests skip when ~/HealthData is absent
bun src/main.ts          # writes ~/HealthData/analysis/{dashboard.html,findings.json,nights.json}
bun src/main.ts --data <dir> --out <dir>
bun src/main.ts --vault <dir>    # also writes <dir>/Garmin-Recovery-Live.md, reads <dir>/Health-Context-Log.md
bun src/main.ts --context <file> # a context log somewhere else

Open ~/HealthData/analysis/dashboard.html in a browser. It loads nothing from the network.

Daily refresh

Two systemd user units, kept outside this repo in ~/.config/systemd/user/:

  • garmindb.timer – daily at 11:30 (+ up to 10 min), Persistent=true so a missed run happens at the next boot.
  • garmindb.service – garmindb_cli.py --all --download --import --analyze --latest under flock, then ExecStartPost rebuilds the dashboard and, with --vault ~/Documents/dDocs/1-areas/my-health, the vault note Garmin-Recovery-Live.md (last 14 nights, a population reference for his age and sex, recent flags, current findings). Both are rebuilt only when the download succeeds. The job writes only that one note; the dated analysis note in the same folder is never touched.
  • The service then runs the health agent's daily summary (pi-durable-bun/src/health/daily.ts, a local LLM, see its README) and renders again. The agent writes ~/HealthData/analysis/summary.json; main.ts puts it at the top of the dashboard and the vault note, marked with its model and date, or as out of date when it covers an older night.

GarminDB's --latest imports files modified in the last 24 hours, not files missing from the database. The day after a full download every file qualifies and an import takes about 20 minutes; after that a run imports only new files.

systemctl --user list-timers garmindb.timer
journalctl --user -u garmindb.service -n 50

Dependencies

  • The summary comes from pi-durable-bun's health agent (see its README for the model server it needs). Without it the dashboard and note are still written, without a current summary.
  • Bun 1.4.0 (bun:sqlite; no npm packages). The unit calls it through the mise shim ~/.local/share/mise/shims/bun.
  • GarminDB, installed as a uv tool (~/.local/bin/garmindb_cli.py). Config in ~/.GarminDb/GarminConnectConfig.json, login token in ~/.GarminDb/garmin_tokens.json. Data lands in ~/HealthData.
  • Inputs read:

    • ~/HealthData/DBs/garmin.db: hrv, resting_hr, daily_summary, device_info (firmware history), attributes (sex and year of birth, for the population reference)
    • ~/HealthData/DBs/garmin_monitoring.db: monitoring_pulse_ox (timestamps are local time)
    • ~/HealthData/Sleep/sleep_YYYY-MM-DD.json: sleep window, stages, respiration, sleep HR, SpO2, skin temperature deviation, breathing variations. GarminDB downloads these fields but does not import them into its database.
    • <vault>/Health-Context-Log.md, optional: what he logged about a night (see Context log below).
  • Population reference values (src/norms.ts) are fixed for men aged 45-49 and come from four open-access papers, saved in the vault under 2-resources/files/20-health-and-fitness/23-wearable-norms/. The note warns when the Garmin profile leaves that group; then the table needs new values.
  • Device the numbers were proven on: Garmin Enduro 3, firmware 10.23 to 23.39, 2024-10-02 to 2026-10-19.

How a night is classified

Each metric is scored as a robust z (median and MAD) against the previous 28 nights, never including the night itself. A night is low-HRV when its HRV z is -1.5 or lower. Low-HRV nights are then labelled, in this order:

  1. Illness-like: two or more of resting or sleep HR up (z >= 1), respiration up (z >= 1), skin temperature up (>= +0.5 degC or z >= 1.5), SpO2 down (z <= -1), and one of them must be respiration or temperature.
  2. Stress-like: the previous day's average stress z >= 1.
  3. Short or late sleep: under 6 h, or bedtime z >= 1.5.
  4. Unexplained.

Low-HRV nights at most one night apart form an episode.

Overnight stress is deliberately not a classifier input: Garmin computes stress from HRV, so it would label nights by their own HRV. Daytime stress is also HR/HRV-derived and rises when ill, which is why the illness rule runs first.

Context log

The watch cannot know about a party or a late meal, and GarminDB does not import Garmin Connect's lifestyle logging. The vault note Health-Context-Log.md holds one line per evening, editable from the phone:

- 2026-10-31 alcohol: Halloween party, beers

The date is the evening the night starts; the entry applies to the night dated by the next morning. Tags: alcohol, late-meal, hot-room, travel, illness, stress, training, other, comma-separated; the note is optional. Other lines are ignored, and a dated line that cannot be read is listed as a warning in the live note.

The log only adds. Measured values, baselines and z-scores stay as Garmin recorded them; each night gains context and sensorLabel (the reading from the sensors alone). A low-HRV night with any tag except illness becomes known-cause and is left out of the episodes; an illness entry keeps the sensor reading.

Firmware breaks

A watch update can change how a metric is measured. Each firmware install date is a candidate break; if the median of the 14 nights after it differs from the 14 before by 3 robust SD or more, the metric's baseline restarts there and its trend uses only the data after the break. On this watch, firmware 13.31 (2025-02-20) moved sleep respiration from 11 to 15.5 br/min overnight.

Limits

  • Wrist HRV is about 10 % off a chest strap; only relative changes are used.
  • Wrist pulse oximetry reads low with movement or a loose strap. The SpO2 figures are a reason to ask about a sleep study, not a measurement of apnea.
  • A single illness-like night also fits alcohol, a late heavy meal or a hot bedroom. Multi-night episodes are the stronger evidence.
  • Mann-Kendall p-values on weekly medians are optimistic because weekly values are still autocorrelated.
  • Health Status from the GDPR export is not used yet.