Computing Growing Degree Days from Gridded Weather Data
One-sentence answer: take the area-weighted daily minimum and maximum temperature over the field, clamp each to the crop’s base and cap, average them, subtract the base, and accumulate from the planting date — then check the season total against the crop’s known range before believing any of it.
Context
Crops do not develop on a calendar; they develop on accumulated heat. That is why a canopy that looks thin on a fixed date means nothing until you know how much thermal time the field has had. Growing degree days are the cheapest, most reliable covariate in a farm platform — they need no authorisation, no sensor and no flight — and they turn every index map from a snapshot into a comparison against where the crop should be.
They are also easy to compute wrongly in ways nothing downstream notices, because the output is a smooth, monotonic curve whether or not it is right. This guide is the accumulation implementation under weather and agronomic data integration.
Prerequisites
Beyond the parent topic’s stack: a daily field-level minimum and maximum temperature series, produced by the zonal aggregation in zonal statistics of rainfall rasters over field boundaries, and a planting date per field-season.
Step-by-step
1. Aggregate temperature to the field — area-weighted, in Celsius.
2. Clamp and average with the crop’s base and cap.
3. Accumulate from planting.
4. Map to stages if you need them.
5. Validate the total.
import numpy as np
import pandas as pd
CROP_BASE_C = {"maize": 10.0, "soybean": 10.0, "wheat": 0.0, "canola": 0.0, "sunflower": 7.0}
CROP_CAP_C = {"maize": 30.0, "sunflower": 30.0} # crops without an entry are uncapped
# Approximate accumulated thermal time at each stage, Celsius degree-days from planting.
MAIZE_STAGES = [(0, "planting"), (120, "VE emergence"), (350, "V6"), (620, "V10"),
(1130, "VT tassel"), (1400, "R2 blister"), (1700, "R5 dent"),
(1900, "R6 physiological maturity")]
def daily_gdd(tmin_c: pd.Series, tmax_c: pd.Series, crop: str) -> pd.Series:
"""Modified average method: clamp to base and cap, average, subtract base, floor at zero."""
base = CROP_BASE_C[crop] # KeyError on an unknown crop, deliberately
cap = CROP_CAP_C.get(crop)
hi = tmax_c.clip(upper=cap) if cap is not None else tmax_c.copy()
hi = hi.clip(lower=base)
lo = tmin_c.clip(lower=base)
if cap is not None:
lo = lo.clip(upper=cap)
assert (hi >= lo).all(), "daily maximum below minimum — the columns are swapped"
return ((hi + lo) / 2.0 - base).clip(lower=0.0)
def accumulate(daily: pd.DataFrame, crop: str, planted_on) -> pd.DataFrame:
"""One row per day from planting, with daily and cumulative thermal time."""
d = daily.loc[daily["date"] >= pd.Timestamp(planted_on)].sort_values("date").copy()
assert not d.empty, f"no weather on or after the planting date {planted_on}"
assert d["date"].is_unique, "duplicate dates in the weather series"
gaps = d["date"].diff().dt.days.dropna()
assert (gaps == 1).all(), (
f"weather series has gaps of {sorted(set(gaps) - {1})} day(s) — "
"accumulated thermal time would silently understate the season")
d["gdd"] = daily_gdd(d["tmin_c"], d["tmax_c"], crop)
d["gdd_cum"] = d["gdd"].cumsum()
return d
def stage_at(gdd_cum: float, table=MAIZE_STAGES) -> str:
"""The last stage whose threshold has been passed."""
reached = [name for threshold, name in table if gdd_cum >= threshold]
return reached[-1] if reached else "before planting"
Inline verification — the season-total check that catches a wrong base temperature, which no per-day assertion can:
acc = accumulate(field_daily, crop="maize", planted_on="2026-05-04")
total = float(acc["gdd_cum"].iloc[-1])
print(f"{len(acc)} days, {total:.0f} GDD accumulated, stage {stage_at(total)}")
assert acc["gdd_cum"].is_monotonic_increasing, "cumulative thermal time decreased"
assert 1200 < total < 1900, (
f"season total {total:.0f} GDD is outside the plausible maize range — "
"check the base temperature (0 °C instead of 10 °C roughly doubles it), "
"the cap, and the accumulation start date")
Settings worth being deliberate about
| Setting | Default here | Why it matters |
|---|---|---|
| Base temperature | crop-specific, no default | 10 °C for maize and soybean, 0 °C for wheat and canola, 7 °C for sunflower. The wrong base shifts a season total by hundreds of degree-days and moves every stage-triggered decision |
| Upper cap | 30 °C for maize | Development does not accelerate above it, so accumulating uncapped credits the crop with progress a heatwave did not produce |
| Accumulation start | planting date | Starting on 1 January conflates fields planted three weeks apart and destroys any cross-field comparison |
| Method | modified average | Clamp both daily extremes into the base–cap band, then average. The simple average of raw extremes over-counts cold nights and under-counts capped days |
| Gap policy | fail on any gap | A cumulative sum over a series missing five days is simply lower, with no error anywhere |
| Stage table | calibrate per hybrid | Published thresholds vary by several hundred degree-days between short- and full-season hybrids |
The check that catches nearly everything is the season total. Individual daily values look reasonable under almost any mistake; a maize season landing at 3,400 degree-days can only mean the base was zero.
Gotchas and edge cases
- A default base temperature is the most damaging convenience here. Applying maize’s 10 °C to winter wheat cuts its accumulated total by well over a thousand degree-days, and applying wheat’s 0 °C to maize roughly doubles it. The
KeyErrorabove is intentional: an unknown crop should stop the pipeline, not silently pick a number.
-
Gaps in the weather series understate the total invisibly. A cumulative sum over a series missing five days is simply lower, with no error. Assert daily continuity before accumulating, as above.
-
Fahrenheit degree-days are not Celsius degree-days. Published stage thresholds exist in both, and the conversion is not a simple scale — a Fahrenheit accumulation with a 50 °F base is 1.8 times the Celsius figure. Carry the unit in the column name if there is any chance of both appearing.
-
Replanting restarts the clock. A field replanted after hail has a second planting date, and its accumulation must start again. Keep the abandoned accumulation rather than overwriting it; the first stand’s history explains the second stand’s conditions.
-
Southern-hemisphere seasons cross the calendar year. Any code that filters by year rather than by the field-season’s own start and end dates truncates half the season at 31 December.
-
Stage tables are cultivar-specific. The maize table above is a reasonable mid-season hybrid. A short-season hybrid reaches maturity several hundred degree-days earlier, so treat the thresholds as a starting point to be calibrated against observed staging, not as constants.
-
Daily extremes from hourly data are not free. Deriving a daily minimum and maximum by resampling hourly temperature depends on which day boundary is used and in which timezone. A UTC day boundary on a farm eight hours west splits the overnight minimum across two days, which lowers both. Aggregate to daily in the farm’s local time, and assert that each day has a full complement of hours before taking extremes from it.
This guide is part of Weather & Agronomic Data Integration for Field Models — see there for aggregation, water balance and how these covariates are stored.
Related
- Zonal Statistics of Rainfall Rasters over Field Boundaries — producing the daily field series this consumes
- Vegetation Index Selection for Crop Stages — the decision accumulated thermal time actually drives
- Temporal Aggregation of Vegetation Indices — comparing index series on thermal time rather than on calendar dates