How to calculate growing degree days from gridded weather

On this page

Problem statement

Growing degree days from a point are a few lines of arithmetic. From a grid they are the same arithmetic plus four things that go wrong quietly: temperatures in Kelvin, a calendar that pandas cannot index, an accumulation that must not skip missing days, and an output that nobody can reproduce because the base, the cap and the method were not recorded.

The parameters matter as much as the data. On a real 2025 daily series for the Dutch polders, base 10 with a 30 °C cap and clipped minima gave 1,264 °Cd while base 10 with the simple average and no cap gave 1,146 — and the date on which 1,000 °Cd was reached differed by fifteen days.

Quick answer

import xarray as xr, numpy as np

def gdd_grid(ds, tmax="tasmax", tmin="tasmin", base=10.0, upper=30.0,
             method="clipped"):
    tx, tn = ds[tmax], ds[tmin]
    if float(tx.max()) > 200:                        # Kelvin
        tx, tn = tx - 273.15, tn - 273.15

    if method == "clipped":
        daily = (np.minimum(tx, upper) + np.maximum(tn, base)) / 2 - base
    else:
        daily = (tx + tn) / 2 - base
    daily = daily.clip(min=0)

    out = daily.cumsum("time")
    out.attrs = {"long_name": "cumulative growing degree days",
                 "units": "degree_Celsius day", "base_c": base, "upper_c": upper,
                 "method": method,
                 "accumulation_start": str(np.datetime_as_string(ds.time.values[0], "D"))}
    return out

The Kelvin check is not defensive programming. A GDD computed from Kelvin with a base of 10 accumulates roughly 263 degree days per day and reaches every threshold on the first date.

From a gridded reanalysis to a grid of threshold dates. checks, then arithmeticdaily max andminnot meanunit andcalendar…Kelvin, 360-daythe formulabase, cap, methodaccumulatefrom a stated startthresholddatesthe productThe arithmetic is one line; the checks are the work spatialworkflow.io
Four checks before the arithmetic; the arithmetic itself is one line.

What you get — from The data used on this page

{'lat': 5, 'lon': 7, 'time': 304} 0.3 to 36.0 °C daily maximum

Daily extremes, units, calendars and gaps

1. Get daily extremes, not hourly means

GDD needs the daily maximum and minimum. From hourly data, resample with max and min — not mean, which loses the range the formula depends on.

2. Check the units

Reanalysis temperatures are usually Kelvin. Convert once, at the top, and assert the range afterwards.

3. Check the calendar

Climate model output often uses a 360-day or a no-leap calendar, which pandas cannot index. xr.decode_cf(use_cftime=True) gives cftime objects that xarray can group and accumulate; converting to a standard calendar loses or invents days.

4. Handle missing days explicitly

cumsum treats NaN as NaN and poisons everything after it, or, with skipna=True, silently treats a missing day as zero. Neither is right without a decision — interpolate short gaps, and flag long ones.

5. Accumulate from a stated start

1 January, the planting date, or emergence. Whichever it is, it belongs in the attributes, because a total without it is not comparable.

6. Extract the threshold dates, not just the total

The useful product is the date each stage's requirement was met, per grid cell. That is a searchsorted along the time axis.

7. Write the parameters into the output

Base, cap, method and start date, in the variable attributes and in the file name. gdd.nc is not a reproducible artefact.

The checks a gridded degree day calculation needs before the arithmetic. five silent failurestemperatures in Celsius, not Kelvin263 °Cd per day otherwisetmin does not exceed tmaxcalendar is standard, or cftime is usedmissing days handled explicitlycumsum poisons or zeroesresampled with max and minmean loses the rangetrusting the defaultsall five fail quietlyEvery one produces plausible numbers spatialworkflow.io
Five checks; four of them fail silently and produce plausible numbers.

From a point series to a gridded season

The data used on this page

Example 1 fetches its own point series from the Open-Meteo historical weather API for 52.49° N, 5.60° E in the Flevoland polders. The gridded examples use ds: daily maximum and minimum 2 m temperature from the same archive for a 5 × 7 lattice at 0.1° spacing over 52.3–52.7° N, 5.3–5.9° E, 1 January to 31 October 2025, in °C, assembled by the setup into an xarray Dataset with tasmax and tasmin.

import os

import numpy as np
import pandas as pd
import requests
import xarray as xr

if not os.path.exists("flevoland_daily_2025.nc"):
    lats, lons = np.round(np.arange(52.3, 52.75, 0.1), 2), np.round(np.arange(5.3, 5.95, 0.1), 2)
    pts = [(la, lo) for la in lats for lo in lons]
    reply = requests.get("https://archive-api.open-meteo.com/v1/archive", params={
        "latitude": ",".join(str(p[0]) for p in pts), "longitude": ",".join(str(p[1]) for p in pts),
        "start_date": "2025-01-01", "end_date": "2025-10-31",
        "daily": "temperature_2m_max,temperature_2m_min", "timezone": "UTC"}, timeout=300).json()
    time = pd.to_datetime(reply[0]["daily"]["time"])
    tx = np.array([r["daily"]["temperature_2m_max"] for r in reply], dtype=float).reshape(len(lats), len(lons), -1)
    tn = np.array([r["daily"]["temperature_2m_min"] for r in reply], dtype=float).reshape(len(lats), len(lons), -1)
    xr.Dataset({"tasmax": (("lat", "lon", "time"), tx), "tasmin": (("lat", "lon", "time"), tn)},
               coords={"lat": lats, "lon": lons, "time": time}).to_netcdf("flevoland_daily_2025.nc")
ds = xr.open_dataset("flevoland_daily_2025.nc")
print(dict(ds.sizes), f"{float(ds.tasmax.min()):.1f} to {float(ds.tasmax.max()):.1f} °C daily maximum")
{'lat': 5, 'lon': 7, 'time': 304} 0.3 to 36.0 °C daily maximum

Example 1 — from a point series, with the method comparison

import json, urllib.request, numpy as np, pandas as pd

def daily_weather(lat, lon, start, end):
    url = ("https://archive-api.open-meteo.com/v1/archive"
           f"?latitude={lat}&longitude={lon}&start_date={start}&end_date={end}"
           "&daily=temperature_2m_max,temperature_2m_min&timezone=UTC")
    with urllib.request.urlopen(url, timeout=120) as r:
        d = json.loads(r.read())["daily"]
    t = pd.DataFrame(d).rename(columns={"temperature_2m_max": "tmax",
                                        "temperature_2m_min": "tmin"})
    t["time"] = pd.to_datetime(t["time"])
    return t

def gdd(tmax, tmin, base=10.0, upper=30.0, method="clipped"):
    if method == "clipped":
        return np.maximum((np.minimum(tmax, upper) + np.maximum(tmin, base)) / 2 - base, 0)
    return np.maximum((tmax + tmin) / 2 - base, 0)

t = daily_weather(52.49, 5.60, "2025-01-01", "2025-10-31")
for base, upper, method, label in [
        (10, 30, "clipped", "maize, base 10, cap 30, clipped"),
        (10, 99, "simple",  "maize, base 10, no cap, simple"),
        (0,  99, "simple",  "wheat, base 0, simple"),
        (5,  25, "clipped", "potato, base 5, cap 25, clipped")]:
    cum = np.cumsum(gdd(t.tmax.values, t.tmin.values, base, upper, method))
    reached = (t.time[np.searchsorted(cum, 1000)].date()
               if cum[-1] >= 1000 else "not reached")
    print(f"{label:34} total {cum[-1]:7.0f} °Cd; 1000 °Cd on {reached}")
maize, base 10, cap 30, clipped    total    1264 °Cd; 1000 °Cd on 2025-08-30
maize, base 10, no cap, simple     total    1146 °Cd; 1000 °Cd on 2025-09-14
wheat, base 0, simple              total    3710 °Cd; 1000 °Cd on 2025-05-17
potato, base 5, cap 25, clipped    total    2347 °Cd; 1000 °Cd on 2025-07-01

Example 2 — the gridded version, with the checks

import xarray as xr, numpy as np

def prepare(ds, tmax="tasmax", tmin="tasmin"):
    problems = []
    tx, tn = ds[tmax], ds[tmin]

    if float(tx.max()) > 200:
        tx, tn = tx - 273.15, tn - 273.15
        problems.append("converted from Kelvin")
    if float(tx.min()) < -90 or float(tx.max()) > 60:
        problems.append(f"implausible range {float(tx.min()):.1f} to {float(tx.max()):.1f} °C")
    if bool((tn > tx).any()):
        problems.append(f"{int((tn > tx).sum())} cells where tmin exceeds tmax")

    cal = ds.time.encoding.get("calendar", "standard")
    if cal not in ("standard", "gregorian", "proleptic_gregorian"):
        problems.append(f"non-standard calendar: {cal}")

    expected = int((ds.time.values[-1] - ds.time.values[0]) /
                   np.timedelta64(1, "D")) + 1 if cal in ("standard", "gregorian") else None
    if expected and len(ds.time) != expected:
        problems.append(f"{expected - len(ds.time)} missing days")

    return tx, tn, problems

tx, tn, problems = prepare(ds)
for p in problems:
    print("!", p)
print(f"{len(problems)} problems found")
0 problems found

Example 3 — threshold dates per grid cell

import xarray as xr, numpy as np

def threshold_date(cum, threshold):
    """First date at which the cumulative GDD reaches a threshold, per cell."""
    reached = cum >= threshold
    idx = reached.argmax("time")
    ever = reached.any("time")
    times = cum["time"].values
    out = xr.DataArray(
        np.where(ever, times[np.clip(idx.values, 0, len(times) - 1)],
                 np.datetime64("NaT")),
        dims=idx.dims, coords=idx.coords, name=f"date_at_{threshold:g}_gdd")
    out.attrs = {"threshold_gdd": threshold, **{k: v for k, v in cum.attrs.items()
                                                if k in ("base_c", "upper_c", "method",
                                                         "accumulation_start")}}
    return out

cum = gdd_grid(ds, base=10, upper=30, method="clipped")
for thr in (500, 1000, 1500):
    d = threshold_date(cum, thr)
    frac = float((~np.isnat(d)).mean())
    print(f"{thr} °Cd reached in {frac:.1%} of cells; "
          f"median date {np.datetime_as_string(np.nanmedian(d.values.astype('datetime64[D]').astype(float)).astype('datetime64[D]'), 'D') if frac else 'n/a'}")
500 °Cd reached in 100.0% of cells; median date 2025-07-01
1000 °Cd reached in 100.0% of cells; median date 2025-08-30
1500 °Cd reached in 0.0% of cells; median date n/a

Carrying the base, cap, method and start date into the threshold-date variable's attributes is what makes the output usable by somebody who did not run the code.

Why mean is the wrong resampler

Why mean is the wrong resampler

The GDD formulae use the daily maximum and minimum separately, because the clipped method raises the minimum to the base before averaging. Resampling hourly data with mean gives the daily mean, from which the clipped method cannot be computed at all and the simple method gives a different answer from one computed on the true extremes.

Why reanalysis grids compress the daily range

A grid cell is a spatial average, and the maximum of an average is smaller than the average of the maxima. A reanalysis therefore reports a lower daily maximum and a higher daily minimum than a station in the same cell. That matters most for the clipped method, where the minimum's position relative to the base determines the result.

Why cumsum needs a decision about missing days

cumsum with skipna=False propagates a single NaN to every later day; with skipna=True it treats the missing day as contributing zero, which silently under-accumulates. Neither is a default anyone should accept. Interpolating gaps of one or two days and flagging longer ones makes the choice explicit and recorded.

Why the threshold date is the product

A season total is a summary of the weather. What is acted on is the date a stage was reached — when to scout, when to apply, when to expect harvest. Producing a grid of dates rather than a grid of totals also makes the method dependence visible, because two methods that differ by 10% in total differ by fifteen days in the date.

A grid of season totals against a grid of threshold dates. what to outputseason total✓a summary of the weather✓hides the method dependence✓not directly actionable✓one number per cellthreshold dates✓when to scout, spray, harvest✓makes the method visible✓what an agronomist uses✓one date per stage per cellTwo methods 10% apart in total were fifteen days apart in the date spatialworkflow.io
The date is what gets acted on, and it exposes the method dependence a total hides.

Kelvin, swapped extremes and leap years

  • Kelvin. Check and convert before anything else.
  • tmin > tmax happens in some products and indicates a bad cell.
  • 360-day calendars need cftime; do not convert them away.
  • Leap years shift day-of-year comparisons by one after February.
  • Soil temperature predicts emergence better than air temperature.
  • Chunk along time for a cumulative sum; chunking along space makes it slow.
  • Accumulation start is part of the definition. Put it in the attributes.
  • Name the file with the parameters. gdd_base10_cap30_clipped_2025.nc.

FAQ

How do I compute growing degree days from a gridded dataset?

Get daily maxima and minima, convert from Kelvin if needed, apply the chosen formula, clip at zero and accumulate along time — recording the base, cap, method and start date in the attributes.

Can I resample hourly data with mean?

No. The formulae use the daily maximum and minimum separately; resample with max and min.

Why are my degree days enormous?

Almost certainly Kelvin. With a base of 10, Kelvin temperatures accumulate about 263 degree days per day.

How do I handle missing days?

Decide explicitly: interpolate short gaps and flag long ones. cumsum either poisons everything after a NaN or silently treats it as zero.

Does the method really change the answer?

Yes. Base 10 with a cap and clipping gave 1,264 °Cd on a real series against 1,146 with the simple average — and the 1,000 °Cd date differed by fifteen days.

What should I output?

A grid of threshold dates rather than a grid of totals, with the base, cap, method and accumulation start in the attributes.