Functional Weave
Code in Rust

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 days
  • growing_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 min
  • growing_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.

pub fn growing_degree_days(days: &[DailyTemperature], base_celsius: f64, upper_celsius: Option<f64>, method: &str) -> DegreeDays
daysDailyTemperature[]one reading per day, in order; may be empty
base_celsiusfloatno development below this, e.g. 10 for maize, 0 for wheat thermal time; to 0.1 °C
upper_celsiusfloat?no extra development above this (30 for maize); null for no cap
methodDegreeDayMethodaverage: max(0, mean - base); clamped: min and max held within base..upper first
returnsDegreeDays

The types it declares, generated into your project

/// One day's extremes in °C, taken to 0.1 °C.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DailyTemperature {
    pub min: f64,
    pub max: f64,
}

// DegreeDayMethod is a string in Rust, one of: "average", "clamped".
// Parameters take it as &str and results hold it as String.

/// The total, and each day's contribution.
#[derive(Debug, Clone, PartialEq)]
pub struct DegreeDays {
    /// sum of the exact daily values, then rounded half up to 0.1
    pub total: f64,
    /// each day exactly, a multiple of 0.05
    pub daily: Vec<f64>,
}

Your code names it in one line, in the file that uses it

fune!(agri.growing-degree-days@^1);  // then call growing_degree_days(…)
impl/rust.rs · 80 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::math_round_float::round_float;  ← from math.round-float ^1.0.0 · built alongside by fune

fn tenths(value: f64, message: &str) -> i64 {
    if !value.is_finite() || value < -100.0 || value > 100.0 {
        panic!("{}", message);
    }
    (round_float(value, 1) * 10.0).round() as i64
}

/// 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.
///
/// # Panics
/// Panics on a temperature out of range, a day whose min is above its max,
/// an upper cap not above the base, or an unknown method.
pub fn growing_degree_days(days: &[DailyTemperature], base_celsius: f64, upper_celsius: Option<f64>, method: &str) -> DegreeDays {
    let base = tenths(base_celsius, "baseCelsius must be a finite number from -100 to 100");
    let upper = upper_celsius.map(|u| tenths(u, "upperCelsius must be a finite number from -100 to 100"));
    if let Some(u) = upper {
        if u <= base {
            panic!("upperCelsius must be above baseCelsius");
        }
    }
    if method != "average" && method != "clamped" {
        panic!("unknown method \"{}\": use average or clamped", method);
    }
    let cap = |t: i64| match upper {
        Some(u) if t > u => u,
        _ => t,
    };
    let mut daily: Vec<f64> = Vec::with_capacity(days.len());
    let mut total: i64 = 0;
    for (i, day) in days.iter().enumerate() {
        let message = format!("day {}: temperatures must be finite numbers from -100 to 100 °C", i + 1);
        let lo = tenths(day.min, &message);
        let hi = tenths(day.max, &message);
        if lo > hi {
            panic!("day {}: min must not be above max", i + 1);
        }
        let (lo, hi) = (cap(lo), cap(hi));
        let twice = if method == "clamped" {
            lo.max(base) + hi.max(base) - 2 * base
        } else {
            (lo + hi - 2 * base).max(0)
        };
        // twice is 2 x (mean - base) in tenths, so the day is twice / 20 degrees.
        daily.push(twice as f64 / 20.0);
        total += twice;
    }
    DegreeDays {
        total: round_div(total, 2, "half-up") as f64 / 10.0,
        daily,
    }
}

pub fn degree_days_to_value(d: &DegreeDays) -> Value {
    Value::obj(vec![
        ("total", Value::Float(d.total)),
        ("daily", Value::Arr(d.daily.iter().map(|&v| Value::Float(v)).collect())),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let days: Vec<DailyTemperature> = args[0]
        .as_arr()
        .iter()
        .map(|v| DailyTemperature {
            min: v.get("min").as_f64(),
            max: v.get("max").as_f64(),
        })
        .collect();
    let upper = if args[2].is_null() { None } else { Some(args[2].as_f64()) };
    degree_days_to_value(&growing_degree_days(&days, args[1].as_f64(), upper, args[3].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add agri.growing-degree-days
Download for Rust agri.growing-degree-days-1.0.0-rust.fune · 11,466 bytes sha256 39399b3ffdc5b3e0f295232c1c7c3f8c4a97bef69a10cb47fa6b863045abfcd1

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./agri.growing-degree-days-1.0.0-rust.fune, or fetch it from a terminal with fune pull agri.growing-degree-days@1.0.0:rust.

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,909
impl/python.py2,370
impl/rust.rs3,057
impl/typescript.ts2,326
vectors.json3,175