Skip to content

terraflow.hazard

WMO/ETCCDI-aligned hazard indicators introduced in v0.5.0 (#138d). Backs four of the TemporalAggregation.kind values that terraflow.temporal.aggregate_per_station dispatches to.

Overview

  • growing_degree_days(df, base_temp_c) — per-station cumulative sum of max(0, T_d - base) across every row in the (scenario-filtered) DataFrame. Note: this is a window-cumulative total, not a per-year average — a 30-year scenario window yields ~30× the value of a 1-year window. Divide by (year_max - year_min + 1) if you need an annualised GDD.
  • frost_days(df, threshold_c) — per-station cumulative count of days where T ≤ threshold across every row in the (scenario-filtered) DataFrame. Maps to WMO ETCCDI FD0 when threshold = 0 °C, but note the WMO definition is per-year — TerraFlow's implementation returns the window total. Divide by the window's year count for the canonical annual FD0.
  • heat_stress_days(df, threshold_c) — per-station cumulative count of days where T ≥ threshold across every row in the (scenario-filtered) DataFrame. Maps to WMO ETCCDI TX35 when threshold = 35 °C; same per-year vs window-total caveat as frost_days above.
  • spei(df, timescale_months) — simplified Thornthwaite-PET-based Standardised Precipitation-Evapotranspiration Index at the final month of the input window.

SPEI simplifications

Documented honestly so a user can decide whether the simplification is acceptable for their analysis:

Aspect Canonical SPEI (R package) TerraFlow spei()
Standardisation Log-logistic CDF fit Z-score
Daylight-hour correction Latitude-dependent in PET Omitted
Heat-index reference Multi-decade climatology Per-station fit on input window

For relative scenario comparisons (e.g. "is SSP5-8.5 drier than historical?") these simplifications are typically acceptable. For absolute SPEI claims against published thresholds (e.g. "moderate drought" at SPEI < -1) prefer the canonical R package via reticulate or a pre-computed SPEI column passed through precip_percentile as a substitute.

Quick example

import pandas as pd
from terraflow.hazard import growing_degree_days, frost_days, heat_stress_days, spei

df = pd.read_csv("data/demo_timeseries.csv", parse_dates=["date"])

gdd = growing_degree_days(df, base_temp_c=10.0)
fd  = frost_days(df,        threshold_c=0.0)
ts  = heat_stress_days(df,  threshold_c=35.0)
spei3 = spei(df, timescale_months=3)

API Reference

hazard

Climate-induced crop-hazard indicators (flagship #138d).

Implements the four hazard kinds dispatched from :mod:terraflow.temporal:

  • growing_degree_days: GDD accumulation above a crop-specific base temperature. GDD = sum_d max(0, daily_mean_temp - base_temp_c).
  • frost_days: count of days with daily mean temperature at or below a threshold (typically 0 °C).
  • heat_stress_days: count of days with daily mean temperature at or above a threshold (typically 30-35 °C for major C3 crops).
  • spei: Standardised Precipitation-Evapotranspiration Index using a simplified Thornthwaite PET. Returns the SPEI value at the final month of the input window.

All four functions take the same long-format station time-series DataFrame shape used elsewhere in the climate-impact pipeline (station_id, date, temperature_c, precipitation_mm) and return a :class:pandas.Series indexed by station_id with float scalar values. Stations with no rows after upstream scenario filtering yield NaN rather than raising — consistent with the rest of the engine.

Simplified-SPEI caveats (documented for reviewers):

  1. PET is computed via Thornthwaite (monthly temperature only); no daylight-hour or latitude correction (assumes 12 h days, 30-day months). Standard SPEI implementations (e.g. R's SPEI package) add a latitude-aware daylight factor.
  2. The annual heat index I is fitted on each station's own monthly means within the input window. Standard practice fits I on a long-run climatology.
  3. Standardisation is a per-station z-score of the rolling timescale_months water-balance sum. Standard SPEI uses a fitted log-logistic distribution; the simplification preserves sign and relative ordering but not the canonical CDF mapping.

frost_days(df, threshold_c)

Per-station count of days with mean temperature ≤ threshold_c.

A daily mean of exactly threshold_c counts as a frost day — matches the WMO convention for ETCCDI FD0 when threshold = 0 °C.

Source code in terraflow/hazard.py
def frost_days(df: pd.DataFrame, threshold_c: float) -> pd.Series:
    """Per-station count of days with mean temperature ≤ *threshold_c*.

    A daily mean of exactly ``threshold_c`` counts as a frost day —
    matches the WMO convention for ETCCDI ``FD0`` when threshold = 0 °C.
    """
    _require_column(df, STATION_ID_COL)
    _require_column(df, TEMPERATURE_COL)
    all_stations = pd.Index(df[STATION_ID_COL].dropna().unique(), name=STATION_ID_COL)
    flags = (df[TEMPERATURE_COL] <= threshold_c).astype(int)
    counted = df.assign(_frost_flag=flags)
    grouped = counted.groupby(STATION_ID_COL)["_frost_flag"].sum().astype(float)
    return grouped.reindex(all_stations)

growing_degree_days(df, base_temp_c)

Per-station GDD accumulation above base_temp_c.

GDD for one day = max(0, daily_mean_temp_c - base_temp_c). The per-station total is the sum across all rows in the (already scenario-filtered) DataFrame.

Parameters:

Name Type Description Default
df DataFrame

Long-format station time-series. Must contain station_id and temperature_c columns.

required
base_temp_c float

Crop-specific base temperature in °C (e.g. 10 °C for maize, 4 °C for winter wheat).

required

Returns:

Type Description
pd.Series:

Indexed by station_id; float scalar GDD total per station. Stations missing from df yield NaN.

Source code in terraflow/hazard.py
def growing_degree_days(df: pd.DataFrame, base_temp_c: float) -> pd.Series:
    """Per-station GDD accumulation above *base_temp_c*.

    GDD for one day = ``max(0, daily_mean_temp_c - base_temp_c)``. The
    per-station total is the sum across all rows in the (already
    scenario-filtered) DataFrame.

    Parameters
    ----------
    df:
        Long-format station time-series. Must contain ``station_id`` and
        ``temperature_c`` columns.
    base_temp_c:
        Crop-specific base temperature in °C (e.g. 10 °C for maize,
        4 °C for winter wheat).

    Returns
    -------
    pd.Series:
        Indexed by ``station_id``; float scalar GDD total per station.
        Stations missing from *df* yield NaN.
    """
    _require_column(df, STATION_ID_COL)
    _require_column(df, TEMPERATURE_COL)
    all_stations = pd.Index(df[STATION_ID_COL].dropna().unique(), name=STATION_ID_COL)
    contribution = (df[TEMPERATURE_COL] - base_temp_c).clip(lower=0.0)
    contribution_df = df.assign(_gdd_contribution=contribution)
    grouped = (
        contribution_df.groupby(STATION_ID_COL)["_gdd_contribution"].sum().astype(float)
    )
    return grouped.reindex(all_stations)

heat_stress_days(df, threshold_c)

Per-station count of days with mean temperature ≥ threshold_c.

A daily mean of exactly threshold_c counts as a heat-stress day — consistent with the WMO ETCCDI TX35 convention when the threshold matches.

Source code in terraflow/hazard.py
def heat_stress_days(df: pd.DataFrame, threshold_c: float) -> pd.Series:
    """Per-station count of days with mean temperature ≥ *threshold_c*.

    A daily mean of exactly ``threshold_c`` counts as a heat-stress day —
    consistent with the WMO ETCCDI ``TX35`` convention when the threshold
    matches.
    """
    _require_column(df, STATION_ID_COL)
    _require_column(df, TEMPERATURE_COL)
    all_stations = pd.Index(df[STATION_ID_COL].dropna().unique(), name=STATION_ID_COL)
    flags = (df[TEMPERATURE_COL] >= threshold_c).astype(int)
    counted = df.assign(_heat_flag=flags)
    grouped = counted.groupby(STATION_ID_COL)["_heat_flag"].sum().astype(float)
    return grouped.reindex(all_stations)

spei(df, timescale_months)

Per-station simplified SPEI at the final month of the input window.

Drought index combining precipitation and evapotranspiration into a standardised metric. Values near zero indicate normal conditions; negative values indicate drought (more negative = more severe); positive values indicate wet anomalies. The scalar returned is the SPEI at the last month covered by the input data, computed over a rolling timescale_months window.

See module docstring for the documented simplifications relative to the canonical R-package SPEI implementation.

Source code in terraflow/hazard.py
def spei(df: pd.DataFrame, timescale_months: int) -> pd.Series:
    """Per-station simplified SPEI at the final month of the input window.

    Drought index combining precipitation and evapotranspiration into a
    standardised metric. Values near zero indicate normal conditions;
    negative values indicate drought (more negative = more severe);
    positive values indicate wet anomalies. The scalar returned is the
    SPEI at the **last** month covered by the input data, computed over
    a rolling *timescale_months* window.

    See module docstring for the documented simplifications relative to
    the canonical R-package SPEI implementation.
    """
    _require_column(df, STATION_ID_COL)
    _require_column(df, TEMPERATURE_COL)
    _require_column(df, PRECIPITATION_COL)
    _require_column(df, DATE_COL)
    if timescale_months <= 0:
        raise ValueError(f"timescale_months must be positive, got {timescale_months}")
    all_stations = pd.Index(df[STATION_ID_COL].dropna().unique(), name=STATION_ID_COL)
    results = {
        station: _spei_one_station(group, timescale_months)
        for station, group in df.groupby(STATION_ID_COL, sort=False)
    }
    series = pd.Series(results, dtype=float)
    series.index.name = STATION_ID_COL
    return series.reindex(all_stations)