Fixing GHCN Temperatures in Tenths of a Degree and -9999 Values

On this page

Problem statement

You plot NOAA GHCN-Daily temperatures and summer days reach 350 degrees, or a mean comes out at a few hundred below zero. GHCN stores values as integers: temperatures in tenths of °C, precipitation in tenths of mm, snowfall and snow depth in whole mm. The fixed-width .dly files mark missing values with -9999. Neither is a bug, but forgetting either produces numbers that are wrong by a factor of ten or by thousands, and the second kind can hide inside an average.

This page reads the Washington Reagan National Airport record in both GHCN formats, the CSV access file and the fixed-width .dly file. It shows what the raw values look like, converts them correctly, catches the -9999 sentinel, and adds range checks that would have flagged either mistake.

Quick answer

Divide temperatures and precipitation by ten, and turn -9999 into missing before anything else:

D = read_dly(DLY)
D = D.replace(-9999, np.nan)
for el in ("TMAX", "TMIN", "PRCP"):
    D[el] = D[el] / 10
print(f"days {len(D):,}; TMAX {D.TMAX.min():.1f} to {D.TMAX.max():.1f} °C; PRCP max {D.PRCP.max():.1f} mm; SNOW max {D.SNOW.max():.0f} mm")
days 31,168; TMAX -13.3 to 40.6 °C; PRCP max 155.2 mm; SNOW max 417 mm

Converted, the .dly gives 31,168 days, with maxima from −13.3 to 40.6 °C, a wettest day of 155.2 mm and a deepest one-day snowfall of 417 mm.

Chart of raw TMAX values from the GHCN .dly file from 1937 to 2024, oscillating between about -100 and 400 in tenths of °C, with 15 points at -9999 marked in red. Chart of raw TMAX values from the GHCN .dly file from 1937 to 2024, oscillating between about -100 and 400 in tenths of °C, with 15 points at -9999 marked in red.
What the raw file stores.

The raw values, what each omission does, range checks

1. The raw values

raw = read_dly(DLY)
print(f"raw TMAX on 2024-07-15: {raw.loc['2024-07-15', 'TMAX']}; raw PRCP: {raw.loc['2024-07-15', 'PRCP']}")
print(f"-9999 values in the raw .dly: TMAX {int((raw.TMAX == -9999).sum())}, TMIN {int((raw.TMIN == -9999).sum())}, PRCP {int((raw.PRCP == -9999).sum())}")
raw TMAX on 2024-07-15: 389; raw PRCP: 0
-9999 values in the raw .dly: TMAX 16, TMIN 16, PRCP 17

The raw file stores 389 for 38.9 °C. TMAX and TMIN each have 16 -9999 values and PRCP 17.

2. What forgetting each step does

t_raw = raw.TMAX.loc["1937":"2024"]
print(f"mean TMAX with -9999 left in and no scaling: {t_raw.mean():.1f}")
print(f"mean TMAX with -9999 removed but no scaling: {t_raw.replace(-9999, np.nan).mean():.1f}")
print(f"mean TMAX done right: {t_raw.replace(-9999, np.nan).mean() / 10:.2f} °C")
mean TMAX with -9999 left in and no scaling: 189.7
mean TMAX with -9999 removed but no scaling: 194.7
mean TMAX done right: 19.47 °C

The three versions of the long-term mean maximum: 189.7 with the sentinels left in and no scaling, 194.7 with the sentinels removed but no scaling, and 19.47 °C done right. Sixteen -9999 values out of tens of thousands shift the mean by 5 tenths, or half a degree once scaled, an error easy to miss in a converted series.

GHCN-Daily element units. GHCN unitselementstored asexampleTMAX, TMINtenths of °C389 = 38.9 °CPRCPtenths of mm1552 = 155.2 mmSNOW, SNWDmm417 = 417 mmmissing (.dly)-9999replace with NaNthe CSV access files leave missing values blank insteadGHCN-Daily readme spatialworkflow.io
GHCN units.

3. Range checks that catch it

Plausible limits catch scale and sentinel errors before they reach a result:

def check(df):
    problems = {}
    if df.TMAX.max() > 60 or df.TMIN.min() < -90: problems["temperature"] = (float(df.TMIN.min()), float(df.TMAX.max()))
    if df.PRCP.max() > 1000: problems["precipitation"] = float(df.PRCP.max())
    return problems or "all within plausible ranges"
print("raw:", check(raw))
print("converted:", check(D))
raw: {'temperature': (-9999.0, 406.0), 'precipitation': 1552.0}
converted: all within plausible ranges

The range checks flag the raw data on both counts, a minimum of −9999 and precipitation of 1,552, and pass the converted data.

The CSV keeps the scale but not the sentinel

The data used on this page

The GHCN-Daily record for USW00013743, Washington Reagan National Airport, as the fixed-width .dly file from NOAA's archive and the CSV access file.

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
DLY = cached("data/USW00013743.dly", "https://www.ncei.noaa.gov/pub/data/ghcn/daily/all/USW00013743.dly")
CSV = cached("data/USW00013743.csv", "https://www.ncei.noaa.gov/data/global-historical-climatology-network-daily/access/USW00013743.csv")

def read_dly(path, elements=("TMAX", "TMIN", "PRCP", "SNOW")):
    """Parse a GHCN .dly file: one line per station-month-element, 31 fixed-width day fields."""
    rows = []
    for line in Path(path).read_text().splitlines():
        el = line[17:21]
        if el not in elements:
            continue
        y, m = int(line[11:15]), int(line[15:17])
        for d in range(31):
            v = int(line[21 + d * 8: 26 + d * 8])
            rows.append((y, m, d + 1, el, v))
    df = pd.DataFrame(rows, columns=["y", "m", "d", "el", "v"])
    df["date"] = pd.to_datetime(dict(year=df.y, month=df.m, day=df.d), errors="coerce")
    return df.dropna(subset=["date"]).pivot_table(index="date", columns="el", values="v", aggfunc="first")
print(f"{DLY.name}: {DLY.stat().st_size / 1e6:.1f} MB")
USW00013743.dly: 5.1 MB

The fixed-width file is 5.1 MB.

Example 1: the CSV has no sentinel, but the same scale

The CSV access file leaves missing values empty instead of -9999, but keeps the tenths:

c = pd.read_csv(CSV, usecols=["DATE", "TMAX"], parse_dates=["DATE"]).set_index("DATE").TMAX
print(f"CSV TMAX on 2024-07-15: {c.loc['2024-07-15']}; minimum value in the CSV {c.min()}; same as the .dly converted back: {c.loc['2024-07-15'] / 10 == D.loc['2024-07-15', 'TMAX']}")
CSV TMAX on 2024-07-15: 389.0; minimum value in the CSV -133.0; same as the .dly converted back: True

The CSV has the same 389 for 15 July, but its lowest value is −133, −13.3 °C: no sentinel. Converted, both formats agree.

Long-term mean daily maximum at Reagan National computed three ways. mean TMAX, three wayssentinels left, no scaling189.7sentinels removed, no scaling194.7done right19.47 °C010020016 -9999 values move the mean by half a degree spatialworkflow.io
Three ways to get the mean.

Punched-card conventions

Reading a GHCN .dly file correctly. reading .dlyfixed-widthlinestation, year,month, element31 day fields8 characters each-9999 to NaNfirstdivide by 10TMAX, TMIN, PRCPrange checkplausible limitsInvalid dates such as 31 February dropped spatialworkflow.io
Reading .dly.

GHCN-Daily goes back to formats designed for punched cards and fixed-width files, where integers were cheaper and safer than decimals. Temperatures are therefore stored in tenths of a degree Celsius, precipitation in tenths of a millimetre, and snowfall and snow depth in millimetres. In the .dly format, each line holds one element for one month, with 31 day fields of 8 characters: a 5-character value and three 1-character flags. Days that did not happen, such as 31 February, or were not observed hold -9999. The newer CSV access files keep the integer scale but leave missing values blank, which pandas reads as NaN.

Both conventions are documented in the GHCN readme, and both are easy to forget when the data is read by hand. The resulting errors differ in visibility. A factor of ten is obvious in a plot, while a few -9999 values among thousands of real ones drag a mean down by a few degrees and pass unnoticed. Range checks against physically plausible limits catch both, and belong in any pipeline that reads raw station data.

Fahrenheit products, traces and invalid dates

Range checks for station data. plausibility checksTMAX under 60 °Ccatches tenthsTMIN over -90 °Ccatches -9999PRCP under 1,000 mm a daycatches tenthsunits per productnormals in °Ftrace flagged Tvalue 0Raw data fails, converted data passes spatialworkflow.io
Plausibility checks.
  • Fahrenheit products: NOAA's normals and Climate Data Online exports can be in °F and inches; check the units of each product.
  • Trace precipitation: stored as 0 with the measurement flag T, not as a small number.
  • Other elements: AWND (average wind) is in tenths of m/s, TAVG in tenths of °C; check the readme for each element.
  • Invalid dates: 31-day fields exist for every month; the extra days are -9999 and must not become dates.

FAQ

Why are GHCN temperatures ten times too big?

They are stored in tenths of °C; divide by 10.

What does -9999 mean in GHCN data?

A missing value, or a day that does not exist in that month, in the fixed-width .dly files; replace it with NaN.

What units is GHCN precipitation in?

Tenths of a millimetre for PRCP; millimetres for SNOW and SNWD.

How do I read a GHCN .dly file in Python?

Parse it as fixed width: station ID, year, month and element, then 31 day groups of 8 characters (value plus three flags).