How to Calculate Climate Normals in Python

On this page

Problem statement

Reagan National Airport's GHCN-Daily record is a maximum, a minimum and a rainfall total for each day. Turning thirty of those years, 1991 to 2020, into a station's normals means averaging by month while skipping months with too many missing days, summing rain rather than averaging it, and fitting a smooth curve through the daily values for a normal of each day — then checking the result against the normals NOAA publishes for the same station, which fill gaps, adjust for station changes and come in °F and inches.

This page computes 1991-2020 normals for Washington Reagan National Airport from the station's GHCN-Daily record: monthly means and totals, and a smoothed daily curve. It compares them with NOAA's official 1991-2020 normals for the same station, and shows how the previous normal period, 1981-2010, differs.

Quick answer

Average 30 years of daily values by month, requiring enough days in each month:

base = G.loc["1991":"2020"]
monthly = base.groupby([base.index.year, base.index.month]).agg(tmax=("TMAX", "mean"), tmin=("TMIN", "mean"), prcp=("PRCP", "sum"), n=("TMAX", "count"))
ok = monthly[monthly.n >= 25]                                       # skip badly incomplete months
N = ok.groupby(level=1)[["tmax", "tmin", "prcp"]].mean()
N["tavg"] = (N.tmax + N.tmin) / 2
N.index = pd.to_datetime(N.index, format="%m").strftime("%b")
print(N.round(1).to_string())
print(f"annual: mean {N.tavg.mean():.1f} °C, precipitation {N.prcp.sum():.0f} mm; months used {len(ok)} of {len(monthly)}")
      tmax  tmin   prcp  tavg
DATE                         
Jan    6.9  -1.3   72.8   2.8
Feb    8.8  -0.3   66.4   4.2
Mar   13.4   3.4   88.9   8.4
Apr   19.7   8.8   81.5  14.3
May   24.5  14.2  100.2  19.3
Jun   29.3  19.4  106.7  24.3
Jul   31.7  22.2  109.9  27.0
Aug   30.7  21.4   82.6  26.1
Sep   26.8  17.6   99.8  22.2
Oct   20.5  10.9   92.9  15.7
Nov   14.3   5.1   73.8   9.7
Dec    9.0   1.1   86.7   5.1
annual: mean 14.9 °C, precipitation 1062 mm; months used 360 of 360

The 1991-2020 normal year at Reagan National runs from a January mean of 2.8 °C to a July mean of 27.0 °C, with 1,062 mm of precipitation. All 360 months of the period are complete enough to use.

Climograph of 1991-2020 normals at Reagan National: monthly precipitation bars between 66 and 110 mm and mean maximum and minimum temperature lines from about 7 and -1 °C in January to 32 and 22 °C in July. Climograph of 1991-2020 normals at Reagan National: monthly precipitation bars between 66 and 110 mm and mean maximum and minimum temperature lines from about 7 and -1 °C in January to 32 and 22 °C in July.
The normal year.

The official normals, daily normals, changing normals

1. The official normals

NOAA's 1991-2020 normals for the station are a CSV in °F and inches. Convert and compare:

O = pd.read_csv(NORMALS_M)
O = O.assign(tavg=(O["MLY-TAVG-NORMAL"] - 32) * 5 / 9, tmax=(O["MLY-TMAX-NORMAL"] - 32) * 5 / 9, prcp=O["MLY-PRCP-NORMAL"] * 25.4)
cmp = pd.DataFrame({"ours tavg": N.tavg.values, "NOAA tavg": O.tavg.values, "ours prcp": N.prcp.values, "NOAA prcp": O.prcp.values}, index=N.index)
print(cmp.round(1).to_string())
print(f"largest difference: tavg {(cmp['ours tavg'] - cmp['NOAA tavg']).abs().max():.2f} °C, precipitation {(cmp['ours prcp'] - cmp['NOAA prcp']).abs().max():.1f} mm")
      ours tavg  NOAA tavg  ours prcp  NOAA prcp
DATE                                            
Jan         2.8        3.1       72.8       72.6
Feb         4.2        4.4       66.4       66.5
Mar         8.4        8.7       88.9       88.9
Apr        14.3       14.6       81.5       81.5
May        19.3       19.6      100.2      100.1
Jun        24.3       24.6      106.7      106.7
Jul        27.0       27.2      109.9      110.0
Aug        26.1       26.3       82.6       82.6
Sep        22.2       22.4       99.8       99.8
Oct        15.7       16.0       92.9       93.0
Nov         9.7        9.9       73.8       73.9
Dec         5.1        5.4       86.7       86.6
largest difference: tavg 0.30 °C, precipitation 0.1 mm

Our precipitation normals match NOAA's to 0.1 mm in every month. Our temperatures run 0.2 to 0.3 °C below NOAA's in every month. An offset this consistent points to method rather than chance, such as NOAA's adjustments for the station's history.

Normals computed from GHCN-Daily compared with NOAA official normals at Reagan National. ours against NOAA, 1991-2020monthours °CNOAA °Cours mmNOAA mmJanuary2.83.172.872.6April14.314.681.581.5July27.027.2109.9110.0October15.716.092.993.0temperature 0.2-0.3 °C lower every month; precipitation within 0.1 mmUSW00013743 spatialworkflow.io
Ours against NOAA.

2. Daily normals

Daily averages over 30 years are noisy. Smooth them with harmonics of the annual cycle:

doy = base.index.dayofyear.values
tavg_day = ((base.TMAX + base.TMIN) / 2).values
mask = ~np.isnan(tavg_day)
t = 2 * np.pi * doy[mask] / 365.25
X = np.column_stack([np.ones(mask.sum())] + [f(k * t) for k in (1, 2, 3) for f in (np.sin, np.cos)])
coef, *_ = np.linalg.lstsq(X, tavg_day[mask], rcond=None)
tt = 2 * np.pi * np.arange(1, 366) / 365.25
curve = np.column_stack([np.ones(365)] + [f(k * tt) for k in (1, 2, 3) for f in (np.sin, np.cos)]) @ coef
print(f"smoothed daily normal: coldest day {curve.min():.1f} °C on day {curve.argmin() + 1}, warmest {curve.max():.1f} °C on day {curve.argmax() + 1}")
D = pd.read_csv(NORMALS_D)
noaa_daily = ((D["DLY-TAVG-NORMAL"] - 32) * 5 / 9).values[:365]
print(f"NOAA daily normal: coldest {noaa_daily.min():.1f} °C, warmest {noaa_daily.max():.1f} °C; mean absolute difference from ours {np.abs(curve - noaa_daily).mean():.2f} °C")
smoothed daily normal: coldest day 2.7 °C on day 20, warmest 27.3 °C on day 208
NOAA daily normal: coldest 2.8 °C, warmest 27.4 °C; mean absolute difference from ours 0.29 °C

The smoothed curve has its coldest day on 20 January at 2.7 °C, and its warmest on day 208, 27 July, at 27.3 °C. NOAA's daily normals give 2.8 and 27.4 °C, and differ from ours by 0.29 °C on average.

3. Normals change

Compare with the previous normal period:

def normal(a, b):
    x = G.loc[a:b]
    return ((x.TMAX + x.TMIN) / 2).mean(), x.PRCP.groupby(x.index.year).sum().mean()
for a, b in (("1961", "1990"), ("1981", "2010"), ("1991", "2020")):
    t, p = normal(a, b)
    print(f"{a}-{b}: mean {t:.2f} °C, precipitation {p:.0f} mm a year")
1961-1990: mean 14.35 °C, precipitation 981 mm a year
1981-2010: mean 14.61 °C, precipitation 1009 mm a year
1991-2020: mean 14.99 °C, precipitation 1062 mm a year

Each normal is warmer and wetter than the last: 14.35 °C and 981 mm for 1961-1990, 14.61 °C and 1,009 mm for 1981-2010, and 14.99 °C and 1,062 mm for 1991-2020.

How unusual 2024 was

The data used on this page

The GHCN-Daily record for USW00013743, Washington Reagan National Airport, and NOAA's U.S. Climate Normals 1991-2020, monthly and daily, for the same station, from NCEI.

from pathlib import Path

import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
def cached(path, url):
    p = Path(path)
    if not p.exists():
        r = requests.get(url, timeout=600)
        r.raise_for_status()
        p.write_bytes(r.content)
    return p
CSV = cached("data/USW00013743.csv", "https://www.ncei.noaa.gov/data/global-historical-climatology-network-daily/access/USW00013743.csv")
NORMALS_M = cached("data/normals_monthly_USW00013743.csv", "https://www.ncei.noaa.gov/data/normals-monthly/1991-2020/access/USW00013743.csv")
NORMALS_D = cached("data/normals_daily_USW00013743.csv", "https://www.ncei.noaa.gov/data/normals-daily/1991-2020/access/USW00013743.csv")
G = pd.read_csv(CSV, usecols=["DATE", "TMAX", "TMIN", "PRCP"], parse_dates=["DATE"]).set_index("DATE").loc[:"2024-12-31"]
G[["TMAX", "TMIN", "PRCP"]] = G[["TMAX", "TMIN", "PRCP"]] / 10
print(f"GHCN record {G.index.min():%Y-%m-%d} to {G.index.max():%Y-%m-%d}")
GHCN record 1936-09-01 to 2024-12-31

The record used runs from 1 September 1936 to the end of 2024.

Example 1: how unusual was 2024?

Compare each month of 2024 with the normal we computed:

y = G.loc["2024"]
m24 = y.groupby(y.index.month).agg(tmax=("TMAX", "mean"), tmin=("TMIN", "mean"), prcp=("PRCP", "sum"))
anom = pd.DataFrame({"tavg anomaly °C": ((m24.tmax + m24.tmin) / 2).values - N.tavg.values, "precip % of normal": 100 * m24.prcp.values / N.prcp.values}, index=N.index)
print(anom.round(1).to_string())
print(f"2024 mean anomaly {anom['tavg anomaly °C'].mean():+.2f} °C; precipitation {m24.prcp.sum() / N.prcp.sum():.0%} of normal")
      tavg anomaly °C  precip % of normal
DATE                                     
Jan               1.5               205.2
Feb               2.6                54.0
Mar               2.6               139.7
Apr               1.6                64.6
May               1.4               109.2
Jun               2.2                27.6
Jul               1.8               111.5
Aug               0.2               140.9
Sep               0.3                58.8
Oct               1.5                40.5
Nov               3.1                62.6
Dec               0.4                84.4
2024 mean anomaly +1.61 °C; precipitation 90% of normal

Every month of 2024 was warmer than the new normal, by 1.61 °C over the year. Precipitation swung from 205% of normal in January to 28% in June, and the year as a whole received 90% of the normal total.

Line chart of daily normals of mean temperature: a noisy raw 30-year mean per day, the NOAA daily normal and our three-harmonic fit, nearly identical, from about 3 °C in January to 27 °C in late July. Line chart of daily normals of mean temperature: a noisy raw 30-year mean per day, the NOAA daily normal and our three-harmonic fit, nearly identical, from about 3 °C in January to 27 °C in late July.
Daily normals.

Thirty-year averages, updated every ten

Annual mean temperature normals at Reagan National for three periods. normals move1961-199014.35 °C, 981 mm1981-201014.61 °C, 1,009 mm1991-202014.99 °C, 1,062 mm05 °C10 °C15 °CEach period warmer and wetter than the last spatialworkflow.io
Normals move.

A climate normal is a 30-year average of a weather variable at a place, updated every ten years by convention of the World Meteorological Organization: 1991-2020 is the current standard period, replacing 1981-2010. Normals describe the climate a place has had, set the baseline for anomalies, and feed planning from heating-degree days to crop calendars. NOAA's official US normals are computed carefully. Missing values are estimated from neighbours, inhomogeneities from station moves and instrument changes are accounted for, precipitation normals use all available data in a defined way, and daily normals are smoothed with harmonic curves.

Normals computed straight from a station's daily file are close to the official ones where the record is complete, as Reagan National's is. Differences of a few tenths of a degree come from gap handling and adjustments, and precipitation differs more because totals are sensitive to missing days. The choice of period matters more. Each newer normal period is warmer than the last, so "above normal" against 1991-2020 is a higher bar than against 1981-2010, and any anomaly should state its baseline.

Incomplete months, precipitation and grids

Checks when calculating climate normals. computing a normal30 years1991-2020complete months only360 of 360 hereunitsNOAA °F and inchessmooth daily normalsharmonicscompare with officialwithin 0.3 °CGHCN-Daily and NOAA normals spatialworkflow.io
Computing a normal.
  • Incomplete months: require a minimum number of days (WMO guidance allows a few missing days per month) before including a month.
  • Precipitation: never average monthly totals that are missing days; one missing wet day changes a total.
  • Units: NOAA's normals files use °F and inches; GHCN uses tenths of °C and mm.
  • Gridded normals: PRISM (US) and WorldClim (global) provide normals on grids for places without stations.

FAQ

How do I calculate climate normals in Python?

Take 30 years of daily data (1991-2020), compute monthly means or totals for each year, skip badly incomplete months, and average each calendar month across the years.

What period are climate normals based on?

The current WMO standard period is 1991-2020; the previous one was 1981-2010.

Where can I download official climate normals?

NOAA publishes US normals by station on the NCEI website, as CSV files per station.

Why do my normals differ from the official ones?

Official normals fill gaps, adjust for station changes and smooth daily values; raw averages differ slightly, precipitation most.