Source: docs/health.md
Heat, health and the night alarm
The tag's accelerometer already sits on every ear. These features count how active each cow is, learn her normal day, and flag the departures a rancher cares about. The rules are plain arithmetic with every threshold written down, so each alert can be explained. The thresholds are starting points. They need checking against what the rancher and vet actually see on this herd before anyone relies on them.
| Alert | What it means | What triggers it |
|---|---|---|
| Possible heat | She may be in standing heat; check her | Activity at least 2x her normal for that hour of the day, and at least 10 more active minutes than normal, in 4 or more of the last 12 hours |
| Moving less than usual | Possible lameness or illness; check her | The last 24 hours add up to 60% or less of her normal day, with data for at least 75% of those hours |
| Herd ran at night | Possible predator, loose dog or theft; check the pasture | 3 or more tags (half the herd for herds under 6) report sustained running within 10 minutes, during night hours (21:00–06:00 by default) |
No heat or low-activity alert fires until a cow has 5 days of history. One heat alert per cow per 7 days (cycles are about 21 days apart), one low-activity alert per 2 days, and one night alarm per 30 minutes.
The code is in src/herdtag/server/health.py. The thresholds are constants at the top of that file.
How the data gets there
The tag adds up "seconds active" in 15-minute buckets. Every hour it sends an activity report on FPort 3, separate from the position check-in on FPort 1. It's 11 bytes, so it fits US915's longest-range data rate:
| Byte | Field | Meaning |
|---|---|---|
| 0 | newest |
Number of the newest finished bucket (counts up, wraps at 256) |
| 1 | flags |
bit 0 running: sustained running since the last report. bit 1 reboot: first report since power-up |
| 2 | age |
Minutes since the newest bucket finished (max 254) |
| 3–10 | b[0..7] |
Active time per bucket, oldest first, in 4-second units (0–225 = 0–15 min). 255 = no data |
Each report covers the last 2 hours, so reports overlap. If one is lost, the next one still carries those buckets. The server stores each bucket once, matching on bucket number and time.
Encoding is in src/herdtag/protocol.py (ActivityReport, ActivityAccumulator). The C version is in firmware/core (ht_activity_*). The two are checked against each other by the golden vectors (make -C firmware/core test).
Running means the tag saw hard, sustained movement (30 s in the firmware). The tag reports it right away at any hour, at most once every 5 minutes, because it doesn't know the farm's time zone. The server decides whether it's night.
How a cow's normal is learned
- Buckets are summed into hours.
- For each hour of the day (2 am, 3 am, and so on), her normal is the median of that hour over the last 14 days. The most recent 24 hours are left out, so today is compared with her usual days, not with itself.
- Medians ignore a one-off busy or quiet day. A heat episode a week ago doesn't move her normal much.
- Ratios use her normal for that hour, with a floor of 1 minute so a quiet hour doesn't make the ratio explode.
Where to see it
- Farmer page: a Health section with plain cards. Examples: "tag-04 may be in heat: about 2.3x her normal activity in 6 of the last 12 hours", "tag-03 is moving less than usual: about 63% less than usual over the last day", "Herd ran at 2:09 am". Below the cards, a small chart per cow of the last 24 hours against her normal.
- Ops page: a heatmap, cow by hour, coloured from "under 0.6x" to "2x or more" against each cow's normal. Heat shows as a warm run, lameness as a cold row, and a night bolt as one warm column across several cows.
API:
| Method | Path | Returns |
|---|---|---|
| GET | /api/health |
Per cow: baseline ready, days of history, last-24 h minutes vs. normal, ratio, heat hours, the last 24 hourly values, and active alerts. Plus the latest night alarm and the thresholds in use |
| GET | /api/health/heatmap?hours=48 |
Hour timestamps and, per cow, ratio to normal and active minutes for each hour |
| GET | /api/health/{dev_id}/activity?days=14 |
Raw buckets and her normal by hour of day |
Settings (environment variables): HERDTAG_TZ (the farm's time zone, e.g. America/Chicago; default is the server's), HERDTAG_NIGHT_HOURS (default 21-6), HERDTAG_HEALTH_MIN_DAYS (default 5).
Try it
uv run herdtag-server
uv run python scripts/seed_activity.py --night-scare # 14 days of history: two cows in heat, one lame, a 2 am bolt
uv run herdtag demo --speed 600 --night-scare # live virtual herd with activity reports
Demo options:
--heat-every DAYS: a third of the herd cycles into heat (default every 21 simulated days).--lame-day DAY: tag-03 goes lame from that simulated day (default 7).--night-scare: everyone bolts at the first simulated 2 am.
The demo's activity reports carry a virtual clock that runs at demo speed, so a simulated day shows up as a day on the health pages.
Limits
- An ear isn't a leg or a neck. Commercial heat detectors mostly sit on the neck or leg. Head movement while grazing, and ear flicks at flies, add activity that isn't walking. The thresholds have to be tuned on this herd against what people see, or against heat-detection patches.
- The firmware thresholds are placeholders. "Active" is a change in acceleration above 0.08 g between 2-second samples; "running" is above 0.6 g for 30 s. Both need tuning on a real cow. Neither has run on hardware.
- Battery. Sampling every 2 s wakes the chip often, and an hourly 11-byte report adds about 24 uplinks a day. Both need measuring with the power profiler. The planned fix is the accelerometer's own activity interrupt and FIFO, so the main chip sleeps.
- Not a diagnosis. Alerts mean "go and look", not "she's lame" or "breed her now".
- Not built yet:
- "Off on her own" (distance from the herd, from positions) would help catch calving and illness.
- A temperature sensor would help with fever and heat stress (v2 hardware).
- Patents. Activity is only reported. It never decides when GPS runs (
use_motion_gatestays off), because motion-gated GPS is the subject of the patent flagged for review.