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°
Two maps of the contiguous United States with about 2,240 sample points coloured by HRRR minus GFS 10 m wind direction: with HRRR as stored, points west of 97.5 degrees west are mostly red, positive, and points to the east mostly blue, negative, median absolute difference 12.7 degrees; after rotation the colours are mixed with no east-west pattern, median 7.0 degrees. Two maps of the contiguous United States with about 2,240 sample points coloured by HRRR minus GFS 10 m wind direction: with HRRR as stored, points west of 97.5 degrees west are mostly red, positive, and points to the east mostly blue, negative, median absolute difference 12.7 degrees; after rotation the colours are mixed with no east-west pattern, median 7.0 degrees.
The grid-relative signature, before and after rotation.

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°
Bar chart of the mean HRRR minus GFS direction in five longitude bands: as stored plus 14.5, plus 5.6, minus 2.7, minus 13.1 and minus 14.0 degrees from west to east; rotated minus 1.3, minus 2.1, minus 3.1, minus 5.4 and plus 1.3. Bar chart of the mean HRRR minus GFS direction in five longitude bands: as stored plus 14.5, plus 5.6, minus 2.7, minus 13.1 and minus 14.0 degrees from west to east; rotated minus 1.3, minus 2.1, minus 3.1, minus 5.4 and plus 1.3.
The error changes sign across 97.5 degrees west; rotation removes it.

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

Mean HRRR minus GFS wind direction in the far west and the east, with HRRR as stored, rotated with the right sign and with the wrong sign. one rotation, two possible signsHRRR windswest of 110°Weast of 85°Wmedian |diff|as stored+14.5° (135–115°W)−14.0° (80–60°W)12.7°rotated, right sign−1.1°−0.3°7.0°rotated, wrong sign+26.9°−27.8°–u_e = cos α·u + sin α·v, v_e = −sin α·u + cos α·v, α = meridian convergence spatialworkflow.io
One rotation, two possible signs: test once.

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

HRRR wind directions: problems and fixes. grid-relative wind trapsSymptomFixdirections drift east and westcheck uvRelativeToGrid = 1a hand-coded anglepyproj meridian_convergenceerror doubles after rotatingwrong sign: test against GFS oncespeeds blamed toorotation leaves speed unchangedHRRR and GFS, 00 UTC 27 September 2024 spatialworkflow.io
Grid-relative wind traps.
  • 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.

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.