agri.growing-degree-days
Growing degree days (thermal time) from daily min/max temperatures, a base and an optional upper cap.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 18 tests, run in TypeScript, Python and Rust.
What it does
Growing degree days (GDD, heat units, thermal time) from daily minimum and maximum temperatures: how much warmth above a base temperature a crop, pest or pasture has had. `growingDegreeDays([{min: 10, max: 20}], 10, null, "average")` is 5.
## The two methods
For example
growing_degree_days(days ×1, 10, —, average)→ total 5, daily 5 one day 10 to 20 °C over base 10 is 5 degree daysgrowing_degree_days(days ×1, 10, 30, average)→ total 7.5, daily 7.5 maize 10/30: averaging caps the hot max but keeps the cool mingrowing_degree_days(days ×1, 10, 30, clamped)→ total 10, daily 10 maize 10/30: clamping raises the cool min to the base as well
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def growing_degree_days(days: Sequence[DailyTemperature], base_celsius: float, upper_celsius: Optional[float], method: DegreeDayMethod) -> DegreeDays
| days | DailyTemperature[] | one reading per day, in order; may be empty |
| base_celsius | float | no development below this, e.g. 10 for maize, 0 for wheat thermal time; to 0.1 °C |
| upper_celsius | float? | no extra development above this (30 for maize); null for no cap |
| method | DegreeDayMethod | average: max(0, mean - base); clamped: min and max held within base..upper first |
| returns | DegreeDays |
The types it declares, generated into your project
@dataclass(frozen=True)
class DailyTemperature:
"""One day's extremes in °C, taken to 0.1 °C."""
min: float
max: float
DegreeDayMethod = Literal["average", "clamped"]
@dataclass(frozen=True)
class DegreeDays:
"""The total, and each day's contribution."""
#: sum of the exact daily values, then rounded half up to 0.1
total: float
#: each day exactly, a multiple of 0.05
daily: List[float]
Your code names it in one line, in the file that uses it
from fune.agri.growing_degree_days import growing_degree_days # agri.growing-degree-days@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import math
from typing import List, Optional, Sequence
from .agri_growing_degree_days_types import DailyTemperature, DegreeDayMethod, DegreeDays
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .math_round_float import round_float ← from math.round-float ^1.0.0 · built alongside by fune
def _tenths(value: float, message: str) -> int:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < -100 or value > 100:
raise ValueError(message)
return int(round(round_float(float(value), 1) * 10))
def growing_degree_days(
days: Sequence[DailyTemperature],
base_celsius: float,
upper_celsius: Optional[float],
method: DegreeDayMethod,
) -> DegreeDays:
"""Growing degree days, McMaster and Wilhelm (1997), in tenths of a degree.
Each day is (max + min) / 2 - base with the temperatures taken to 0.1 C,
so it is an exact multiple of 0.05 and the total is summed exactly and
rounded once. Rounding each day to 0.1 first and adding drifts: three days
of 0.05 are 0.15 (0.2 to one place), not 0.3.
"""
base = _tenths(base_celsius, "baseCelsius must be a finite number from -100 to 100")
upper: Optional[int] = None
if upper_celsius is not None:
upper = _tenths(upper_celsius, "upperCelsius must be a finite number from -100 to 100")
if upper <= base:
raise ValueError("upperCelsius must be above baseCelsius")
if method not in ("average", "clamped"):
raise ValueError('unknown method "%s": use average or clamped' % (method,))
def cap(t: int) -> int:
return upper if upper is not None and t > upper else t
daily: List[float] = []
total = 0
for i, day in enumerate(days):
message = "day %d: temperatures must be finite numbers from -100 to 100 °C" % (i + 1,)
lo = _tenths(day.min, message)
hi = _tenths(day.max, message)
if lo > hi:
raise ValueError("day %d: min must not be above max" % (i + 1,))
lo = cap(lo)
hi = cap(hi)
if method == "clamped":
twice = max(lo, base) + max(hi, base) - 2 * base
else:
twice = max(lo + hi - 2 * base, 0)
# twice is 2 x (mean - base) in tenths, so the day is twice / 20 degrees.
daily.append(twice / 20)
total += twice
return DegreeDays(total=round_div(total, 2, "half-up") / 10, daily=daily)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add agri.growing-degree-days
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./agri.growing-degree-days-1.0.0-python.fune, or fetch it from a terminal with fune pull agri.growing-degree-days@1.0.0:python.
The whole function, every language, is one file too: agri.growing-degree-days-1.0.0.fune, 16,359 bytes, sha256 32de959c3bba666eb438eecb3ef515d8cd611fdb6ab664a9e7bfaf32c5c31bc8. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before agri.growing-degree-days
after — your function gets the result and the arguments, and returns the final result.
# fune: after agri.growing-degree-days
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace math.round-div in agri.growing-degree-days
# fune: replace math.round-float in agri.growing-degree-days
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show agri.growing-degree-days --steps.
# fune: step agri.growing-degree-days after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| one day 10 to 20 °C over base 10 is 5 degree days | days ×1, 10, —, average | → | total 5, daily 5 |
| maize 10/30: averaging caps the hot max but keeps the cool min | days ×1, 10, 30, average | → | total 7.5, daily 7.5 |
| maize 10/30: clamping raises the cool min to the base as well | days ×1, 10, 30, clamped | → | total 10, daily 10 |
| a cold day: the average method floors a negative day at zero | days ×1, 5, —, average | → | total 0, daily 0 |
| the same cold day clamped still counts the warm afternoon | days ×1, 5, —, clamped | → | total 1.5, daily 1.5 |
| three days of 0.05 total 0.15, rounded once to 0.2, not 0.3 from rounding each day | days ×3, 10, —, average | → | total 0.2, daily 0.05, 0.05, 0.05 |
| readings are taken to 0.1 °C before averaging | days ×1, 10, —, average | → | total 5.1, daily 5.05 |
| a day entirely above the cap counts only up to the cap | days ×1, 10, 30, average | → | total 20, daily 20 |
| clamped, a day entirely above the cap is the same | days ×1, 10, 30, clamped | → | total 20, daily 20 |
| base 0 with a frost: average floors, clamped counts the thaw | days ×1, 0, —, clamped | → | total 2.5, daily 2.5 |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| base 0 with a frost averaged is zero | days ×1, 0, —, average | → | total 0, daily 0 |
| a spring week for grass T-sum at base 0 | days ×4, 0, —, average | → | total 16, daily 5, 3, 0, 8 |
| no days is zero | , 10, —, average | → | total 0, daily |
| a day with min equal to max | days ×1, 10, —, average | → | total 5, daily 5 |
| min above max is an error | days ×2, 10, —, average | → | error: day 2: min must not be above max |
| an upper cap at the base is an error | days ×1, 10, 10, average | → | error: upperCelsius must be above baseCelsius |
| an impossible temperature is an error | days ×1, 10, —, average | → | error: day 1: temperatures must be finite numbers from -100 to 100 |
| an unknown method is an error | days ×1, 10, —, triangle | → | error: unknown method "triangle" |
More from the author
McMaster and Wilhelm (1997) showed that "the" GDD equation is read two ways, and that the difference matters most on cool days, so the method is an argument rather than a hidden choice:
- **average** (their method 1): the day is (max + min) / 2 - base, and a negative day counts as 0. With an upper cap, max and min above the cap are first lowered to it. - **clamped** (their method 2, and the US "86/50" maize method): max and min are each held within base..upper first, then averaged and the base subtracted. A day that is 5 °C at dawn and 35 °C in the afternoon counts 10 GDD at base 10, cap 30, where averaging gives 7.5.
The upper cap is optional (null for none). Neither method models the hours above the cap (no triangle or sine methods).
## Exactness
Temperatures and the base are taken to 0.1 °C (half away from zero, `math.round-float`), so each day is an exact multiple of 0.05 and is returned as it is. The total adds the exact daily values and rounds once, half up, to 0.1. Rounding each day first would make three days of 0.05 into 0.3; this gives 0.2 (0.15 rounded).
## Limits
Temperatures, base and cap are -100 to 100 °C; the cap must be above the base; a day's min must not be above its max. An empty list is 0. The function does not know crop bases: 10 °C for maize, 0 °C for wheat thermal time and grass T-sum, 5 or 6 °C for many temperate crops are the caller's choice.
## Source
G. S. McMaster and W. W. Wilhelm, "Growing degree-days: one equation, two interpretations", Agricultural and Forest Meteorology 87 (1997) 291-300, https://doi.org/10.1016/S0168-1923(97)00027-0.
Files
| Path | Bytes |
|---|---|
| README.md | 1,909 |
| impl/python.py | 2,370 |
| impl/rust.rs | 3,057 |
| impl/typescript.ts | 2,326 |
| vectors.json | 3,175 |