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.

How the base temperature and cap shape a day's contribution Three days shown as vertical temperature ranges against a base line at ten degrees and a cap line at thirty degrees for maize. A cool day spanning six to fourteen degrees contributes two degree-days after the minimum is raised to the base. A warm day spanning twelve to twenty-six contributes nine. A heatwave day spanning twenty to thirty-eight is capped at thirty and contributes fifteen rather than nineteen. 0 °C 10 °C 20 °C 30 °C 38 °C base 10 °C cap 30 °C cool day 6–14 °C → 2 GDD warm day 12–26 °C → 9 GDD heatwave day 20–38 °C → 15 GDD, not 19 Shaded green is the part of the day's range that counts; amber above the cap is discarded.

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.

Maize growth stages on the clock the crop actually runs on A timeline of accumulated thermal time from planting for maize, marking emergence at about 120 degree-days, six leaves at 350, rapid growth at 620, tasselling at 1,130 and dent at 1,700. The axis is accumulated degree-days rather than calendar days. VE emergence 120 V6 six leaves 350 V10 rapid growth 620 VT tassel 1130 R5 dent 1700 Accumulated degree-days from planting, base 10 °C capped at 30 °C Thresholds are cultivar-specific: a short-season hybrid reaches each stage several hundred degree-days earlier, so calibrate against observed staging.

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.

PYTHON
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:

PYTHON
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 KeyError above is intentional: an unknown crop should stop the pipeline, not silently pick a number.
Base temperatures, caps, and the totals that prove them A table of four crops with the base temperature and upper cap each uses for thermal time, and the plausible range of accumulated degree-days for a season. The season total is the check that catches a wrong base temperature, which no per-day assertion would notice. Crop Base and cap Plausible season total Maize development stalls above 30 °C base 10 °C, cap 30 °C 1,200–1,900 °C·d Soybean similar thermal threshold base 10 °C, uncapped 1,000–1,700 °C·d Winter wheat grows through cool weather base 0 °C, uncapped 1,400–2,600 °C·d Sunflower intermediate threshold base 7 °C, cap 30 °C 1,300–2,000 °C·d
  • 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.