How to Calculate Wind Speed and Direction From U and V in Python

On this page

Problem statement

Weather models store wind as two components: u, the eastward part, and v, the northward part, both in metres per second. Stations, forecasts and people describe wind as a speed and a direction, and the direction follows a meteorological convention: the compass bearing the wind blows from, measured clockwise from north. A westerly wind blows from the west towards the east, so it has a positive u, and its direction is 270°. Getting from one form to the other is two lines of NumPy, and getting the convention wrong turns every arrow around.

This page converts GFS 10-metre winds at 00 UTC on 27 September 2024, as Hurricane Helene approached Florida, into speed and direction, interpolates them to eight airports in Florida and Georgia, and compares them with what the airports reported.

Quick answer

Speed is the length of the vector; the direction the wind comes from is the angle of the reversed vector, measured from north:

def speed_direction(u, v):
    speed = np.hypot(u, v)
    direction = np.degrees(np.arctan2(-u, -v)) % 360          # from which the wind blows, clockwise from north
    return speed, np.where(speed > 0, direction, np.nan)      # no direction when calm

for name, (u, v) in {"from the west": (5, 0), "from the south": (0, 5), "from the north-east": (-3.5, -3.5)}.items():
    s, d = speed_direction(u, v)
    print(f"u = {u:+.1f}, v = {v:+.1f}  →  {s:.1f} m/s from {d:.0f}°  ({name})")
u = +5.0, v = +0.0  →  5.0 m/s from 270°  (from the west)
u = +0.0, v = +5.0  →  5.0 m/s from 180°  (from the south)
u = -3.5, v = -3.5  →  4.9 m/s from 45°  (from the north-east)
Map of north Florida and south Georgia at 00 UTC on 27 September 2024 with GFS 10 m wind speed shaded and grey arrows, and red arrows at seven airports showing the reported wind, each labelled with the GFS and reported direction and speed: the arrows agree in direction everywhere, from north-easterlies at Tallahassee and Apalachicola to south-easterlies at Jacksonville; Tampa's GFS speed of 20.9 metres per second is far above its reported 12.9; Cross City has no report. Map of north Florida and south Georgia at 00 UTC on 27 September 2024 with GFS 10 m wind speed shaded and grey arrows, and red arrows at seven airports showing the reported wind, each labelled with the GFS and reported direction and speed: the arrows agree in direction everywhere, from north-easterlies at Tallahassee and Apalachicola to south-easterlies at Jacksonville; Tampa's GFS speed of 20.9 metres per second is far above its reported 12.9; Cross City has no report.
Model and airport winds side by side, as speed and the direction the wind comes from.

Interpolate to stations, read observations, compare on the circle

1. Interpolate the model to the stations

GFS longitudes run from 0 to 360, so station longitudes need 360 added before interpolating:

pts = STATIONS.set_index("icao")
at = WIND.interp(latitude=xr.DataArray(pts.lat, dims="icao"), longitude=xr.DataArray(pts.lon % 360, dims="icao"))
pts["gfs_speed"], pts["gfs_dir"] = speed_direction(at.u10.values, at.v10.values)
print(pts[["name", "gfs_speed", "gfs_dir"]].round(1).to_string())
                                    name  gfs_speed  gfs_dir
icao                                                        
KJAX  Jacksonville International Airport        9.6    126.9
KTPA         Tampa International Airport       20.9    170.3
KCTY                  Cross City Airport       10.3    110.9
KTLH        Tallahassee Regional Airport        6.3     38.1
KVLD           Valdosta Regional Airport        7.9     87.0
KAAF                Apalachicola Airport       12.2      0.7
KGNV        Gainesville Regional Airport        9.3    126.3
KMAI          Marianna Municipal Airport        7.0     26.0

2. Read the station observations

The ISD WND field packs direction, a quality flag, a type code, speed in tenths of a metre per second, and another flag. Take the routine hourly report closest to the model time:

def parse_wnd(wnd):
    d, dq, kind, s, sq = wnd.split(",")
    direction = np.nan if d == "999" or kind == "C" else float(d)
    speed = np.nan if s == "9999" else int(s) / 10
    return direction, speed

near = OBS[(OBS.REPORT_TYPE.str.strip() == "FM-15") & (abs(OBS.time - MODEL_TIME) <= pd.Timedelta("10min"))]
near = near.assign(obs_dir=[parse_wnd(w)[0] for w in near.WND], obs_speed=[parse_wnd(w)[1] for w in near.WND])
pts = pts.join(near.set_index("icao")[["time", "obs_dir", "obs_speed"]])
print(pts[["obs_dir", "obs_speed", "time"]].to_string())
      obs_dir  obs_speed                time
icao                                        
KJAX    120.0       10.8 2024-09-26 23:56:00
KTPA    160.0       12.9 2024-09-26 23:53:00
KCTY      NaN        NaN                 NaT
KTLH     40.0        6.7 2024-09-26 23:53:00
KVLD     80.0        4.6 2024-09-26 23:53:00
KAAF     10.0       13.4 2024-09-26 23:53:00
KGNV    110.0        9.8 2024-09-26 23:53:00
KMAI     10.0        5.7 2024-09-26 23:53:00

Cross City, close to where Helene came ashore three hours later, has no routine report in that window; a comparison has to allow for stations that go quiet when the weather is worst.

3. Compare directions on the circle

A direction difference has to wrap: 350° and 10° are 20° apart, not 340°:

diff = (pts.gfs_dir - pts.obs_dir + 180) % 360 - 180
pts["dir_error"], pts["speed_error"] = diff, pts.gfs_speed - pts.obs_speed
print(pts[["gfs_dir", "obs_dir", "dir_error", "gfs_speed", "obs_speed", "speed_error"]].round(1).to_string())
print(f"median absolute direction error {pts.dir_error.abs().median():.0f}°; median speed error {pts.speed_error.median():+.1f} m/s")
      gfs_dir  obs_dir  dir_error  gfs_speed  obs_speed  speed_error
icao                                                                
KJAX    126.9    120.0        6.9        9.6       10.8         -1.2
KTPA    170.3    160.0       10.3       20.9       12.9          8.0
KCTY    110.9      NaN        NaN       10.3        NaN          NaN
KTLH     38.1     40.0       -1.9        6.3        6.7         -0.4
KVLD     87.0     80.0        7.0        7.9        4.6          3.3
KAAF      0.7     10.0       -9.3       12.2       13.4         -1.2
KGNV    126.3    110.0       16.3        9.3        9.8         -0.5
KMAI     26.0     10.0       16.0        7.0        5.7          1.3
median absolute direction error 9°; median speed error -0.4 m/s

The directions agree within 17° everywhere, and most speeds within about a metre per second. Tampa is the exception, 20.9 m/s in the model against 12.9 observed: the airport is on the shore of Tampa Bay, and a grid cell 27 km across averages the faster winds over water with the slower ones over land.

GFS 10 m wind interpolated to seven airports against their reports near 00 UTC on 27 September 2024. model against airport, direction and speedairportGFSreporteddifferenceJacksonville127° 9.6120° 10.8+7° −1.2Tampa170° 20.9160° 12.9+10° +8.0Tallahassee38° 6.340° 6.7−2° −0.4Valdosta87° 7.980° 4.6+7° +3.3Apalachicola1° 12.210° 13.4−9° −1.2Gainesville126° 9.3110° 9.8+16° −0.5Marianna26° 7.010° 5.7+16° +1.3Direction the wind blows from; speed in m/s. Cross City sent no report spatialworkflow.io
Directions within 16 degrees; speeds mostly within a metre per second, except Tampa.

The same result from MetPy, and back again

The data used on this page

The GFS 0.25° analysis for 00 UTC on 27 September 2024 from the NOAA GFS bucket on AWS, 10 m UGRD and VGRD only, and NOAA's Integrated Surface Database (Global Hourly) for eight airports, located from the ISD station list.

from pathlib import Path

import numpy as np
import pandas as pd
import requests
import xarray as xr

Path("data").mkdir(exist_ok=True)
GFS = "https://noaa-gfs-bdp-pds.s3.amazonaws.com/gfs.20240927/00/atmos/gfs.t00z.pgrb2.0p25.f000"
G = Path("data/gfs_helene.grib2")
if not G.exists():                                             # the two 10 m wind messages, by byte range
    lines = requests.get(GFS + ".idx", timeout=60).text.splitlines()
    starts = [int(l.split(":")[1]) for l in lines]
    with open(G, "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(GFS, headers={"Range": f"bytes={starts[i]}-{starts[i + 1] - 1}"}, timeout=120).content)
WIND = xr.open_dataset(G, engine="cfgrib", decode_timedelta=False, backend_kwargs={
    "indexpath": "", "filter_by_keys": {"typeOfLevel": "heightAboveGround", "level": 10}}).load()
MODEL_TIME = pd.Timestamp(WIND.time.values)

IDS = {"KJAX": "72206013889", "KTPA": "72211012842", "KCTY": "72212012833", "KTLH": "72214093805",
       "KVLD": "72216693845", "KAAF": "72220012832", "KGNV": "74756012816", "KMAI": "74776003818"}
frames = []
for icao, sid in IDS.items():
    f = Path(f"data/{sid}.csv")
    if not f.exists():
        f.write_bytes(requests.get(f"https://noaa-global-hourly-pds.s3.amazonaws.com/2024/{sid}.csv", timeout=300).content)
    d = pd.read_csv(f, usecols=["DATE", "REPORT_TYPE", "NAME", "LATITUDE", "LONGITUDE", "WND"], dtype=str)
    frames.append(d.assign(icao=icao))
OBS = pd.concat(frames)
OBS["time"] = pd.to_datetime(OBS.DATE)
STATIONS = (OBS.drop_duplicates("icao")[["icao", "NAME", "LATITUDE", "LONGITUDE"]]
            .rename(columns={"NAME": "name", "LATITUDE": "lat", "LONGITUDE": "lon"}).astype({"lat": float, "lon": float}))
STATIONS["name"] = STATIONS.name.str.split(",").str[0].str.title()
print(f"GFS analysis at {MODEL_TIME:%Y-%m-%d %H:%M} UTC; {len(STATIONS)} stations, {len(OBS):,} reports in 2024")
GFS analysis at 2024-09-27 00:00 UTC; 8 stations, 110,088 reports in 2024

Example 1: the same result from MetPy

MetPy's wind_direction uses the same convention and returns 0° only for calm, with northerly winds at 360°:

import metpy.calc as mpcalc
from metpy.units import units

d_mp = mpcalc.wind_direction(at.u10.values * units("m/s"), at.v10.values * units("m/s")).m
print(f"largest difference from our function: {np.abs((d_mp - pts.gfs_dir.values + 180) % 360 - 180).max():.1e}°")
for u, v in ((0.0, -5.0), (0.0, 0.0)):
    print(f"u = {u}, v = {v}: MetPy {mpcalc.wind_direction(u * units('m/s'), v * units('m/s')).m:.0f}°, ours {speed_direction(u, v)[1]:.0f}°")
largest difference from our function: 0.0e+00°
u = 0.0, v = -5.0: MetPy 360°, ours 0°
u = 0.0, v = 0.0: MetPy 0°, ours nan°

Example 2: back from speed and direction to components

Station observations need the reverse to be averaged or compared as vectors:

obs = pts.dropna(subset=["obs_dir"])
rad = np.radians(obs.obs_dir)
u_obs, v_obs = -obs.obs_speed * np.sin(rad), -obs.obs_speed * np.cos(rad)
print(pd.DataFrame({"u": u_obs.round(2), "v": v_obs.round(2), "back to direction": speed_direction(u_obs, v_obs)[1].round(0)}).to_string())
         u      v  back to direction
icao                                
KJAX -9.35   5.40              120.0
KTPA -4.41  12.12              160.0
KTLH -4.31  -5.13               40.0
KVLD -4.53  -0.80               80.0
KAAF -2.33 -13.20               10.0
KGNV -9.21   3.35              110.0
KMAI -0.99  -5.61               10.0

Components are what the arithmetic needs

Four wind vectors as u and v components and as meteorological direction. components and the direction they come fromblows towardsdirection (from)nameu = +5, v = 0east270°westerlyu = 0, v = +5north180°southerlyu = −3.5, v = −3.5south-west45°north-easterlyu = 0, v = 0nowherenone (calm)calmdirection = atan2(−u, −v) in degrees, mod 360; undefined when calm spatialworkflow.io
Components and the direction they come from.

The components are what the model's equations use, and they are the right form for any arithmetic: averaging, interpolating and differencing all work on u and v, never on directions. The direction convention exists for people. Meteorologists name winds after where they come from, because that is where the weather comes from; oceanographers name currents after where they go. The two conventions differ by exactly 180°, and the formula atan2(-u, -v) is atan2(u, v) turned around.

A model value at a point is not an observation. The GFS grid cell is about 27 km across, and a station measures one spot 10 m above an airfield, averaged over two minutes. Near a hurricane the gradient across one cell is steep, and the timing differs too: the METAR closest to 00 UTC was taken at 23:53.

Calm, north and grid-relative winds

Converting u and v to speed and direction: problems and fixes. speed and direction trapsTrapInsteadatan2(v, u) as the directionatan2(−u, −v): from, clockwisefrom northcalm given a direction (180°)NaN where speed is zerodirections subtracted directlywrap: (a − b + 180) mod 360 − 180station longitudes −180 to 180add 360 for the GFS gridGFS 10 m wind and ISD airport reports, Helene, 27 September 2024 spatialworkflow.io
Speed and direction traps.
  • Calm: direction is undefined when speed is zero. Without the np.where, arctan2 of two negative zeros gives 180°, a southerly that is not there; ISD marks calm with type C, and MetPy returns 0°.
  • North: report northerly winds as 360°, keeping 0° for calm, as aviation does; % 360 gives 0°.
  • Missing values: ISD uses 999 for direction and 9999 for speed.
  • Grid-relative winds: on rotated or Lambert grids such as HRRR's, u and v follow the grid, not east and north.
  • Units: ISD speeds are in metres per second times ten; knots are m/s × 1.94384.

FAQ

How do I calculate wind direction from u and v?

Use np.degrees(np.arctan2(-u, -v)) % 360, which gives the direction the wind blows from, clockwise from north.

How do I calculate wind speed from u and v?

np.hypot(u, v), the length of the wind vector.

What does a positive u wind mean?

Wind blowing towards the east, which is a westerly wind with a direction of 270°.

How do I convert wind speed and direction to u and v?

u = -speed × sin(direction) and v = -speed × cos(direction), with the direction in radians.