How to Check Model Winds Against Station Observations in Python

On this page

Problem statement

A gridded wind field looks authoritative, and the only way to know how far to trust it is to compare it with measurements. Weather stations are the obvious reference, and the comparison has more decisions in it than it seems: which stations, which report time, how to get the model value at a point, how to compare directions, and which summary numbers to report. Done carefully, it tells you not just how good a model is but where it is good, which is usually the more useful answer.

This page compares the 10-metre wind of two models, the 0.25Β° GFS and the 3 km HRRR, both analyses for 00 UTC on 27 September 2024, with about 200 airport stations in Florida, Georgia, Alabama and South Carolina as Hurricane Helene approached.

Quick answer

Get the model value at each station, then report bias and root-mean-square error of speed, overall and by distance from the storm:

def scores(model, obs):
    e = model - obs
    return pd.Series({"bias": e.mean(), "rmse": np.sqrt((e ** 2).mean()), "n": len(e)})

S["gfs_speed"] = np.hypot(GFS_AT.u10.values, GFS_AT.v10.values)
S["hrrr_speed"] = np.hypot(HRRR_U, HRRR_V)
table = pd.DataFrame({m: scores(S[f"{m}_speed"], S.obs_speed) for m in ("gfs", "hrrr")}).T
print(f"{len(S)} stations with a 00 UTC wind report")
print(table.round(2).to_string())
200 stations with a 00 UTC wind report
      bias  rmse      n
gfs   1.61  2.69  200.0
hrrr -0.05  1.52  200.0
Two maps of about 200 stations in Florida, Georgia, Alabama and South Carolina coloured by model minus observed 10 m wind speed at 00 UTC on 27 September 2024, with Helene's position marked in the Gulf: the GFS panel is mostly red, positive, with the darkest red at stations on Florida's Gulf coast near Tampa and Crystal River, bias plus 1.61 and RMSE 2.69 metres per second; the HRRR panel is mostly pale with a mix of small positive and negative errors, bias minus 0.05 and RMSE 1.52. Two maps of about 200 stations in Florida, Georgia, Alabama and South Carolina coloured by model minus observed 10 m wind speed at 00 UTC on 27 September 2024, with Helene's position marked in the Gulf: the GFS panel is mostly red, positive, with the darkest red at stations on Florida's Gulf coast near Tampa and Crystal River, bias plus 1.61 and RMSE 2.69 metres per second; the HRRR panel is mostly pale with a mix of small positive and negative errors, bias minus 0.05 and RMSE 1.52.
GFS runs fast, especially near the storm; HRRR is close to unbiased.

Stations and a report time, model values at them, scores by distance

1. Choose stations and a report time

ISD-Lite gives one record per station per hour, the observation nearest the top of the hour, with direction in degrees and speed in tenths of a metre per second; -9999 is missing:

print(f"stations listed: {len(STATIONS)}; with a file for 2024: {len(FILES)}; with a 00 UTC record: {len(OBS)}")
print(f"missing speed: {(OBS.ws == -9999).sum()}; missing direction: {(OBS.wd == -9999).sum()}; calm (speed 0): {(OBS.ws == 0).sum()}")
stations listed: 222; with a file for 2024: 222; with a 00 UTC record: 202
missing speed: 2; missing direction: 9; calm (speed 0): 7

2. Get the model at the stations

The GFS is on a regular latitude-longitude grid, so bilinear interpolation is one call. HRRR's grid is projected, so the nearest grid point is found with a KD-tree in the projection's metres, and its winds are rotated to true north:

print(f"nearest HRRR grid point: median {np.median(HRRR_DIST) / 1000:.2f} km away, farthest {HRRR_DIST.max() / 1000:.2f} km")
print(f"HRRR rotation at the stations: {np.degrees(ALPHA).min():.1f}Β° to {np.degrees(ALPHA).max():.1f}Β°")
nearest HRRR grid point: median 1.13 km away, farthest 2.04 km
HRRR rotation at the stations: 5.8Β° to 11.7Β°

3. Score by distance from the storm

An overall score hides where the errors are. Splitting by distance from Helene's best-track position separates the storm from the background:

S["near"] = np.where(S.km < 300, "within 300 km", "beyond 300 km")
for m in ("gfs", "hrrr"):
    print(m.upper(), S.groupby("near").apply(lambda d: scores(d[f"{m}_speed"], d.obs_speed), include_groups=False).round(2).to_string(), sep="\n")
GFS
               bias  rmse      n
near                            
beyond 300 km  1.41  2.36  162.0
within 300 km  2.47  3.81   38.0
HRRR
               bias  rmse      n
near                            
beyond 300 km  0.02  1.37  162.0
within 300 km -0.32  2.04   38.0
Scatter of observed against model 10 m wind speed for each station and both models, with a dashed one-to-one line: GFS points, orange, lie mostly above the line, some by 7 to 11 metres per second; HRRR points, blue, straddle it. Scatter of observed against model 10 m wind speed for each station and both models, with a dashed one-to-one line: GFS points, orange, lie mostly above the line, some by 7 to 11 metres per second; HRRR points, blue, straddle it.
Each station twice, against perfect agreement.

Directions where defined, and the worst stations

The data used on this page

NOAA's ISD-Lite hourly files for 2024 for every station in the ISD station list with an ICAO code in Florida, Georgia, Alabama and South Carolina; the GFS 0.25Β° and HRRR 3 km analyses for 00 UTC on 27 September 2024 from AWS (GFS, HRRR), 10 m UGRD and VGRD only; and the NHC best track for Helene.

import gzip
from pathlib import Path

import numpy as np
import pandas as pd
import pyproj
import requests
import xarray as xr
from scipy.spatial import cKDTree

Path("data").mkdir(exist_ok=True)

def fetch(url, path):
    p = Path("data", path)
    if not p.exists():
        r = requests.get(url, timeout=120)
        if r.status_code != 200:
            return None
        p.write_bytes(r.content)
    return p

def grib_subset(url, path):
    p = Path("data", path)
    if not p.exists():
        lines = requests.get(url + ".idx", timeout=60).text.splitlines()
        starts = [int(l.split(":")[1]) for l in lines]
        with open(p, "wb") as f:
            for i, l in enumerate(lines):
                if ":UGRD:10 m above ground:" in l or ":VGRD:10 m above ground:" in l:
                    f.write(requests.get(url, headers={"Range": f"bytes={starts[i]}-{starts[i + 1] - 1}"}, timeout=120).content)
    return xr.open_dataset(p, engine="cfgrib", decode_timedelta=False, backend_kwargs={
        "indexpath": "", "filter_by_keys": {"typeOfLevel": "heightAboveGround", "level": 10}}).load()

hist = pd.read_csv(fetch("https://www.ncei.noaa.gov/pub/data/noaa/isd-history.csv", "isd-history.csv"), dtype=str)
STATIONS = hist[(hist.CTRY == "US") & hist.STATE.isin(["FL", "GA", "AL", "SC"]) & hist.ICAO.notna() & (hist.WBAN != "99999")
                & (hist.BEGIN <= "20240101") & (hist.END >= "20241001")].copy()
STATIONS["id"] = STATIONS.USAF + "-" + STATIONS.WBAN
FILES, rows = [], []
for sid in STATIONS.id:
    f = fetch(f"https://www.ncei.noaa.gov/pub/data/noaa/isd-lite/2024/{sid}-2024.gz", f"{sid}-2024.gz")
    if f is None:
        continue
    FILES.append(sid)
    d = pd.read_fwf(gzip.open(f, "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"])
    r = d[(d.m == 9) & (d.d == 27) & (d.h == 0)]
    if len(r):
        rows.append((sid, int(r.wd.iloc[0]), int(r.ws.iloc[0])))
OBS = pd.DataFrame(rows, columns=["id", "wd", "ws"]).merge(STATIONS[["id", "STATION NAME", "LAT", "LON"]], on="id")
S = OBS[OBS.ws != -9999].astype({"LAT": float, "LON": float}).reset_index(drop=True)
S["obs_speed"] = S.ws / 10

gfs = grib_subset("https://noaa-gfs-bdp-pds.s3.amazonaws.com/gfs.20240927/00/atmos/gfs.t00z.pgrb2.0p25.f000", "gfs_helene.grib2")
GFS_AT = gfs.interp(latitude=xr.DataArray(S.LAT, dims="s"), longitude=xr.DataArray(S.LON % 360, dims="s"))
hrrr = grib_subset("https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20240927/conus/hrrr.t00z.wrfsfcf00.grib2", "hrrr_00z.grib2")
lcc = pyproj.CRS.from_proj4("+proj=lcc +lat_1=38.5 +lat_2=38.5 +lat_0=38.5 +lon_0=-97.5 +R=6371229")
to_lcc = pyproj.Transformer.from_crs(4326, lcc, always_xy=True)
gx, gy = to_lcc.transform(hrrr.longitude.values - 360, hrrr.latitude.values)
HRRR_DIST, idx = cKDTree(np.column_stack([gx.ravel(), gy.ravel()])).query(np.column_stack(to_lcc.transform(S.LON, S.LAT)))
ALPHA = np.radians(pyproj.Proj(lcc).get_factors(S.LON.values, S.LAT.values).meridian_convergence)
ug, vg = hrrr.u10.values.ravel()[idx], hrrr.v10.values.ravel()[idx]
HRRR_U, HRRR_V = np.cos(ALPHA) * ug + np.sin(ALPHA) * vg, -np.sin(ALPHA) * ug + np.cos(ALPHA) * vg
S["km"] = pyproj.Geod(ellps="WGS84").inv(S.LON.values, S.LAT.values, np.full(len(S), -84.3), np.full(len(S), 28.7))[2] / 1000
print(f"{len(S)} stations; GFS and HRRR at 00 UTC 27 Sep 2024; Helene at 28.7Β°N 84.3Β°W (best track)")
200 stations; GFS and HRRR at 00 UTC 27 Sep 2024; Helene at 28.7Β°N 84.3Β°W (best track)

Example 1: directions, where they are defined

Compare directions only where both the station and the model have a wind worth a direction, here above 2 m/s, and wrap the difference:

def dirn(u, v):
    return np.degrees(np.arctan2(-u, -v)) % 360

ok = (S.wd != -9999) & (S.obs_speed > 2)
for m, (u, v) in {"gfs": (GFS_AT.u10.values, GFS_AT.v10.values), "hrrr": (HRRR_U, HRRR_V)}.items():
    err = (dirn(u, v)[ok] - S.wd[ok] + 180) % 360 - 180
    print(f"{m.upper()}: {ok.sum()} stations, median |direction error| {np.median(np.abs(err)):.0f}Β°, mean error {err.mean():+.0f}Β°")
GFS: 182 stations, median |direction error| 11Β°, mean error +8Β°
HRRR: 182 stations, median |direction error| 7Β°, mean error -1Β°

Example 2: the worst stations

S["gfs_err"] = S.gfs_speed - S.obs_speed
print(S.nlargest(5, "gfs_err")[["STATION NAME", "km", "obs_speed", "gfs_speed", "hrrr_speed"]].round(1).to_string(index=False))
                             STATION NAME    km  obs_speed  gfs_speed  hrrr_speed
                    CRYSTAL RIVER AIRPORT 170.2        7.7       18.9        12.4
              TAMPA INTERNATIONAL AIRPORT 190.9       12.9       20.9        13.1
                       VANDENBERG AIRPORT 206.2       10.8       17.9        13.9
                        INVERNESS AIRPORT 194.1        8.2       15.3        10.2
MT PLEASANT REGIONAL AIRPORT FASION FIELD 635.2        0.0        6.5         3.2

Four of the five largest GFS errors are within about 200 km of Helene's centre, where the model put 15 to 21 m/s and the stations measured 8 to 13. HRRR's values at the same stations are much closer.

Bias and RMSE answer different questions

Speed bias and RMSE of GFS and HRRR 10 m wind against about 200 stations, overall and by distance from Helene. scores, overall and by distance from the stormstationsGFS bias / RMSEHRRR bias / RMSEall200+1.61 / 2.69βˆ’0.05 / 1.52within 300 km38+2.47 / 3.81βˆ’0.32 / 2.04beyond 300 km162+1.41 / 2.36+0.02 / 1.37direction, > 2 m/s182median 11Β°median 7Β°m/s; FL, GA, AL and SC stations, 00 UTC 27 September 2024 spatialworkflow.io
Scores overall and by distance from the storm.

Bias and RMSE answer different questions. Bias is the average error and says whether a model runs high or low; RMSE combines that with scatter and says how far off a single value typically is. A model can have no bias and a large RMSE if its errors cancel. Reporting both, with the number of stations, is the minimum; splitting by region, distance or terrain is where the comparison becomes informative.

Station and grid values are not the same thing. An anemometer measures one point 10 m above an airfield, averaged over two minutes; a grid value is an average over a cell, 27 km wide for GFS and 3 km for HRRR, and over the model's time step. Near coasts the difference is systematic: a coarse cell mixes the stronger winds over water with the weaker ones over land, which is part of why the GFS errors here are largest at stations near the storm and the Gulf coast, such as Crystal River and Tampa. Some of what looks like model error is representativeness, not mistakes.

Report times, light winds and independence

Comparing model winds with stations: problems and fixes. verification trapsTrapInsteadone overall scoresplit: near the storm GFS +2.47m/sdirections at calm stationsonly above 2 m/s: 182 of 200HRRR winds unrotatedrotate: 5.8–11.7Β° at thesestationsgaps read as model errora cell average vs one anemometerGFS and HRRR analyses against ISD-Lite, Helene spatialworkflow.io
Verification traps.
  • Report time: ISD-Lite takes the report nearest the hour; full ISD lets you pick the routine METAR at :53.
  • Calm and light winds: direction errors are meaningless at very low speeds; set a threshold and report it.
  • Missing stations: stations in the worst weather often stop reporting; their absence biases scores towards calm areas.
  • Height: some stations measure above or below 10 m; check the station metadata for anything unusual.
  • Independence: analyses assimilate many of these observations, so analysis scores flatter the model; forecasts give a fairer test.

FAQ

How do I compare model winds with weather station data in Python?

Interpolate the model to station locations, compute speed and direction, and report bias and RMSE of speed and the wrapped direction error.

Where can I get hourly station wind data?

NOAA's ISD-Lite gives one record per station per hour as small text files; the full ISD has every report.

What is a good RMSE for 10 m wind speed?

It depends on the weather and terrain; here HRRR scored 1.52 m/s and GFS 2.69 m/s across about 200 stations near a hurricane.

Why does a coarse model overestimate wind at coastal stations?

Its grid cells average winds over water and land, and winds over water are stronger.