Hurricane Tracks in the Wrong Hemisphere: HURDAT2's N and W Suffixes
On this page
Problem statement
HURDAT2 writes positions as 28.0N and 94.8W. Pass them to float() and you get a ValueError. Strip the letter to get a number, and every Atlantic hurricane lands in Asia, with 62.4% of fixes in the longitudes of India and Indochina. Assume every longitude is west and negate it, and 126 fixes from storms that reached Europe and the Arctic end up on the wrong side of the map. The letter is the sign, and it has to be read for every row. This page shows each failure and the parse that handles all of them.
Quick answer
Take everything but the last character as the number, then multiply by β1 when the last character is W (longitude) or S (latitude). Never assume a hemisphere for the whole file.
raw = [l.split(",") for l in open("data/hurdat2-1851-2025-092326.txt") if not l[:2].isalpha()]
lat_s = pd.Series([r[4].strip() for r in raw]); lon_s = pd.Series([r[5].strip() for r in raw])
try:
lon_s.astype(float)
except ValueError as e:
print("ValueError:", e)
naive = lon_s.str.rstrip("EW").astype(float)
print(f"suffix stripped, sign ignored: longitudes {naive.min():.1f} to {naive.max():.1f}; fixed: {hd.lon.min():.1f} to {hd.lon.max():.1f}")
ValueError: could not convert string to float: '94.8W'
suffix stripped, sign ignored: longitudes 0.0 to 136.9; fixed: -136.9 to 63.0
float("94.8W") fails on the first row. Stripping the letter gives longitudes from 0.0 to 136.9, all east. Read with the letter, they run from 136.9Β°W to 63.0Β°E.
Reading the hemisphere letters
1. Look at the suffixes
print("latitude suffixes:", lat_s.str[-1].value_counts().to_dict(), "| longitude suffixes:", lon_s.str[-1].value_counts().to_dict())
print(f"field width: latitude {lat_s.str.len().unique().tolist()}, longitude {lon_s.str.len().unique().tolist()}")
latitude suffixes: {'N': 55524} | longitude suffixes: {'W': 55396, 'E': 128}
field width: latitude [5, 4], longitude [5, 6, 4]
Every latitude in the Atlantic file is N. Longitudes are W in 55,396 fixes and E in 128. The field width varies (4 to 6 characters) because the numbers are not zero-padded, so slice from the end, not the start.
2. Apply the sign from the letter
lon = lon_s.str[:-1].astype(float) * np.where(lon_s.str[-1] == "W", -1, 1)
lat = lat_s.str[:-1].astype(float) * np.where(lat_s.str[-1] == "S", -1, 1)
print(f"matches the setup's parse: {np.allclose(lon, hd.lon) and np.allclose(lat, hd.lat)}")
matches the setup's parse: True
Applying the sign from each letter reproduces the setup's read_hurdat2() exactly. The same two lines handle S latitudes in Southern Hemisphere files.
3. The fixes east of the prime meridian
east = hd[hd.lon > 0]
print(f"fixes with an E longitude: {len(east)} in {east.sid.nunique()} storms; examples:",
east.drop_duplicates("sid")[["sid", "name"]].head(4).values.tolist())
wrong = (np.abs(naive) * -1 != hd.lon).sum()
print(f"'always west' shortcut (-abs(lon)) gets {wrong} fixes wrong")
fixes with an E longitude: 126 in 19 storms; examples: [['AL041932', 'UNNAMED'], ['AL091951', 'HOW'], ['AL051952', 'UNNAMED'], ['AL041956', 'CARLA']]
'always west' shortcut (-abs(lon)) gets 126 fixes wrong
126 fixes in 19 storms are strictly east of the prime meridian (two more sit exactly on it at 0.0E). All of them are extratropical stages of storms that crossed the North Atlantic towards Europe, Scandinavia and the Arctic; no tropical stage in the file lies east of 0Β°. A -abs(lon) shortcut moves all 126 to the western hemisphere. Each track then folds back on itself at 0Β°, drawing a spike towards the Americas.
The map with the wrong sign, and a stricter parse
The data used on this page
The National Hurricane Center's HURDAT2 Atlantic best-track file, 1851β2025.
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
# NOAA National Hurricane Center HURDAT2 best tracks: Atlantic 1851-2025 (released 23 September 2026) and
# north-east and north-central Pacific 1949-2025 (29 September 2026). NHC keeps every release under its own name.
FILES = ["hurdat2-1851-2025-092326.txt", "hurdat2-nepac-1949-2025-092926.txt"]
for name in FILES:
if not Path(f"data/{name}").exists():
Path(f"data/{name}").write_bytes(requests.get(f"https://www.nhc.noaa.gov/data/hurdat/{name}", timeout=300).content)
COLS = ["date", "time", "record", "status", "lat", "lon", "wind", "pressure"] + \
[f"r{kt}_{q}" for kt in (34, 50, 64) for q in ("ne", "se", "sw", "nw")] + ["rmw"]
def read_hurdat2(path):
"""One header line per storm (id, name, number of fixes), then one line per fix; -999 means missing."""
rows = []
for line in open(path):
f = [x.strip() for x in line.split(",")]
if f[0][:2].isalpha():
sid, name = f[0], f[1]
continue
rows.append([sid, name] + f[:21])
h = pd.DataFrame(rows, columns=["sid", "name"] + COLS)
h["time"] = pd.to_datetime(h.date + h.time, format="%Y%m%d%H%M")
h["lat"] = h.lat.str[:-1].astype(float) * np.where(h.lat.str[-1] == "S", -1, 1)
h["lon"] = h.lon.str[:-1].astype(float) * np.where(h.lon.str[-1] == "W", -1, 1)
for c in COLS[6:]:
h[c] = pd.to_numeric(h[c]).replace(-999, np.nan)
h["season"] = h.sid.str[-4:].astype(int)
h["synoptic"] = h.time.dt.strftime("%H%M").isin(["0000", "0600", "1200", "1800"])
return h.drop(columns="date")
hd = read_hurdat2("data/hurdat2-1851-2025-092326.txt")
print(f"HURDAT2 Atlantic: {hd.sid.nunique():,} storms, {len(hd):,} fixes, {hd.season.min()}-{hd.season.max()}")
HURDAT2 Atlantic: 1,988 storms, 55,524 fixes, 1851-2025
Example 1: what the wrong sign does to a map
pts = gpd.GeoSeries(gpd.points_from_xy(naive, lat), crs=4326)
print(f"with positive longitudes, fixes inside 60E-110E (India to Indochina): {pts.x.between(60, 110).mean():.1%}")
with positive longitudes, fixes inside 60E-110E (India to Indochina): 62.4%
Plotted with the sign dropped, 62.4% of Atlantic fixes fall in the longitudes of India and Indochina, where they look like a plausible set of Bay of Bengal and South China Sea cyclones. The error does not look like an error on a map without a coastline.
Example 2: a regex parse
p = lon_s.str.extract(r"^(?P<deg>\d+\.\d)(?P<hem>[EW])$")
print(f"parsed by pattern: {p.deg.notna().mean():.2%}; hemispheres {p.hem.value_counts().to_dict()}")
parsed by pattern: 100.00%; hemispheres {'W': 55396, 'E': 128}
A regular expression that requires the number and the letter parses 100.00% of longitudes and fails loudly (NaN) on anything unexpected, such as a missing letter or a stray character. Use it when you read files from several sources.
Why the file uses letters
HURDAT2's format comes from a time when positions were written the way navigators write them, with a hemisphere letter rather than a sign. A fixed-width record also avoids a column that is sometimes one character longer. HURDAT2, introduced in 2013, kept the letters, which are easy to read but are not numbers.
Related traps
- The Pacific file has
Elongitudes for storms that crossed the date line; the same parse applies, but tracks then need unwrapping at the date line. - IBTrACS stores signed decimal degrees, so code written for one source silently breaks on the other.
-999appears in the wind and pressure columns, never in the positions, so a position always parses.
Internal links
- How to read HURDAT2 β the whole format
- Pacific hurricane tracks streak across the map β the other longitude problem
- How to map a hurricane season's tracks β the tracks drawn correctly
FAQ
How do I convert HURDAT2 latitude and longitude to decimal degrees?
Drop the last character and convert to float, then negate the value when that character is W or S.
Why are my hurricane tracks plotted in Asia?
The longitudes lost their sign: 94.8W read as 94.8 is east. In the Atlantic file 62.4% of fixes then fall in the longitudes of India and Indochina.