Methodology — How We Measured Tornado Migration

Methodology

A reproducibility note: every number on this site, and every figure, can be regenerated from tornadoes.parquet (3.1 MB, download) using the recipes documented below.


1. Data sources

We use the NOAA Storm Events Database maintained by the National Centers for Environmental Information (NCEI). The relevant table is StormEvents_details-ftp_v1.0_d<YEAR>.csv.gz, one file per year, downloadable from https://www.ncei.noaa.gov/pub/data/swdi/stormevents/csvfiles/. We downloaded 77 files, covering 1950 through 2024 (we also retain 2025–2026 in the working set but exclude them from the headline analysis to keep the period boundary clean).

Total: 77 compressed CSV files, ~80 MB unpacked, ~1.3 GB uncompressed rows.

2. Filtering and cleaning

We retain event rows where EVENT_TYPE == "Tornado". We do not filter by F/EF rating at the row level; we retain all ratings so that downstream analyses can pick their own inclusion threshold.

When BEGIN_LAT and BEGIN_LON are missing (which happens in roughly 1–3% of pre-1986 records), we still keep the row for annual counts but exclude it from geographic analyses.

When BEGIN_DATE_TIME fails to parse under one format (mostly the pre-1986 single-digit day), we fall back to an alternative format. Every date that survives either parse is included.

3. Period boundaries

We split the record into three windows chosen before analysis:

  • Period 1 (1950–1985, 36 years) — pre-Doppler era. Reporting is sparse at the F0/EF0 end. We use this period as the baseline.
  • Period 2 (1986–2000, 15 years) — early Doppler era. Transition window.
  • Period 3 (2001–2024, 24 years) — modern Doppler era with high reporting completeness at all F/EF levels.

Period boundaries were chosen to bracket the US WSR-88D rollout (1990–1997) symmetrically and to give P1 and P3 approximately equal temporal coverage (36 vs 24 years).

4. Centroid calculation

The F/EF1+ centroid for each period is computed as:

centroid_latitude = mean(event.begin_lat)  over F/EF1+ events in period
centroid_longitude = mean(event.begin_lon) over F/EF1+ events in period

Because we are averaging in geographic coordinates rather than over a sphere, the latitude centroid is slightly biased toward the northern hemisphere; for the contiguous US, this bias is on the order of 0.01° and is negligible. We did not project to an equal-area projection before averaging.

The "shift" between two periods is computed as the Euclidean distance on the lat/lon surface, multiplied by 69 miles per degree (the approximate latitude-to-miles factor for the US). We report the P1-to-P3 shift as the headline metric:

shift_miles = 69 * sqrt((lat_p3 - lat_p1)² + (lon_p3 - lon_p1)²)
            ≈ 69 * sqrt(0.10² + 1.00²) ≈ 70 miles

5. Decadal density maps

We use matplotlib.pyplot.hexbin with 22 × 22 grid cells spanning the contiguous US bounding box. Each hex's color reflects the count of F/EF0+ tornado touch-down points falling within it, on a log color scale (magma colormap, saturation at 1, ceiling at 800).

The bounding box is (-106, -75) × (24, 50) in lon/lat, chosen to fit the 48 contiguous states with a 2° margin.

6. State-level aggregation

States are aggregated using the STATE field from NOAA. We compute:

state_year_count = count of events where STATE == X and YEAR == Y
state_era_count = sum of state_year_count over Y in era E
state_era_per_yr = state_era_count / number_of_years_in_era
ratio_p3_p1 = state_era_per_yr[P3] / state_era_per_yr[P1]

States with fewer than 200 F/EF1+ events across the full record are excluded from the ratio ranking to avoid noise amplification.

7. Seasonal aggregation

We assign each event to one of four seasons based on begin_date.month:

SeasonMonths
SpringMarch, April, May
SummerJune, July, August
AutumnSeptember, October, November
WinterDecember, January, February

We then compute share-of-annual for each season × era cell, summing to 100%.

8. Reproducibility

All scripts are at /var/lib/hermes/tornado-alley/scripts/:

  • 01_parse_raw.py — Read all 77 yearly gzipped CSVs into a single tidy parquet.
  • 02_analyze.py — Compute period summaries, centroids, state ratios, season shares.
  • 03_visualize.py — Render the 9 figures used on this site.
  • 04_headline_json.py — Roll headline numbers into headline.json for the site template.

Dependencies: pandas 3.0, pyarrow 19, matplotlib 3.10, numpy 2.x. No compiled C extensions required.

Run the full pipeline with:

cd /var/lib/hermes/tornado-alley
./venv/bin/python scripts/01_parse_raw.py     # ~3 seconds
./venv/bin/python scripts/02_analyze.py      # ~2 seconds
./venv/bin/python scripts/03_visualize.py     # ~25 seconds
./venv/bin/python scripts/04_headline_json.py
./venv/bin/hugo --minify                      # ~3 seconds

End-to-end pipeline runtime on a Linux VM: ~35 seconds.

9. Known limitations

Rating-method change (F → EF). The 2007 transition is not rating-preserving: there is documented systematic demotion of F3-rated events in the early EF years. We use F/EF1+ as our headline threshold because it is the lowest tier whose rating is not dominated by the F→EF discontinuity; F/EF2+ is even more robust.

Reporting completeness. The pre-1986 record has systematic under-reporting of weak (F0) tornadoes in rural areas. The F/EF1+ threshold largely suppresses this bias, but residual bias remains.

Sparse rural data. Wyoming, Montana, and the Dakotas have very low absolute tornado counts; ratios for these states are necessarily noisy. We retain them in the state ranking but flag them in interpretation.

Tornado vs straight-line-wind ambiguity. The Storm Events Database records what the local NWS office classified as a tornado. Misclassification into "Thunderstorm Wind" or vice versa occurs at the edges of the dataset but is not bulk.

10. Extensions worth doing

The current pipeline covers the contiguous US only. Alaska, Hawaii, Puerto Rico, and the US Virgin Islands have records in the Storm Events Database but are not part of this study. We also do not currently differentiate night-time from daytime tornadoes — that's a separate and well-studied axis in the recent literature (e.g. Krocak & Brooks 2021) and would be a natural extension.


For questions or comments, see the paper or the underlying parquet at /data/tornadoes.parquet.