HerdTag

Source: docs/forage.md

Grass-aware rotation

Cycle grazing can move the herd when the grass says so, not just when the calendar does. Each paddock gets a greenness reading from satellite images. That becomes an estimate of grass on hand, then days of grazing left and whether a resting paddock has recovered.

Code: src/herdtag/server/forage.py. The rotation engine (rotation.py) asks it one question: is it time to move yet? Every safety rule in Cycle grazing still applies unchanged: the gate waits for every tag, stragglers hold the gate open, rest periods hold.

Where the numbers come from

Source Setting What it is
Simulated (default) HERDTAG_FORAGE=synthetic A deterministic grass model: seasonal growth, the herd eating it down while it's in a paddock (from the grazing log), regrowth after. Used for tests and demos. No network.
Sentinel-2 satellite HERDTAG_FORAGE=planetary Free Sentinel-2 L2A images from Microsoft Planetary Computer. No account or key. The paddock's mean NDVI (a greenness index) from the red and near-infrared bands, counting only pixels the scene classification marks as vegetation or bare soil. Clouds, shadows and water are dropped.

Sentinel-2 passes every few days; clouds hide some passes. A pass is skipped when less than half of the paddock is clear (min_valid_frac). Images from 2022 onward store reflectance with a +1000 offset, which the code removes before computing NDVI.

The satellite refreshes nightly in the server. POST /api/forage/refresh forces it; it takes roughly 2–3 seconds per pass per paddock the first time, then only new passes are fetched.

For a fast demo with simulated grass, HERDTAG_FORAGE_TIME_SCALE=1440 makes one grass day pass every real minute.

The model

  1. NDVI → standing grass (lb dry matter per acre) through a curve of points, linear between them:
NDVI 0.15 0.30 0.45 0.60 0.75 0.85
lb/acre 0 800 1,700 2,600 3,500 4,200

These are placeholders. NDVI tracks green leaf, not weight, and the relationship changes with grass species, season and how dormant the stand is. Calibrate before trusting it (below). 2. Grazeable grass = (standing − residual) × acres. The residual (default 1,000 lb/acre) is what you leave behind so the stand regrows. 3. Days left = grazeable ÷ (head × daily intake). Intake defaults to 30 lb a head a day, about 2.5% of a 1,200 lb cow including trampling. While the herd is in a paddock, what it has eaten since the last clear pass is subtracted, because the satellite hasn't seen it yet. 4. Ready = standing grass at or above 2,500 lb/acre. For a recovering paddock, a "ready about" date comes from the trend over the last 30 days. If grass isn't growing, there's no date. 5. Levels on the map: red = short (under residual + 500), amber = fair, green = ready.

Every number above is a setting: GET/PUT /api/forage/settings.

Calibrating

The fastest calibration is the rancher's eye. Stand in a paddock and judge "this would carry 150 head about 8 days":

POST /api/forage/{paddock_id}/calibrate   {"days": 8, "head": 150, "note": "judged on foot, 4 Nov"}

That rescales the paddock so today's estimate matches. Do it a few times across the season and paddocks; if the scales keep landing near the same number, adjust the NDVI curve in settings instead. Clipped-and-weighed samples are better still. DELETE /api/forage/{paddock_id}/calibrate resets a paddock.

Using it in a rotation

In the rotation builder, tick Move when grass is ready on a step. Its days become the longest stay. Per step:

For a grass step, the next paddock also has to be ready. If it isn't, the herd stays put and the farmer gets a rotation_grass_hold alert with the expected ready date. Move now overrides both grass checks once.

Grass maths needs the whole herd. If not every animal wears a tag, set Head in the herd on the rotation (head_count); otherwise the tag count is used.

API

Method Path Notes
GET /api/forage Per paddock: ndvi, date, cloud_free, dm_lb_acre, grazeable_lb, days_left, ready, ready_at, level, grazed_now, head, acres, source, calibration_scale
GET /api/forage/{id}/history Every observation with its dry-matter estimate
POST /api/forage/refresh Fetch now; returns observation counts and any per-paddock errors
POST /api/forage/{id}/calibrate {days, head?, note?}
DELETE /api/forage/{id}/calibrate Back to the uncalibrated curve
GET / PUT /api/forage/settings Curve, residual, ready level, intake, lookback, cloud limits

Limits