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.

World map from the Americas to east Asia with Atlantic best-track fixes in blue over the Atlantic and the same fixes in red mirrored over Africa, the Indian Ocean and Asia, where they appear when the W is dropped. World map from the Americas to east Asia with Atlantic best-track fixes in blue over the Atlantic and the same fixes in red mirrored over Africa, the Indian Ocean and Asia, where they appear when the W is dropped.
The Atlantic file read correctly (blue) and with the hemisphere letter dropped (red): 62.4% of fixes land in the longitudes of India and Indochina.

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.

Two maps of the North Atlantic and Europe: on the left, 19 storm tracks reaching Europe, Scandinavia and the Arctic; on the right the same tracks folded back at the prime meridian into spikes pointing west. Two maps of the North Atlantic and Europe: on the left, 19 storm tracks reaching Europe, Scandinavia and the Arctic; on the right the same tracks folded back at the prime meridian into spikes pointing west.
The 19 storms with E longitudes, read correctly and with the 'always west' shortcut, which gets 126 fixes wrong.

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.

Two histograms of longitude: correct values in blue between about 100Β°W and 0Β°, and the sign-lost values in red mirrored between 0Β° and 100Β°E. Two histograms of longitude: correct values in blue between about 100Β°W and 0Β°, and the sign-lost values in red mirrored between 0Β° and 100Β°E.
With the sign lost, every longitude is mirrored across the prime meridian.

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 E longitudes 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.
  • -999 appears in the wind and pressure columns, never in the positions, so a position always parses.

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.