NOAA Storm Events — Dataset Sketch

datavis-36613 · exploratory look at the shared teaching data (2024)

Author

MaDS Fall 2026

Published

June 15, 2026

What this is. A first pass over one year (2024) of the NOAA Storm Events database—the shared teaching and checkpoint dataset for Data Visualization. The goal is to show (a) the data is real, rich, and useful for practicing the full visualization sequence and (b) what a student analysis can draw from it. The final project uses a separate workforce-compensation archive.

Reproducibility. Every input is listed in the shared source register. This document reads the committed file ../data/StormEvents_details-ftp_v1.0_d2024_c20260421.csv.gz. To refresh or pull a different year, see the Getting the data section — one line.

Code
# Packages (install once):
#   install.packages(c("tidyverse","scales","maps"))
# tidyverse pulls in readr, dplyr, stringr, tidyr, forcats, lubridate, ggplot2.
# `maps` backs ggplot2::map_data("state") for the CONUS map.
library(readr)
library(dplyr)
library(stringr)
library(tidyr)
library(forcats)
library(lubridate)
library(ggplot2)
library(scales)

theme_set(theme_minimal(base_size = 12))
storm_pal <- "#c1440e"  # warm "hazard" accent used throughout

Getting the data

The Storm Events database is published as one gzipped CSV per year at NOAA NCEI. We pinned the 2024 details file. read_csv() reads .gz directly — no manual unzip.

Code
# Source: https://www.ncei.noaa.gov/pub/data/swdi/stormevents/csvfiles/
# File:   StormEvents_details-ftp_v1.0_d2024_c20260421.csv.gz   (data year 2024)
#
# To pull a fresh copy programmatically (the `c2026...` stamp changes each refresh,
# so glob the data-year instead of hard-coding it):
#   url <- "https://www.ncei.noaa.gov/pub/data/swdi/stormevents/csvfiles/StormEvents_details-ftp_v1.0_d2024_c20260421.csv.gz"
#   download.file(url, "../data/StormEvents_details_d2024.csv.gz")

raw_path <- Sys.glob("../data/StormEvents_details-ftp_v1.0_d2024_*.csv.gz")[1]

events <- read_csv(raw_path, show_col_types = FALSE, guess_max = 100000)
dim(events)
[1] 69801    51

The file has 51 columns describing 69,801 events.

What’s in a row?

Each row is one storm event: when, where, what type, who/what it hurt, and a free-text narrative. The variable groups that matter for visualization:

Code
tibble::tribble(
  ~Group,            ~Columns,
  "When",            "BEGIN_DATE_TIME, END_DATE_TIME, YEAR, MONTH_NAME",
  "Where",           "STATE, CZ_NAME (county/zone), BEGIN_LAT, BEGIN_LON, WFO",
  "What",            "EVENT_TYPE (50 types), EPISODE_NARRATIVE, EVENT_NARRATIVE",
  "Human impact",    "INJURIES_DIRECT/INDIRECT, DEATHS_DIRECT/INDIRECT",
  "Economic impact", "DAMAGE_PROPERTY, DAMAGE_CROPS  (e.g. '5.00K', '2.50M')",
  "Physical magnitude", "MAGNITUDE (wind kt / hail in.), TOR_F_SCALE, CATEGORY, FLOOD_CAUSE"
)
Group Columns
When BEGIN_DATE_TIME, END_DATE_TIME, YEAR, MONTH_NAME
Where STATE, CZ_NAME (county/zone), BEGIN_LAT, BEGIN_LON, WFO
What EVENT_TYPE (50 types), EPISODE_NARRATIVE, EVENT_NARRATIVE
Human impact INJURIES_DIRECT/INDIRECT, DEATHS_DIRECT/INDIRECT
Economic impact DAMAGE_PROPERTY, DAMAGE_CROPS (e.g. ‘5.00K’, ‘2.50M’)
Physical magnitude MAGNITUDE (wind kt / hail in.), TOR_F_SCALE, CATEGORY, FLOOD_CAUSE

One real event

Code
events |>
  filter(EVENT_TYPE == "Tornado") |>
  slice(1) |>
  select(BEGIN_DATE_TIME, STATE, CZ_NAME, EVENT_TYPE, TOR_F_SCALE,
         DEATHS_DIRECT, DAMAGE_PROPERTY, EVENT_NARRATIVE) |>
  glimpse()
Rows: 1
Columns: 8
$ BEGIN_DATE_TIME <chr> "05-NOV-24 11:01:00"
$ STATE           <chr> "IOWA"
$ CZ_NAME         <chr> "DAVIS"
$ EVENT_TYPE      <chr> "Tornado"
$ TOR_F_SCALE     <chr> "EF0"
$ DEATHS_DIRECT   <dbl> 0
$ DAMAGE_PROPERTY <chr> "120.00K"
$ EVENT_NARRATIVE <chr> "A brief tornado occurred south of Floris from a devel…

Cleaning the two variables students will fight with

The damage fields are strings with K/M/B suffixes, not numbers. This is the single most important cleaning step — and a great teaching moment about why raw data needs care before you can chart dollars.

Code
parse_damage <- function(x) {
  x <- str_trim(x)
  num  <- as.numeric(str_extract(x, "^[0-9.]+"))
  mult <- dplyr::case_when(
    str_detect(x, "K$") ~ 1e3,
    str_detect(x, "M$") ~ 1e6,
    str_detect(x, "B$") ~ 1e9,
    TRUE                ~ 1
  )
  coalesce(num * mult, 0)
}

events <- events |>
  mutate(
    prop_usd   = parse_damage(DAMAGE_PROPERTY),
    crop_usd   = parse_damage(DAMAGE_CROPS),
    begin_dt   = dmy_hms(BEGIN_DATE_TIME),
    month_ord  = factor(MONTH_NAME, levels = month.name),
    deaths     = DEATHS_DIRECT + DEATHS_INDIRECT,
    injuries   = INJURIES_DIRECT + INJURIES_INDIRECT
  )

The headline numbers (2024)

Code
events |>
  summarise(
    Events            = scales::comma(n()),
    `Property damage` = dollar(sum(prop_usd), scale = 1e-9, suffix = "B", accuracy = 0.1),
    `Crop damage`     = dollar(sum(crop_usd), scale = 1e-9, suffix = "B", accuracy = 0.1),
    Deaths            = scales::comma(sum(deaths)),
    Injuries          = scales::comma(sum(injuries)),
    `Event types`     = n_distinct(EVENT_TYPE)
  )
Events Property damage Crop damage Deaths Injuries Event types
69,801 $15.7B $2.3B 1,140 1,840 50

In 2024 these events carried ~$15.7B in property damage and ~$2.3B in crop damage — almost all of it concentrated in a handful of hurricane/flood episodes. That concentration is the business story.

Story 1 — Where do the dollars come from?

Code
events |>
  group_by(EVENT_TYPE) |>
  summarise(prop = sum(prop_usd), .groups = "drop") |>
  slice_max(prop, n = 10) |>
  mutate(EVENT_TYPE = fct_reorder(EVENT_TYPE, prop)) |>
  ggplot(aes(prop, EVENT_TYPE)) +
  geom_col(fill = storm_pal) +
  scale_x_continuous(labels = label_dollar(scale = 1e-6, suffix = "M")) +
  labs(title = "2024 property damage by storm type",
       subtitle = "A few hurricane/flood events dominate the loss total",
       x = "Property damage", y = NULL)

Count and cost rank very differently — thunderstorm wind and hail are most frequent, but hurricanes, tropical storms, and flash floods drive the dollars. A classic “frequency vs. severity” insurance framing students can run with.

Code
events |>
  group_by(EVENT_TYPE) |>
  summarise(n = n(), prop = sum(prop_usd), .groups = "drop") |>
  slice_max(n, n = 8) |>
  arrange(desc(n)) |>
  transmute(`Event type` = EVENT_TYPE,
            Count = scales::comma(n),
            `Property $` = dollar(prop, scale = 1e-6, suffix = "M", accuracy = 1))
Event type Count Property $
Thunderstorm Wind 19,940 $412M
Hail 8,941 $227M
Flash Flood 4,661 $3,143M
High Wind 3,994 $128M
Winter Weather 3,557 $5M
Drought 3,473 $0M
Heat 3,171 $0M
Excessive Heat 2,660 $0M

Story 2 — When? (seasonality)

Code
events |>
  count(month_ord) |>
  ggplot(aes(month_ord, n)) +
  geom_col(fill = "#1f6f8b") +
  scale_y_continuous(labels = comma) +
  labs(title = "2024 storm events by month",
       subtitle = "Spring severe-weather peak, then a fall hurricane signal",
       x = NULL, y = "Events") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Story 3 — Where? (geography)

About 60% of events carry point coordinates; the rest are coded to a county or forecast zone. Even the point subset is enough for a national risk map.

Code
states_map <- map_data("state")

geo <- events |>
  filter(!is.na(BEGIN_LAT), !is.na(BEGIN_LON),
         between(BEGIN_LON, -125, -66), between(BEGIN_LAT, 24, 50))

ggplot() +
  geom_polygon(data = states_map, aes(long, lat, group = group),
               fill = "grey95", color = "white") +
  geom_point(data = geo, aes(BEGIN_LON, BEGIN_LAT),
             size = 0.4, alpha = 0.18, color = storm_pal) +
  coord_quickmap() +
  labs(title = "2024 geocoded storm events (CONUS)",
       subtitle = paste0(scales::comma(nrow(geo)), " events with coordinates"),
       x = NULL, y = NULL) +
  theme_void()

Code
events |>
  group_by(STATE) |>
  summarise(prop = sum(prop_usd), .groups = "drop") |>
  slice_max(prop, n = 8) |>
  mutate(`Property damage` = dollar(prop, scale = 1e-6, suffix = "M", accuracy = 1)) |>
  transmute(State = str_to_title(STATE), `Property damage`)
State Property damage
Florida $7,186M
North Carolina $2,138M
Georgia $1,589M
New Mexico $562M
Texas $559M
Washington $549M
Oklahoma $366M
South Carolina $349M

Florida ($7.2B) and North Carolina ($2.1B) top 2024 — the Hurricane Helene/Milton footprint. A good prompt for students: normalize damage by population or home value (see the Census/Zillow joins in the computing course) and the map changes completely.

Story 4 — Physical magnitude

Code
events |>
  filter(EVENT_TYPE == "Hail", !is.na(MAGNITUDE), MAGNITUDE > 0) |>
  ggplot(aes(MAGNITUDE)) +
  geom_histogram(binwidth = 0.25, fill = "#3a7d44") +
  labs(title = "Reported hail size (2024)", x = "Hail diameter (inches)", y = "Reports")

MAGNITUDE doubles as wind speed (knots) for wind events and hail size (inches) for hail; TOR_F_SCALE gives EF rating for tornadoes; CATEGORY for hurricanes. Plenty for distribution and severity visuals.

The hurricane bridge → HURDAT2 (light look)

Storm Events tells you where damage landed. HURDAT2 tells you the track of the storm that caused it — 6-hourly best-track fixes back to 1851. Overlaying the two is the natural link to the computing course’s database, where the spatial join between storm tracks and weather stations is the centerpiece.

Code
# Source: https://www.nhc.noaa.gov/data/#hurdat
hurdat_url <- "https://www.nhc.noaa.gov/data/hurdat/hurdat2-1851-2023-051124.txt"

# HURDAT2 is a "header + track rows" text format:
#   header:  AL092023, IDALIA, 41,   (id, name, # track rows)
#   track:   20230830, 1200,  , HU, 30.0N, 83.4W, 110, 942, ...
parse_hurdat2 <- function(path) {
  ln <- readr::read_lines(path)
  is_hdr <- str_detect(ln, "^[A-Z]{2}\\d{6},")
  ids <- cumsum(is_hdr)
  hdr <- str_split_fixed(ln[is_hdr], ",", 4)
  storms <- tibble(storm_id = str_trim(hdr[,1]),
                   name     = str_trim(hdr[,2]),
                   sid      = which(is_hdr) |> seq_along())
  trk <- tibble(raw = ln[!is_hdr], sid = ids[!is_hdr]) |>
    separate(raw, into = paste0("v", 1:21), sep = ",", fill = "right", extra = "drop") |>
    transmute(
      sid,
      datetime = ymd_hm(paste(str_trim(v1), str_trim(v2))),
      status   = str_trim(v4),
      lat = as.numeric(str_remove(str_trim(v5), "N")) *
            ifelse(str_detect(v5, "S"), -1, 1),
      lon = as.numeric(str_remove(str_trim(v6), "W")) *
            ifelse(str_detect(v6, "W"), -1, 1),
      wind_kt  = suppressWarnings(as.numeric(str_trim(v7)))
    )
  left_join(trk, storms |> select(sid, storm_id, name), by = "sid")
}

# Live pull (works wherever outbound NHC access is allowed):
tracks <- tryCatch(parse_hurdat2(hurdat_url), error = function(e) NULL)

if (!is.null(tracks)) {
  cat("Parsed", n_distinct(tracks$storm_id), "storms,",
      nrow(tracks), "track fixes\n")
  head(tracks, 4)
} else {
  cat("No network access to NHC in this environment — run locally to fetch HURDAT2.\n")
}
Parsed 1973 storms, 54749 track fixes
sid datetime status lat lon wind_kt storm_id name
1 1851-06-25 00:00:00 HU 28.0 -94.8 80 AL011851 UNNAMED
1 1851-06-25 06:00:00 HU 28.0 -95.4 80 AL011851 UNNAMED
1 1851-06-25 12:00:00 HU 28.0 -96.0 80 AL011851 UNNAMED
1 1851-06-25 18:00:00 HU 28.1 -96.5 80 AL011851 UNNAMED
Code
tracks |>
  filter(!is.na(lon), !is.na(lat), between(lon, -100, -10), between(lat, 5, 50)) |>
  ggplot(aes(lon, lat, group = storm_id, color = wind_kt)) +
  geom_path(alpha = 0.5, linewidth = 0.4) +
  scale_color_viridis_c(option = "inferno", direction = -1, name = "Max wind (kt)") +
  coord_quickmap() +
  labs(title = "HURDAT2 Atlantic tracks (sample)",
       subtitle = "Color = intensity; this is the layer the DB joins to weather stations",
       x = NULL, y = NULL) +
  theme_minimal()

What a student portfolio could answer

  • Frequency vs. severity: which storm types are cheap-but-common vs. rare-but-catastrophic?
  • Risk maps: damage per capita / per housing dollar by county (needs the Census + Zillow joins).
  • Trend: pull 1950→2024 and chart billion-dollar events per decade.
  • Seasonality by region: hail on the Plains vs. hurricanes on the Gulf coast.
  • Narrative mining: the free-text EVENT_NARRATIVE is a text-analysis bonus.