Why Are My Station Wind Speeds Ten Times Too High?

On this page

Problem statement

You read wind speeds from NOAA's Integrated Surface Database, plot them, and Chicago's typical wind is 41 metres per second, a hurricane every day, with a maximum of 9,999. Nothing is wrong with the stations. ISD stores wind speed as an integer in tenths of a metre per second, packed into a text field with its direction and quality codes, and marks missing values with 9999. Read the number as it is and every speed is ten times too high; leave the missing code in and the maximum is 999.9.

This page decodes a year of hourly wind reports from Chicago O'Hare in 2024 three ways, shows what each mistake does to the statistics, and checks the decoded speeds against ISD-Lite, a simplified version of the same data.

Quick answer

Split the WND field, divide the speed by ten, and mask the missing codes:

w = ALL.WND.str.split(",", expand=True)
w.columns = ["dir", "dir_q", "type", "speed", "speed_q"]
raw_number = pd.to_numeric(w.speed)
speed = pd.to_numeric(w.speed.replace("9999", np.nan)) / 10           # tenths of m/s; 9999 = missing
for label, x in (("as read", raw_number), ("÷ 10, missing kept", raw_number / 10), ("÷ 10, missing masked", speed)):
    print(f"{label:>22}: mean {x.mean():6.1f}, median {x.median():5.1f}, max {x.max():6.1f}")
               as read: mean   43.5, median  41.0, max 9999.0
    ÷ 10, missing kept: mean    4.4, median   4.1, max  999.9
  ÷ 10, missing masked: mean    4.2, median   4.1, max   14.4
Two histograms of O'Hare's 8,756 hourly wind speeds in 2024: as read from the file, the values run from 0 to about 144 with most between 20 and 60, a typical value of 41; divided by ten they run from 0 to 14.4 metres per second with a median of 4.1; both show the same regular gaps between values. Two histograms of O'Hare's 8,756 hourly wind speeds in 2024: as read from the file, the values run from 0 to about 144 with most between 20 and 60, a typical value of 41; divided by ten they run from 0 to 14.4 metres per second with a median of 4.1; both show the same regular gaps between values.
The same numbers, unscaled and scaled.

Read the field as text, use quality codes, check ISD-Lite

1. Read the field as text

WND looks like 250,5,N,0046,5: direction in degrees, a quality code, a type code (N normal, C calm, V variable, 9 missing), speed in tenths of m/s with leading zeros, and a quality code. Read it as text, or the leading zeros and the commas will confuse pandas:

print(ALL.WND.head(3).tolist())
print(w.type.value_counts().rename({"N": "normal", "C": "calm", "V": "variable", "9": "missing"}).to_string())
['310,5,N,0057,5', '320,5,N,0072,5', '320,5,N,0072,5']
type
normal      8079
calm         517
variable     160

2. Use the quality codes

The quality codes say whether a value passed NOAA's checks. Codes 1 and 5 mean passed; 2 and 6 suspect; 3 and 7 erroneous; 9 missing:

print(w.speed_q.value_counts().to_string())
bad = w.speed_q.isin(["2", "3", "6", "7"])
print(f"speeds flagged suspect or erroneous: {bad.sum()}")
speed_q
5    8026
1     729
9       1
speeds flagged suspect or erroneous: 0
The five parts of an ISD WND field, with a real O'Hare example and what each means. reading 310,5,N,0057,5partmeaning310direction310° (999 = missing)5direction qualitypassed checksNtypeN normal, C calm, V variable0057speed5.7 m/s (9999 = missing)5speed qualitypassed checksSpeed is in tenths of a metre per second: divide by 10 spatialworkflow.io
Reading one ISD wind field.

3. Check against ISD-Lite

ISD-Lite is a simplified hourly extract of the same database, one line per hour with speed also in tenths of m/s and missing as -9999. The two decoded series should agree for the same hours:

lite = LITE.set_index("time").speed
both = pd.DataFrame({"isd": speed.groupby(ALL.time.dt.round("h").values).first(), "lite": lite}).dropna()   # :51 reports belong to the next hour
print(f"{len(both):,} hours in both; identical speed in {(both.isd == both.lite).mean():.1%}; "
      f"mean {both.isd.mean():.2f} against {both.lite.mean():.2f} m/s")
8,750 hours in both; identical speed in 99.2%; mean 4.24 against 4.24 m/s

The hour each report belongs to matters as much as the scale. Routine reports are taken at 51 minutes past; rounding to the nearest hour matches ISD-Lite in 99.2% of hours, while truncating to the hour shifts every report back by one and matches only 20.7%.

Knots and miles per hour

The data used on this page

NOAA's Integrated Surface Database, Global Hourly access file for Chicago O'Hare (station 725300-94846) for 2024, routine hourly reports (FM-15) only, and the ISD-Lite file for the same station and year.

import gzip
from pathlib import Path

import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
SRC = {"72530094846.csv": "https://noaa-global-hourly-pds.s3.amazonaws.com/2024/72530094846.csv",
       "725300-94846-2024.gz": "https://www.ncei.noaa.gov/pub/data/noaa/isd-lite/2024/725300-94846-2024.gz"}
for name, url in SRC.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=300).content)
ALL = pd.read_csv("data/72530094846.csv", usecols=["DATE", "REPORT_TYPE", "WND"], dtype=str)
ALL = ALL[ALL.REPORT_TYPE.str.strip() == "FM-15"].copy()
ALL["time"] = pd.to_datetime(ALL.DATE)
lite = pd.read_fwf(gzip.open("data/725300-94846-2024.gz", "rt"), widths=[4, 3, 3, 3, 6, 6, 6, 6, 6, 6, 6, 6], header=None,
                   names=["y", "m", "d", "h", "t", "td", "slp", "wd", "ws", "sky", "p1", "p6"])
LITE = pd.DataFrame({"time": pd.to_datetime(dict(year=lite.y, month=lite.m, day=lite.d, hour=lite.h)),
                     "speed": lite.ws.where(lite.ws != -9999) / 10})
print(f"{len(ALL):,} routine hourly reports; {len(LITE):,} ISD-Lite hours")
8,756 routine hourly reports; 8,766 ISD-Lite hours

Example 1: knots and miles per hour

Once in metres per second, convert for the audience:

s = speed.dropna()
print(f"median {s.median():.1f} m/s = {s.median() * 1.94384:.1f} kt = {s.median() * 2.23694:.1f} mph; "
      f"strongest {s.max():.1f} m/s = {s.max() * 1.94384:.0f} kt")
kt = s[s > 0] * 1.94384
print(f"non-calm speeds within 0.15 kt of a whole number of knots: {(np.abs(kt - kt.round()) < 0.15).mean():.0%}; "
      f"distinct speed values: {s.nunique()}")
median 4.1 m/s = 8.0 kt = 9.2 mph; strongest 14.4 m/s = 28 kt
non-calm speeds within 0.15 kt of a whole number of knots: 100%; distinct speed values: 27

Every speed is a whole number of knots converted to metres per second: US airports report in knots, and the year's speeds take only 27 different values. Differences of a tenth of a metre per second are rounding in the conversion, not measurements.

Scaled integers in a fixed-width archive

Common ISD fields stored as scaled integers, with their scale and missing codes. scaled integers in ISDfieldquantityunit storedmissingWNDwind speedtenths of m/s9999OC1wind gusttenths of m/s9999TMPair temperaturetenths of °C+9999DEWdew pointtenths of °C+9999SLPsea-level pressuretenths of hPa99999ISD-Lite uses the same scales with −9999 for missing spatialworkflow.io
Scaled integers in ISD.

ISD stores most measurements as scaled integers: temperature in tenths of a degree, pressure in tenths of a hectopascal, wind in tenths of a metre per second. Integers keep the fixed-width archive compact and avoid ambiguity about decimal separators, and every value has a documented missing code made of nines that cannot be confused with a real value of the same width. The CSV access files keep the packed fields as they are, so the scaling is the reader's job.

The scale factor is easy to miss because the result can look plausible in one context: 46, the code for 4.6 m/s, is also a believable number of knots or kilometres per hour. The missing code is easy to miss because it is rare: here one hour in the year, which barely moves a mean but sets the maximum.

Calm, gusts and special reports

Decoding ISD wind speeds: problems and fixes. ISD decoding trapsSymptomFixmedian wind 41 m/sdivide by 10: 4.1 m/smaximum 999.9 m/smask 9999 before dividingcalm hours droppedtype C: keep as 0 m/sonly 20.7% match ISD-Literound to the hour, do not floorChicago O'Hare, 2024 spatialworkflow.io
ISD decoding traps.
  • Calm: calm hours have type C and speed 0; keep them as 0 m/s, not missing.
  • Gusts: gusts are in the separate OC1 field, also in tenths of m/s.
  • Other fields: TMP and DEW are in tenths of a degree with +9999 missing; SLP in tenths of hPa with 99999 missing.
  • ISD-Lite: speeds in tenths of m/s, missing as -9999, one record per hour.
  • Special reports: FM-16 specials repeat some hours; keep FM-15 for hourly statistics.

FAQ

What units are ISD wind speeds in?

Tenths of a metre per second: divide by ten for m/s.

What does 9999 mean in NOAA ISD wind data?

A missing speed; 999 is a missing direction. Mask them before computing anything.

How do I parse the ISD WND field in Python?

Split on commas into direction, direction quality, type, speed and speed quality, read as text, then convert the speed and divide by ten.

Why does my maximum wind speed show 999.9?

The missing code 9999 was divided by ten but not masked.