How to Map a Hurricane Season's Tracks Coloured by Intensity

On this page

Problem statement

The classic picture of a hurricane season is a map of every storm's track, coloured by Saffir–Simpson category so you can see where each one strengthened and weakened. Building it from HURDAT2 means three decisions: how to turn a wind speed into a category, what colour to give the stages when the storm was not tropical, and how to colour a line that changes intensity along its length. This page draws the 2025 Atlantic season (13 named storms, three of them category 5) and marks its landfalls, including Melissa's in Jamaica.

Quick answer

Cut the wind into Saffir–Simpson classes, but only for fixes whose status is TD, TS or HU. Winds are stored in 5-kt steps, so in practice categories 1 to 5 begin at 65, 85, 100, 115 and 140 kt. Then turn each storm's consecutive fixes into two-point segments and colour each by the category at its start. Plot fixes with record == "L" as landfall markers.

from shapely.geometry import LineString

cat = pd.cut(hd.wind, [0, 33, 63, 82, 95, 112, 136, 200], labels=["TD", "TS", "1", "2", "3", "4", "5"])
hd["cat"] = np.where(hd.status.isin(["TD", "TS", "HU"]), cat.astype(str), "other")
s = hd[hd.season == 2025].sort_values(["sid", "time"])
nxt = s.groupby("sid")[["lon", "lat"]].shift(-1)
seg = gpd.GeoDataFrame(s.assign(nlon=nxt.lon, nlat=nxt.lat).dropna(subset=["nlon"]), crs=4326,
                       geometry=[LineString([(a, b), (c, d)]) for a, b, c, d in zip(s.lon, s.lat, nxt.lon, nxt.lat) if not np.isnan(c)])
print(f"2025: {s.sid.nunique()} storms, {len(seg):,} track segments; by category: {seg.cat.value_counts().to_dict()}")
2025: 13 storms, 362 track segments; by category: {'TS': 135, 'other': 112, '1': 32, '4': 24, '2': 22, 'TD': 14, '3': 13, '5': 10}

The 2025 season's 13 storms make 362 segments. 135 start at tropical-storm strength, 112 in a non-tropical stage (depression remnants, extratropical gales), and 10 at category 5.

Map of the Atlantic with the 13 storm tracks of 2025 coloured by category, the strongest purple and red segments in the Caribbean and western Atlantic, grey extratropical tails towards Europe, and circles at landfalls in Mexico, South Carolina, Cabo Verde, the Bahamas, Jamaica and Cuba. Map of the Atlantic with the 13 storm tracks of 2025 coloured by category, the strongest purple and red segments in the Caribbean and western Atlantic, grey extratropical tails towards Europe, and circles at landfalls in Mexico, South Carolina, Cabo Verde, the Bahamas, Jamaica and Cuba.
The 2025 Atlantic season coloured by Saffir–Simpson category, with landfall records circled.

Category, colour and landfall for each segment

1. One season, all fixes

print(s.groupby("sid").agg(name=("name", "first"), fixes=("time", "size"), first=("time", "min"), last=("time", "max")).head(5).to_string())
             name  fixes               first                last
sid                                                             
AL012025   ANDREA     15 2025-06-22 00:00:00 2025-06-25 12:00:00
AL022025    BARRY      8 2025-06-28 18:00:00 2025-06-30 06:00:00
AL032025  CHANTAL     16 2025-07-04 06:00:00 2025-07-07 18:00:00
AL042025   DEXTER     43 2025-08-02 18:00:00 2025-08-13 06:00:00
AL052025     ERIN     68 2025-08-11 00:00:00 2025-08-27 18:00:00

Each storm is a block of fixes from first to last advisory. Andrea in June had 15. Erin ran for over two weeks in August, with 68 fixes, the longest track of the season.

2. Category from wind, only while tropical

peak = s.loc[s.groupby("sid").wind.idxmax(), ["sid", "name", "time", "wind", "pressure", "status", "cat"]]
print(peak.sort_values("wind", ascending=False).to_string(index=False))
print("lowest wind in each class, kt:", {str(c): int(hd.wind[(hd.cat == str(c))].min()) for c in ["TS", "1", "2", "3", "4", "5"]})
     sid      name                time  wind  pressure status   cat
AL132025   MELISSA 2025-10-28 12:00:00   165     892.0     HU     5
AL082025  HUMBERTO 2025-09-27 18:00:00   140     919.0     HU     5
AL052025      ERIN 2025-08-16 18:00:00   140     913.0     HU     5
AL072025 GABRIELLE 2025-09-23 00:00:00   120     944.0     HU     4
AL092025    IMELDA 2025-10-01 12:00:00    80     968.0     HU     1
AL042025    DEXTER 2025-08-08 06:00:00    70     989.0     EX other
AL102025     JERRY 2025-10-08 12:00:00    55    1002.0     TS    TS
AL032025   CHANTAL 2025-07-06 06:00:00    50    1002.0     TS    TS
AL062025   FERNAND 2025-08-25 12:00:00    50     999.0     TS    TS
AL112025     KAREN 2025-10-08 12:00:00    50    1000.0     EX other
AL122025   LORENZO 2025-10-14 00:00:00    50    1000.0     TS    TS
AL022025     BARRY 2025-06-29 18:00:00    40    1006.0     TS    TS
AL012025    ANDREA 2025-06-23 12:00:00    35    1014.0     TS    TS
lowest wind in each class, kt: {'TS': 35, '1': 65, '2': 85, '3': 100, '4': 115, '5': 140}

Melissa peaked at 165 kt and 892 hPa on 28 October, and Humberto and Erin both reached 140 kt, category 5. Dexter's and Karen's highest winds came after they had become extratropical, so their peak fixes are coloured "other", not category 1 or tropical storm. Using the status column keeps a North Atlantic gale from appearing as a hurricane on the map. The lowest stored wind in each class confirms the cut points: 65, 85, 100, 115 and 140 kt for categories 1 to 5.

3. Colour each segment by where it starts

hours = seg.groupby("cat").size() * 6
print("approximate hours at each stage (six-hourly segments):", hours.reindex(["TD", "TS", "1", "2", "3", "4", "5", "other"]).fillna(0).astype(int).to_dict())
print(f"segments that start at a non-synoptic fix: {(~seg.synoptic).sum()}")
approximate hours at each stage (six-hourly segments): {'TD': 84, 'TS': 810, '1': 192, '2': 132, '3': 78, '4': 144, '5': 60, 'other': 672}
segments that start at a non-synoptic fix: 6

Counting six-hourly segments approximates time at each stage: about 810 hours at tropical-storm strength, 60 hours at category 5 and 672 hours in non-tropical stages. Only 6 segments start at an extra, non-synoptic fix, so the approximation holds. The landfall fixes shorten the segments around them but do not change the picture.

Stacked horizontal bars of hours each 2025 storm spent at each stage, longest for Erin and Melissa, with Melissa, Erin and Humberto showing category 5 segments and Dexter and Karen mostly not tropical. Stacked horizontal bars of hours each 2025 storm spent at each stage, longest for Erin and Melissa, with Melissa, Erin and Humberto showing category 5 segments and Dexter and Karen mostly not tropical.
Time at each stage, from six-hourly segments: about 810 hours at tropical-storm strength and 60 at category 5 across the season.

4. Mark landfalls

lf = s[s.record == "L"]
print(lf[["name", "time", "lat", "lon", "wind", "status"]].to_string(index=False))
   name                time  lat   lon  wind status
  BARRY 2025-06-30 02:00:00 21.9 -97.7    30     TD
CHANTAL 2025-07-06 08:00:00 33.6 -79.0    45     TS
   ERIN 2025-08-11 06:00:00 17.2 -25.2    35     TS
 IMELDA 2025-09-29 12:00:00 26.4 -77.1    45     TS
MELISSA 2025-10-28 17:25:00 18.1 -78.0   160     HU
MELISSA 2025-10-29 07:20:00 20.0 -76.2   100     HU
MELISSA 2025-10-29 22:15:00 23.1 -75.0    80     HU
MELISSA 2025-10-30 02:00:00 24.0 -74.5    80     HU

2025 had eight landfall records. Barry came ashore in Mexico as a depression, Chantal in South Carolina as a tropical storm, Erin crossed the Cabo Verde islands at its genesis, and Imelda brushed the Bahamas. Melissa made three landfalls: in Jamaica at 160 kt on 28 October, then in Cuba and the Bahamas, followed by a fourth Bahamas fix. No hurricane made landfall on the US mainland.

Melissa close up, and distances

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: Melissa, fix by fix near its peak

m = s[s.name == "MELISSA"].set_index("time")
print(m.loc["2025-10-26 12:00":"2025-10-29 00:00", ["record", "status", "lat", "lon", "wind", "pressure"]].to_string())
                    record status   lat   lon  wind  pressure
time                                                         
2025-10-26 12:00:00            HU  16.3 -76.4   115     952.0
2025-10-26 18:00:00            HU  16.3 -76.9   120     944.0
2025-10-27 00:00:00            HU  16.3 -77.3   125     934.0
2025-10-27 06:00:00            HU  16.3 -77.7   135     923.0
2025-10-27 12:00:00            HU  16.3 -78.0   145     912.0
2025-10-27 18:00:00            HU  16.4 -78.3   155     906.0
2025-10-28 00:00:00            HU  16.5 -78.6   155     909.0
2025-10-28 06:00:00            HU  16.9 -78.4   155     900.0
2025-10-28 12:00:00            HU  17.5 -78.1   165     892.0
2025-10-28 17:25:00      L     HU  18.1 -78.0   160     897.0
2025-10-28 18:00:00            HU  18.2 -78.0   145     910.0
2025-10-29 00:00:00            HU  18.8 -77.1   105     953.0

Melissa's last two days at sea show why the best track adds extra fixes. It strengthened from 115 kt to 165 kt in 48 hours while crawling west at about 16°N. The landfall fix at 17:25 UTC, 160 kt and 897 hPa, falls between the 12:00 and 18:00 synoptic fixes. By the next synoptic hour the storm had already weakened over Jamaica's mountains to 145 kt.

Map of the western Caribbean with Melissa's track: a slow loop south of Jamaica, a purple category 5 stretch, landfall circles on Jamaica, Cuba and twice in the Bahamas, each labelled with time and wind. Map of the western Caribbean with Melissa's track: a slow loop south of Jamaica, a purple category 5 stretch, landfall circles on Jamaica, Cuba and twice in the Bahamas, each labelled with time and wind.
Melissa's six-hourly fixes and four landfall records; the Jamaica landfall at 17:25 UTC on 28 October was at 160 kt.

Example 2: how far each storm travelled

from pyproj import Geod

geod = Geod(ellps="WGS84")
km = s.groupby("sid").apply(lambda g: geod.line_length(g.lon.values, g.lat.values) / 1000)
print(pd.DataFrame({"name": s.groupby("sid").name.first(), "km": km.round(0)}).sort_values("km", ascending=False).head(5).to_string())
               name       km
sid                         
AL052025       ERIN  12255.0
AL072025  GABRIELLE   7489.0
AL092025     IMELDA   5848.0
AL042025     DEXTER   5639.0
AL132025    MELISSA   5451.0

Erin travelled 12,255 km from the Cabo Verde islands to the far North Atlantic, more than twice Melissa's 5,451 km. Measure track lengths on the ellipsoid with pyproj.Geod; a planar length in degrees would understate east–west travel at high latitudes.

How the colours are defined

The Saffir–Simpson scale uses one-minute sustained wind: category 1 from 64 kt, 2 from 83 and 3 from 96, with 4 and 5 above. HURDAT2 rounds winds to 5 kt, so the thresholds fall between stored values: a 95-kt fix is category 2 and a 100-kt fix category 3. Below hurricane strength the National Hurricane Center uses status rather than wind: a storm with 40 kt winds is a tropical storm only if its status is TS or SS. An extratropical low with the same wind is not on the scale at all.

Colouring segments by their starting fix is a convention, not physics. Intensity changes continuously between fixes. For a smoother map, interpolate the wind along each segment and draw it with a colour gradient. For a season overview, the segment colours read clearly.

Making the map read

  • Draw weak stages thin and grey, and draw hurricanes thicker. Otherwise long-lived tropical storms dominate the map.
  • Projection. Atlantic tracks reach from 10°N to 60°N. A conic or Lambert azimuthal projection keeps them in proportion, and plain longitude–latitude stretches the northern ends.
  • Season maps need the 31 single-fix storms only if you want a complete count. They cannot be drawn as lines.
  • Labels at peak intensity work better than at genesis, where storms crowd together off Africa.

FAQ

How do I colour a hurricane track by category in Python?

Classify each fix with pd.cut at the Saffir–Simpson thresholds, but only for tropical statuses. Then build two-point segments between consecutive fixes and colour each by its start.

How strong was Hurricane Melissa?

165 kt with a central pressure of 892 hPa at 12:00 UTC on 28 October 2025, according to the HURDAT2 best track. It made landfall in Jamaica at 160 kt.