---
title: "NOAA Storm Events — Dataset Sketch"
subtitle: "datavis-36613 · exploratory look at the shared teaching data (2024)"
author: "MaDS Fall 2026"
date: 2026-06-15
format:
html:
toc: true
toc-depth: 3
code-fold: true
code-tools: true
fig-width: 8
fig-height: 4.5
df-print: kable
embed-resources: true
execute:
warning: false
message: false
cache: true
---
> **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 [`../data/SOURCES.md`](../data/SOURCES.md).
> 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.
```{r setup}
# 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.
```{r load}
#| code-fold: show
# 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)
```
The file has **`r ncol(events)` columns** describing **`r scales::comma(nrow(events))` 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:
```{r schema}
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"
)
```
### One real event
```{r one-row}
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()
```
## 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.
```{r clean-damage}
#| code-fold: show
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)
```{r headline}
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)
)
```
> 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?
```{r damage-by-type}
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.
```{r count-vs-cost}
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))
```
## Story 2 — When? (seasonality)
```{r seasonality}
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.
```{r map}
#| fig-height: 5
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()
```
```{r state-table}
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`)
```
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
```{r magnitude}
#| fig-height: 3.5
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.
```{r hurdat2}
#| code-fold: show
# 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")
}
```
```{r hurdat2-plot}
#| eval: !expr "exists('tracks') && !is.null(tracks)"
#| fig-height: 5
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.
## Reproducibility footer
```{r sessioninfo}
#| code-fold: show
sessionInfo()
```
_Data: NOAA Storm Events `d2024` (NCEI) + HURDAT2 (NHC). Full source list in
[`../data/SOURCES.md`](../data/SOURCES.md)._