How to Animate a Hurricane Track in Python

On this page

Problem statement

A static track map shows where a hurricane went. An animation shows how: the long, fast run across the Atlantic, the rapid strengthening, the slow crawl into the Gulf, the turn and the acceleration inland. Best-track data is not a video feed, though. Points come every three hours, with extra points at landfall, so they are unevenly spaced in time. The map has to stay still while the storm moves. Intensity has to be readable on every frame.

This page animates the full life of Hurricane Beryl, 28 June to 11 July 2024, from the IBTrACS best track. It resamples the track to an even clock, colours each position by Saffir–Simpson category, and writes the animation to a file with a frame count that does not depend on the data's irregularities.

Quick answer

Interpolate the best track onto a regular hourly clock, draw each frame on a fixed map extent, and colour the current position by category:

clock = pd.date_range(track.t.min(), track.t.max(), freq="1h")
sec = lambda t: (pd.Index(t) - track.t.min()).total_seconds()        # seconds since the first point
hourly = pd.DataFrame({k: np.interp(sec(clock), sec(track.t), track[k]) for k in ("lat", "lon", "wind")}, index=clock)
fig, ax = base_map()                                                   # fixed extent, drawn once
trail, = ax.plot([], [], color="0.4", lw=1)
head = ax.scatter([], [], s=80, edgecolors="k", zorder=5)
label = ax.text(0.02, 0.95, "", transform=ax.transAxes, va="top")
def frame(i):
    trail.set_data(hourly.lon[:i + 1], hourly.lat[:i + 1])
    head.set_offsets([[hourly.lon.iloc[i], hourly.lat.iloc[i]]]); head.set_color(colour(hourly.wind.iloc[i]))
    label.set_text(f"{hourly.index[i]:%d %b %H:%M} UTC  {hourly.wind.iloc[i]:.0f} kt")
anim = FuncAnimation(fig, frame, frames=range(0, len(hourly), 3))
anim.save("data/beryl.gif", writer=PillowWriter(fps=12))
print(f"{len(track)} best-track points -> {len(hourly)} hourly positions -> {len(range(0, len(hourly), 3))} frames; "
      f"{Path('data/beryl.gif').stat().st_size / 1e6:.1f} MB")
109 best-track points -> 313 hourly positions -> 105 frames; 0.4 MB
Six frames of an animated map of Hurricane Beryl's track across the Atlantic, Caribbean and Gulf of Mexico. On 30 June it is a category 1 storm east of the Windward Islands; on 2 July a category 5 south of Hispaniola; on 4 July category 2 near Jamaica; on 6 July a tropical storm in the Gulf after crossing Yucatán; on 8 July a category 1 at the Texas coast; on 10 July a post-tropical low over the eastern United States. The trail is coloured by category. Six frames of an animated map of Hurricane Beryl's track across the Atlantic, Caribbean and Gulf of Mexico. On 30 June it is a category 1 storm east of the Windward Islands; on 2 July a category 5 south of Hispaniola; on 4 July category 2 near Jamaica; on 6 July a tropical storm in the Gulf after crossing Yucatán; on 8 July a category 1 at the Texas coast; on 10 July a post-tropical low over the eastern United States. The trail is coloured by category.
Six frames from the animation, the current position ringed. Data: IBTrACS v04r01 (NOAA NCEI); Natural Earth.

Read the timing, resample evenly, keep the map still

1. Read the best track and look at its timing

gaps = track.t.diff().dropna()
print(f"{len(track)} points {track.t.min():%d %b %H:%M} – {track.t.max():%d %b %H:%M}; time steps:",
      gaps.value_counts().sort_index().rename(lambda x: f"{x.total_seconds() / 60:.0f} min").to_dict())
print("off-clock points:", track[~track.t.dt.hour.isin(range(0, 24, 3)) | (track.t.dt.minute > 0)][["t", "wind"]].astype(str).values.tolist())
109 points 28 Jun 12:00 – 11 Jul 12:00; time steps: {'20 min': 2, '45 min': 1, '60 min': 1, '120 min': 1, '135 min': 1, '160 min': 2, '180 min': 100}
off-clock points: [['2024-07-01 15:20:00', '120'], ['2024-07-02 09:45:00', '145'], ['2024-07-05 11:00:00', '80'], ['2024-07-08 08:40:00', '80']]

Most points are three hours apart; the extra ones mark landfalls and peak intensity, added by the National Hurricane Center's post-storm analysis. Animated one point per frame, the storm would freeze at each of those moments.

2. Resample to an even clock

Linear interpolation of latitude, longitude and wind onto hourly steps keeps the special points' information while making each frame the same length of time:

from pyproj import Geod
_, _, d = Geod(ellps="WGS84").inv(hourly.lon[:-1].values, hourly.lat[:-1].values, hourly.lon[1:].values, hourly.lat[1:].values)
speed = pd.Series(d / 1000, index=hourly.index[1:])                      # km per hour
print(f"track length {speed.sum():,.0f} km in {len(hourly) - 1} hours; forward speed median {speed.median():.0f} km/h, "
      f"fastest {speed.max():.0f} km/h around {speed.idxmax():%d %b %H:%M}")
track length 9,039 km in 312 hours; forward speed median 29 km/h, fastest 60 km/h around 09 Jul 19:00

3. Keep the map still

Every frame must have the same extent, projection and background. Letting matplotlib autoscale to the points drawn so far makes the map zoom and pan as the track grows, which reads as motion that never happened. Draw the base map once, then update only the track artists:

print("extent:", base_extent, "| track bounds:",
      [round(float(v), 1) for v in (hourly.lon.min(), hourly.lat.min(), hourly.lon.max(), hourly.lat.max())])
extent: (-100, -35, 8, 46) | track bounds: [-96.0, 8.9, -39.6, 43.1]
Best-track points for Hurricane Beryl that fall between the regular 3-hourly times. points off the clocktime (UTC)windwhy it is there1 Jul 15:20120 ktlandfall, Carriacou2 Jul 09:45145 ktpeak intensity5 Jul 11:0080 ktlandfall, Yucatán8 Jul 08:4080 ktlandfall, TexasUSA_RECORD codes: L = landfall, I = intensity peak spatialworkflow.io
Points off the clock; the USA_RECORD column marks landfall with L and intensity peak with I.

4. Make intensity readable

Colour the current position and the trail by Saffir–Simpson category, and print the wind speed in the label. The category comes from the maximum sustained wind:

cats = pd.cut(hourly.wind, [0, 34, 64, 83, 96, 113, 137, 300], right=False, labels=["TD", "TS", "1", "2", "3", "4", "5"])
print(cats.value_counts().reindex(["TD", "TS", "1", "2", "3", "4", "5"]).to_dict(), "hours by category")
print(f"category 4 or 5 for {(cats.isin(['4', '5'])).sum()} hours; first best-track point at category 5: {track.t[track.wind >= 137].iloc[0]:%d %b %H:%M}")
{'TD': 73, 'TS': 89, '1': 26, '2': 20, '3': 39, '4': 48, '5': 18} hours by category
category 4 or 5 for 66 hours; first best-track point at category 5: 02 Jul 00:00

A track coloured by intensity in a still image

The data used on this page

IBTrACS v04r01, North Atlantic list, from NOAA NCEI (USA agency wind and Saffir–Simpson category); land from Natural Earth 1:50m.

from pathlib import Path

import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
from matplotlib.animation import FuncAnimation, PillowWriter

Path("data").mkdir(exist_ok=True)
SRC = {"ibtracs.NA.list.v04r01.csv": "https://www.ncei.noaa.gov/data/international-best-track-archive-for-climate-stewardship-ibtracs/v04r01/access/csv/ibtracs.NA.list.v04r01.csv",
       "ne_50m_land.zip": "https://naciscdn.org/naturalearth/50m/physical/ne_50m_land.zip"}
for name, url in SRC.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=600).content)
ib = pd.read_csv("data/ibtracs.NA.list.v04r01.csv", skiprows=[1], low_memory=False, keep_default_na=False)
track = ib[(ib.NAME == "BERYL") & (ib.SEASON.astype(int) == 2024)].copy()
track = track.assign(t=pd.to_datetime(track.ISO_TIME), lat=pd.to_numeric(track.LAT), lon=pd.to_numeric(track.LON),
                     wind=pd.to_numeric(track.USA_WIND)).reset_index(drop=True)[["t", "lat", "lon", "wind"]]
land = gpd.read_file("data/ne_50m_land.zip")
base_extent = (-100, -35, 8, 46)
COLOURS = ["#5ebaff", "#00faf4", "#ffffcc", "#ffe775", "#ffc140", "#ff8f20", "#ff6060"]


def colour(kt):
    return COLOURS[int(np.searchsorted([34, 64, 83, 96, 113, 137], kt, side="right"))]


def base_map():
    fig, ax = plt.subplots(figsize=(6, 4), dpi=100)
    ax.set_facecolor("#d6ecfa"); land.plot(ax=ax, color="#eef2f6", edgecolor="#94a3b8", lw=0.3)
    ax.set_xlim(base_extent[:2]); ax.set_ylim(base_extent[2:]); ax.set_xticks([]); ax.set_yticks([])
    return fig, ax


print(f"Beryl 2024: {len(track)} points, peak {track.wind.max():.0f} kt")
Beryl 2024: 109 points, peak 145 kt

Example 1: trail coloured by intensity in a still image

fig, ax = base_map()
for (_, a), (_, b) in zip(hourly.iloc[:-1].iterrows(), hourly.iloc[1:].iterrows()):
    ax.plot([a.lon, b.lon], [a.lat, b.lat], color=colour(a.wind), lw=2.5, solid_capstyle="round")
fig.savefig("data/beryl_track.png", dpi=150)
print("segments drawn:", len(hourly) - 1)
segments drawn: 312
From best-track points to an animation. track to framesbest track109 pointseven clock313 hourlypositionsfixed map−100 to −35°,8–46°Ncolour bycategorySSHS from windevery 3 h aframe105 frames, 0.4 MB9,039 km in 312 hours; fastest 60 km/h on 9 July spatialworkflow.io
From best track to frames. Beryl travelled 9,039 km in 312 hours.

Mapping storm time onto playback time

A storm-track animation maps time onto playback time, and space onto a fixed map. Both mappings have to be constant for the motion to be honest. Best-track data is designed for analysis, not playback: its points are synoptic times plus special points at landfall and peak intensity. Interpolating onto an even clock is the step that turns it into frames. It does not add information, it only spaces what is there evenly in time.

Hours Beryl spent in each Saffir–Simpson category, from the hourly-resampled track. hours by categorydepression / other73 htropical storm89 hcategory 126 hcategory 220 hcategory 339 hcategory 448 hcategory 518 h045 h90 h313 hourly positions, 28 June – 11 July 2024 spatialworkflow.io
Hours Beryl spent in each category.

What the animation shows best is the change in speed and intensity: the steady run across the Atlantic at a median 29 km/h, the explosive strengthening to category 5, the weakening over Yucatán, and the acceleration after landfall in Texas. A static map can show the same path, but it compresses thirteen days into one picture, where the storm's pace is invisible. Pair the animation with a static intensity-coloured track for print and for readers who cannot watch it.

The dateline, sustained winds and extratropical stages

Hurricane track animation problems and fixes. track animation problemsSymptomFixstorm pauses at landfallresample to an even clockmap zooms as track growsfix axis limits firststart of track off the mapextent from the whole trackcategory times look earlytake them from original pointsIBTrACS v04r01, Beryl 2024 spatialworkflow.io
Track animation problems and fixes.
  • Interpolating across the dateline needs continuous longitudes; see the dateline guide.
  • Linear interpolation between 3-hourly points is fine for display; for analysis, use the original points.
  • Wind is the 1-minute sustained maximum in the USA columns; other agencies use 10-minute winds.
  • Extratropical stages (category codes below 0) should look different from tropical ones; the colours above treat them as depressions.
  • Frame count sets file size; a frame every three hours of storm keeps the GIF small.
  • Aspect ratio: plot in a projected CRS or set ax.set_aspect near the storm's latitude to avoid stretched tracks.

FAQ

How do I animate a hurricane track in Python?

Interpolate the best track onto an even clock, draw the base map once with a fixed extent, and update a trail line and a current-position marker in FuncAnimation, saving with PillowWriter or FFMpegWriter.

Why does my hurricane animation stall at some points?

Best-track files contain extra points at landfall and peak intensity; animating one point per frame lingers there. Resample to an even time step.

How do I colour a track by hurricane category?

Map the maximum sustained wind to Saffir–Simpson categories (64, 83, 96, 113 and 137 kt thresholds) and colour each segment or marker by category.

Why does my animation zoom as the track grows?

Matplotlib autoscales to the data; set fixed axis limits before animating.