Why Are My HRRR Wind Directions Wrong?
On this page
Problem statement
You compute wind direction from HRRR's u and v and compare it with another source. Along the middle of the country the directions agree; towards the coasts they drift apart, turned one way in the east and the other way in the west, by up to about 20°. Speeds agree everywhere. The cause is in the GRIB header: HRRR's wind components are relative to its Lambert conformal grid, not to true north and east, and the grid is turned against the meridians everywhere except along 97.5°W.
This page measures the error by comparing HRRR's 10-metre winds with the GFS, whose components are earth-relative, at 00 UTC on 27 September 2024, before and after rotating them.
Quick answer
Check the GRIB flag, compute the meridian convergence with pyproj, and rotate:
print(f"uvRelativeToGrid = {H.u10.attrs['GRIB_uvRelativeToGrid']} (1: components follow the grid)")
alpha = np.radians(pyproj.Proj(LCC).get_factors(LON, LAT).meridian_convergence)
u_e = np.cos(alpha) * H.u10.values + np.sin(alpha) * H.v10.values
v_e = -np.sin(alpha) * H.u10.values + np.cos(alpha) * H.v10.values
for name, (u, v) in {"as stored": (H.u10.values, H.v10.values), "rotated": (u_e, v_e)}.items():
err = wrap(direction(u, v)[S] - GFS_DIR)
print(f"{name:>9}: median |difference from GFS| {np.median(np.abs(err[OK])):.1f}°")
uvRelativeToGrid = 1 (1: components follow the grid)
as stored: median |difference from GFS| 12.7°
rotated: median |difference from GFS| 7.0°
Find the pattern, take the angle from the projection, check the sign
1. Find the pattern
Grid-relative winds leave a signature that no other error has: the direction error changes sign across the central meridian and grows with distance from it, while speed is unaffected:
raw = wrap(direction(H.u10.values, H.v10.values)[S] - GFS_DIR)
bands = pd.cut(LON[S][OK], [-135, -115, -105, -90, -80, -60]) # longitude bands, west to east
print(pd.Series(raw[OK]).groupby(bands, observed=True).agg(["mean", "count"]).round(1).to_string())
spd_ratio = np.hypot(H.u10.values, H.v10.values)[S] / np.hypot(u_e, v_e)[S]
print(f"speed before ÷ after rotation: {spd_ratio.min():.6f} to {spd_ratio.max():.6f}")
mean count
(-135, -115] 14.5 508
(-115, -105] 5.6 372
(-105, -90] -2.7 567
(-90, -80] -13.1 339
(-80, -60] -14.0 454
speed before ÷ after rotation: 1.000000 to 1.000000
2. Get the angle from the projection, not a guess
The angle is the meridian convergence of HRRR's projection, which pyproj computes from the projection's parameters. For a Lambert conformal projection with one standard parallel it equals sin(38.5°) times the longitude difference from 97.5°W:
simple = np.sin(np.radians(38.5)) * (LON + 97.5)
print(f"pyproj convergence vs sin(38.5°)·(lon − lon₀): largest difference {np.abs(np.degrees(alpha) - simple).max():.2e}°")
print(f"angle across the domain: {np.degrees(alpha).min():.1f}° to {np.degrees(alpha).max():.1f}°")
pyproj convergence vs sin(38.5°)·(lon − lon₀): largest difference 3.22e-10°
angle across the domain: -22.8° to 22.8°
3. Check the sign once, against data
There are two possible signs, and published formulas use both depending on how the angle is defined. Rotating the wrong way doubles the error instead of removing it:
u_w = np.cos(-alpha) * H.u10.values + np.sin(-alpha) * H.v10.values
v_w = -np.sin(-alpha) * H.u10.values + np.cos(-alpha) * H.v10.values
for name, (u, v) in {"right sign": (u_e, v_e), "wrong sign": (u_w, v_w)}.items():
err = wrap(direction(u, v)[S] - GFS_DIR)
print(f"{name}: mean difference east of 85°W {err[OK & (LON[S] > -85)].mean():+.1f}°, west of 110°W {err[OK & (LON[S] < -110)].mean():+.1f}°")
right sign: mean difference east of 85°W -0.3°, west of 110°W -1.1°
wrong sign: mean difference east of 85°W -27.8°, west of 110°W +26.9°
The same rotation with xarray
The data used on this page
The HRRR and GFS analyses for 00 UTC on 27 September 2024 from AWS (HRRR, GFS), 10 m UGRD and VGRD only. GFS components are earth-relative; HRRR is sampled every 25th grid point in each direction, about 75 km apart, and the GFS interpolated to the same points.
from pathlib import Path
import numpy as np
import pandas as pd
import pyproj
import requests
import xarray as xr
Path("data").mkdir(exist_ok=True)
def grib_wind(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()
H = grib_wind("https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20240927/conus/hrrr.t00z.wrfsfcf00.grib2", "hrrr_00z.grib2")
G = grib_wind("https://noaa-gfs-bdp-pds.s3.amazonaws.com/gfs.20240927/00/atmos/gfs.t00z.pgrb2.0p25.f000", "gfs_helene.grib2")
LAT, LON = H.latitude.values, H.longitude.values - 360
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")
S = (slice(None, None, 25), slice(None, None, 25)) # a sample of grid points, ~75 km apart
def direction(u, v):
return np.degrees(np.arctan2(-u, -v)) % 360
def wrap(a):
return (a + 180) % 360 - 180
g = G.interp(latitude=xr.DataArray(LAT[S], dims=("y", "x")), longitude=xr.DataArray(LON[S] % 360, dims=("y", "x")))
GFS_DIR = direction(g.u10.values, g.v10.values)
OK = np.hypot(g.u10.values, g.v10.values) > 3 # directions only where the wind is not light
print(f"{OK.size} sample points, {OK.sum()} with GFS wind above 3 m/s")
3096 sample points, 2240 with GFS wind above 3 m/s
Example 1: the same fix with xarray
Keep the coordinates by building the rotated fields as DataArrays:
a = xr.DataArray(alpha, dims=("y", "x"))
H["u_earth"] = np.cos(a) * H.u10 + np.sin(a) * H.v10
H["v_earth"] = -np.sin(a) * H.u10 + np.cos(a) * H.v10
print(list(H.data_vars), H.u_earth.dims)
['u10', 'v10', 'u_earth', 'v_earth'] ('y', 'x')
Lambert grids and meridian convergence
A Lambert conformal grid is built on a cone that touches the earth along 38.5°N. Its columns are straight lines on the cone, which become meridians only at the central longitude; elsewhere, true north is turned relative to the grid's "up" by the meridian convergence, which grows steadily with distance east or west. Models integrate their equations along their own grid, so HRRR, the North American Mesoscale model and many regional models write u and v along the grid axes. The GRIB2 flag resolutionAndComponentFlags records this, and eccodes exposes it as uvRelativeToGrid.
Speed is unaffected because rotation does not change a vector's length. Anything that uses direction is affected: wind roses, wind barbs and arrows on a geographic map, comparisons with stations, and conversions to compass bearings. Some tools rotate automatically and some do not; checking the flag and testing against an earth-relative source once settles it.
Other models, already-rotated data and calm points
- Other models: NAM, RAP and many WRF outputs are grid-relative too; global latitude-longitude models such as GFS are not.
- Already rotated: some converters, such as
wgrib2 -new_grid_winds earth, rotate on the way out; check the flag in the output. - The earth's shape: use the radius in the GRIB header; for the rotation angle it makes no practical difference.
- Projected maps: to draw winds on another projection, rotate to earth-relative first, then to the map's own axes.
- Calm points: rotation of a zero vector is zero; mask calm before computing directions.
Internal links
- How to read HRRR wind data in Python — the full HRRR workflow
- Why are my wind directions off by 180 degrees? — the other common direction error
FAQ
Are HRRR winds grid-relative?
Yes. The GRIB messages set uvRelativeToGrid = 1: u and v follow the Lambert conformal grid's x and y axes.
How large is the HRRR wind rotation?
From about −23° on the west edge of the domain to +23° on the east edge, zero along 97.5°W.
How do I rotate grid-relative winds in Python?
Compute the meridian convergence with pyproj.Proj(crs).get_factors(lon, lat).meridian_convergence, then u_e = cos α · u + sin α · v and v_e = −sin α · u + cos α · v.
Does rotation change wind speed?
No; it changes direction only.