library(googledrive)
drive_deauth() # the folder is shared by link, so no Google sign-in is needed
folder <- as_id("https://drive.google.com/drive/folders/1ahnjyrwAU_YePkB99XzDeNyI653TRe_7?usp=share_link")
files <- drive_ls(folder, recursive = TRUE)
# Example: download the monthly county panel into wildfire_data/clean/
dir.create("wildfire_data/clean", recursive = TRUE, showWarnings = FALSE)
files |>
filter(name == "wildfire_county_month_panel_2006_2025.csv.gz") |>
drive_download(path = "wildfire_data/clean/wildfire_county_month_panel_2006_2025.csv.gz",
overwrite = TRUE)Wildfire smoke data: from raw files to clean panels
Every dataset for the Canadian wildfire smoke project, in two forms: the raw files as the agencies publish them, and clean county tables built from them with the tidyverse. Each section explains what the data measure, shows the cleaning code step by step, and describes every variable.
No health outcomes. The bundle contains fires, smoke, air quality, weather, population, and boundaries only. It contains no mortality data, so course work stays outcome-blind, as the research guide requires.
Get the data
All files live in one shared Google Drive folder, wildfire_data:
Open the wildfire_data folder on Google Drive ↗
- Open the folder and download it. Google Drive splits large folders into several zip files; unzip all of them into the same place.
- Put the
wildfire_datafolder in the root of your RStudio project, next to your.Rprojfile, so these paths work:
your-project/
├── your-project.Rproj
└── wildfire_data/
├── raw/ ← files as published by NASA, NOAA, EPA, Census, ...
└── clean/ ← tidy county tables built by the code in this guide
- You rarely need everything. Most projects need only
clean/(1.6 GB). Downloadraw/(3.7 GB) only for the sources whose cleaning you want to reproduce or change.
If you prefer to download from R, the googledrive package can fetch single files from a link-shared folder without signing in:
Set up R
Every code chunk on this page assumes the packages and paths below. The code chunks are shown but not run on this page; run them in your own project. Run a section’s chunks in order, because later chunks use objects made by earlier ones. Sections that build on other sections say so; most use only the files that the first section writes to clean/.
library(tidyverse)
library(sf) # spatial joins: boundaries, FIRMS, HMS, AQS, CSU, EMBER, NBAC
library(ncdf4) # NetCDF grids: CSU smoke sample and EMBER model runs
library(readxl) # OMB metro-area delineation (.xlsx)
library(foreign) # NBAC attribute table (.dbf)
raw_dir <- "wildfire_data/raw"
clean_dir <- "wildfire_data/clean"Conventions used in every clean file
- Counties.
GEOIDis the 5-character county FIPS code, stored as text ("09001", not9001). Always read it withcol_types = cols(GEOID = col_character())or the leading zero disappears. The county universe is the 3,108 counties of the contiguous United States plus DC, on 2020 Census boundaries, so Connecticut keeps its 8 legacy counties. - Dates.
dateis a calendar day in ISO form (2023-06-07). Satellite products use UTC (Coordinated Universal Time) days; fire detections also carry a local solar day; monitor data use the local calendar day. Each section says which one it uses. - Units. PM2.5 in micrograms per cubic meter (µg/m³), temperature in °C, precipitation in mm, fire radiative power (FRP) in megawatts (MW), area in km² or hectares.
- Missing vs. zero. A missing row or
NAcan mean “zero”, “not observed”, or “not applicable”. Each section states which, and the panel section shows how the joins handle it. - Big daily tables are split into one
.csv.gzfile per year inside a folder.read_csv()reads.gzfiles directly.
Data map
| Source | What it measures | Raw grain | Clean grain | Years |
|---|---|---|---|---|
| Boundaries and population | County, state, and province outlines; 2020 population; metro areas | polygon; block group | county | 2020 / 2021 / 2023 |
| SEER population | County population by age and sex | county × year × race × sex × age | county × year | 2006–2024 |
| NASA FIRMS | Satellite fire detections and fire radiative power | detection | detection; province × day; country × day | 2003–2024 |
| CWFIS NBAC | Canadian burned area by fire | fire polygon | fire; province × year | 1972–2025 |
| NOAA HMS | Analyst-drawn smoke plume outlines | plume polygon | plume; county × day; county × month | 2005–2025 |
| CSU kriged smoke | Smoke PM2.5 near the ground (main measure) | 15 km grid cell × day | county × day; county × month | 2006–2025 |
| Childs et al. | Smoke PM2.5 (machine-learning estimate) | county × smoke day | county × day; county × month | 2006–2023 |
| EPA EMBER | Modeled fire and Canadian-fire PM2.5 | 36 km grid cell × day | county × day | Apr–Sep 2023 |
| EPA AQS | Monitor PM2.5 and ozone | monitor × day | site × day; county × day; county × month | 2006–2025 |
| nClimGrid-Daily | Temperature and precipitation | county × month file, one column per day | county × day; county × month | 2006–2025 |
| ERA5 | Winds, humidity, boundary-layer height | 0.25° grid (not shipped) | county × day; county × month | 2006–2025 |
| Smoke episodes | Multi-day smoke episodes; Canadian share of nearby fires | project-built | episode; county × day | 2006–2025 |
| County panels | Everything joined | — | county × month; county × day (2023) | 2006–2025 |
What is in the folder
| Raw folder | Files | Size |
|---|---|---|
raw/aqs/ |
41 | 188 MB |
raw/boundaries/ |
4 | 149 MB |
raw/census_bg/ |
1 | 10 MB |
raw/childs/ |
2 | 115 MB |
raw/csu/ |
2 | 491 MB |
raw/ember/ |
5 | 100 MB |
raw/firms/ |
67 | 445 MB |
raw/hms/ |
21 | 85 MB |
raw/nbac/ |
3 | 1.2 GB |
raw/nclimgrid/ |
724 | 751 MB |
raw/seer/ |
2 | 271 MB |
| All raw files | 3.7 GB |
| Clean file or folder | Files | Size |
|---|---|---|
clean/aqs_county_day_2006_2025.csv.gz |
1 | 25 MB |
clean/aqs_county_month_2006_2025.csv |
1 | 6 MB |
clean/aqs_ozone_site_day_2006_2025.csv.gz |
1 | 27 MB |
clean/aqs_pm25_site_day_2006_2025.csv.gz |
1 | 18 MB |
clean/aqs_sites.csv |
1 | 237 KB |
clean/canada_provinces_2021.csv |
1 | 1 KB |
clean/canada_provinces_2021.gpkg |
1 | 1 MB |
clean/canadian_dominated_summary.csv |
1 | 15 KB |
clean/census_bg_centers_2020.csv |
1 | 10 MB |
clean/childs_smoke_county_day/ |
18 files (one per year) | 58 MB |
clean/childs_smoke_county_month_2006_2023.csv |
1 | 19 MB |
clean/counties_2020.csv |
1 | 334 KB |
clean/counties_2020.gpkg |
1 | 18 MB |
clean/county_cbsa_2023.csv |
1 | 256 KB |
clean/county_day_fire_share_2006_2025.csv.gz |
1 | 55 MB |
clean/csu_sample_county_day_2023.csv.gz |
1 | 7 MB |
clean/csu_smoke_county_day/ |
20 files (one per year) | 119 MB |
clean/csu_smoke_county_month_2006_2025.csv |
1 | 39 MB |
clean/ember_county_day_2023.csv |
1 | 26 MB |
clean/era5_county_day/ |
20 files (one per year) | 545 MB |
clean/era5_county_month_2006_2025.csv |
1 | 51 MB |
clean/firms_canada_province_day_2003_2024.csv |
1 | 3 MB |
clean/firms_country_day_2003_2024.csv |
1 | 901 KB |
clean/firms_modis_detections_2003_2024.csv.gz |
1 | 107 MB |
clean/hms_calendar_2005_2025.csv |
1 | 158 KB |
clean/hms_county_day_2006_2025.csv.gz |
1 | 15 MB |
clean/hms_county_month_2006_2025.csv |
1 | 21 MB |
clean/hms_smoke_plumes_2005_2025.csv |
1 | 50 MB |
clean/nbac_fires_1972_2025.csv |
1 | 7 MB |
clean/nbac_province_year_1972_2025.csv |
1 | 32 KB |
clean/nclimgrid_county_day/ |
20 files (one per year) | 239 MB |
clean/nclimgrid_county_month_2006_2025.csv |
1 | 42 MB |
clean/seer_county_year_2006_2024.csv |
1 | 4 MB |
clean/smoke_episodes_2006_2025.csv |
1 | 41 KB |
clean/smoke_person_days_by_bin.csv |
1 | 79 KB |
clean/us_states_2020.csv |
1 | 4 KB |
clean/us_states_2020.gpkg |
1 | 416 KB |
clean/wildfire_county_day_2023.csv.gz |
1 | 38 MB |
clean/wildfire_county_month_panel_2006_2025.csv.gz |
1 | 51 MB |
| All clean files | 1.6 GB |
Boundaries, population centers, and metro areas
These files say where places are and how many people live in them. The U.S. Census Bureau publishes 2020 county outlines in its cartographic boundary files, which are simplified for maps, and 2020 state outlines in TIGER/Line, its full-detail geographic database. For each of about 240,000 block groups (small areas of roughly 600 to 3,000 people), it also publishes the 2020 Census population and its center of population. Statistics Canada publishes the outlines of the 13 provinces and territories from its 2021 Census, and the Office of Management and Budget (OMB) groups U.S. counties into metropolitan and micropolitan areas.
Every other module builds on these files. Each county panel has one row per county in counties_2020.csv, the block-group file supplies population weights, and the state and province outlines label fire detections. They are snapshots of 2020–2023: they do not show how population or boundaries changed later, and they say nothing about where smoke goes.
| Provider | U.S. Census Bureau (county and state boundaries, centers of population, delineation file); Statistics Canada (provinces and territories); OMB (metro and micro area definitions) |
| Coverage | 2020 Census (U.S.), 2021 Census (Canada), July 2023 metro delineation · raw: all U.S. states, DC, Puerto Rico, the Island Areas (American Samoa, Guam, Northern Mariana Islands, U.S. Virgin Islands), and Canada; clean: contiguous U.S. + DC (counties, block groups), 50 states + DC, Canada |
| Raw unit | one polygon = a county, a state, or a province; one row = a block group (centers file) or a county in a metro or micro area (delineation file) |
| Clean unit | one row = a county (3,108), a block group (238,193), a state (51), a province or territory (13), or a county with its metro area (3,108) |
| Licence and citation | U.S. files: public domain (U.S. Government work); cite “U.S. Census Bureau, 2020 Cartographic Boundary Files, 2020 TIGER/Line Shapefiles, and 2020 Centers of Population” and “Office of Management and Budget, Bulletin No. 23-01 (July 2023)”. Canadian file: Statistics Canada Open Licence; cite “Statistics Canada, 2021 Census Boundary Files, Provinces and territories (Cartographic), catalogue 92-160-X” |
| Documentation | Census cartographic boundary files; TIGER/Line shapefiles; 2020 centers of population and method; StatCan boundary files reference guide; Census delineation files and glossary; Connecticut: OPM municipalities by planning region and Census notice on Connecticut’s county equivalents |
Raw files
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
boundaries/cb_2020_us_county_500k.zip |
12.0 MB | zipped ESRI shapefile, NAD83 longitude/latitude (EPSG:4269) | one county or county equivalent (3,234 features: all states, DC, Puerto Rico, Island Areas) |
boundaries/tl_2020_us_state.zip |
9.3 MB | zipped ESRI shapefile, NAD83 longitude/latitude (EPSG:4269) | one state, DC, or territory (56 features) |
boundaries/lpr_000b21a_e.zip |
127.5 MB | zipped ESRI shapefile, Statistics Canada Lambert in meters (EPSG:3347) | one province or territory (13 features) |
census_bg/CenPop2020_Mean_BG__dl20260927.txt |
10.1 MB | comma-separated text, UTF-8 with a byte-order mark | one block group (242,335 rows: 50 states, DC, Puerto Rico) |
boundaries/list1_2023.xlsx |
0.1 MB | Excel workbook, sheet “List 1”, every cell stored as text | one county or county equivalent inside a metro or micro area (1,915 rows) |
Raw variables
County shapefile cb_2020_us_county_500k.zip (Census 2020 cartographic boundary file, 1:500,000), described by the Census metadata file cb_2020_us_county_500k.shp.ea.iso.xml inside the zip.
| Variable | Type | Description |
|---|---|---|
STATEFP |
Character (2) | State FIPS code (Federal Information Processing Series: the federal number of the state), e.g., 36 = New York |
COUNTYFP |
Character (3) | County FIPS code within the state, e.g., 055 |
COUNTYNS |
Character (8) | County code in the Geographic Names Information System (GNIS) of the U.S. Geological Survey |
AFFGEOID |
Character (14) | American FactFinder identifier: summary level code + geovariant code + 00US + GEOID, e.g., 0500000US36055 |
GEOID |
Character (5) | County identifier: state FIPS + county FIPS, e.g., 36055 = Monroe County, New York. Keep it as text so the leading zero of codes such as 01001 survives |
NAME |
Character (100) | County name, e.g., Monroe |
NAMELSAD |
Character (100) | County name with its legal/statistical description, e.g., Monroe County, Orleans Parish, Baltimore city |
STUSPS |
Character (2) | U.S. Postal Service state abbreviation |
STATE_NAME |
Character (100) | State name |
LSAD |
Character (2) | Legal/statistical area description code: 00 blank, 03 City and Borough, 04 Borough, 05 Census Area, 06 County, 07 District, 10 Island, 12 Municipality, 13 Municipio, 15 Parish, 25 city |
ALAND |
Numeric (14) | Land area, square meters |
AWATER |
Numeric (14) | Water area inside the county’s legal boundary, square meters (includes Great Lakes and coastal water) |
| geometry | MULTIPOLYGON | County outline, generalized for 1:500,000 maps and clipped to the shoreline |
State shapefile tl_2020_us_state.zip (TIGER/Line 2020), described by the Census metadata file tl_2020_us_state.shp.ea.iso.xml inside the zip.
| Variable | Type | Description |
|---|---|---|
REGION |
Character (2) | Census region code: 1 Northeast, 2 Midwest, 3 South, 4 West, 9 Puerto Rico and the Island Areas |
DIVISION |
Character (2) | Census division code: 0 Puerto Rico and the Island Areas, 1 New England, 2 Middle Atlantic, 3 East North Central, 4 West North Central, 5 South Atlantic, 6 East South Central, 7 West South Central, 8 Mountain, 9 Pacific |
STATEFP |
Character (2) | State FIPS code |
STATENS |
Character (8) | State GNIS code |
GEOID |
Character (2) | State identifier (same as STATEFP) |
STUSPS |
Character (2) | U.S. Postal Service state abbreviation |
NAME |
Character (100) | State name |
LSAD |
Character (2) | Legal/statistical area description code; 00 (blank) for every state |
MTFCC |
Character (5) | Feature class code from MAF/TIGER, the Census Bureau’s address list (Master Address File) joined to its geographic database; G4000 = state or equivalent feature |
FUNCSTAT |
Character (1) | Functional status; A = active government providing primary general-purpose functions |
ALAND |
Numeric (14) | Land area, square meters |
AWATER |
Numeric (14) | Water area, square meters |
INTPTLAT |
Character (11) | Latitude of the Census internal point (a point inside the state), stored as text with a sign, e.g., +38.6472854 |
INTPTLON |
Character (12) | Longitude of the internal point, e.g., -080.6183274 |
| geometry | MULTIPOLYGON | Full-detail state outline; it includes inland water, coastal water, and the state’s part of the Great Lakes |
Province shapefile lpr_000b21a_e.zip (Statistics Canada 2021 cartographic boundary file), described by Table 4.1 of the StatCan reference guide.
| Variable | Type | Description |
|---|---|---|
PRUID |
Character (2) | Uniquely identifies a province or territory, e.g., 35 = Ontario |
DGUID |
Character (21) | Dissemination Geography Unique Identifier, e.g., 2021A000235 (2021 vintage, province schema, PRUID 35) |
PRNAME |
Character (100) | Province or territory name, in English and French when they differ, e.g., Quebec / Québec |
PRENAME |
Character (100) | Name in English |
PRFNAME |
Character (100) | Name in French |
PREABBR |
Character (10) | English abbreviation, e.g., Que. |
PRFABBR |
Character (10) | French abbreviation, e.g., Qc |
LANDAREA |
Numeric (12, 4) | Land area, square kilometers |
| geometry | MULTIPOLYGON | Outline with coastal water erased, so coasts become thousands of separate pieces. Inland lakes such as Lake Winnipeg stay inside; the Great Lakes and the St. Lawrence are erased as coastal water |
Block-group centers file CenPop2020_Mean_BG__dl20260927.txt, the Census file CenPop2020_Mean_BG.txt with the research project’s download date (2026-09-27) added to its name.
| Variable | Type | Description |
|---|---|---|
STATEFP |
text, 2 characters | State FIPS code |
COUNTYFP |
text, 3 characters | County FIPS code |
TRACTCE |
text, 6 characters | Census tract code |
BLKGRPCE |
text, 1 character | Block group code within the tract |
POPULATION |
integer | 2020 Census population of the block group (April 1, 2020); 0 for 1,393 block groups, which still get a coordinate (how it is chosen is not documented by the provider) |
LATITUDE |
decimal degrees, signed text, e.g., +32.464466 |
Latitude of the block group’s center of population: the point where the area would balance if every resident had equal weight. Census computes it from a point inside each census block, weighted by block population |
LONGITUDE |
decimal degrees, e.g., -086.486302 |
Longitude of the center of population (negative = west) |
Delineation file list1_2023.xlsx, sheet “List 1” (rows 1 and 2 are titles, row 3 holds the column names, and the last three rows are a blank line, a note, and a source line), with definitions from the Census glossary.
| Variable | Type | Description |
|---|---|---|
CBSA Code |
text (5 digits) | Core Based Statistical Area (CBSA) code. A CBSA is a metropolitan or micropolitan statistical area |
Metropolitan Division Code |
text (5 digits), blank if none | Code of the metropolitan division: a county or group of counties inside a metro area whose core has at least 2.5 million people |
CSA Code |
text (3 digits), blank if none | Combined Statistical Area code: two or more adjacent CBSAs linked by commuting |
CBSA Title |
text | CBSA name, e.g., Rochester, NY |
Metropolitan/Micropolitan Statistical Area |
text | Metropolitan Statistical Area (core urban area of at least 50,000 people) or Micropolitan Statistical Area (core urban area of 10,000 to 49,999) |
Metropolitan Division Title |
text | Name of the metropolitan division |
CSA Title |
text | Name of the CSA |
County/County Equivalent |
text | County name; for Connecticut, a planning region name |
State Name |
text | State name |
FIPS State Code |
text (2 digits) | State FIPS code |
FIPS County Code |
text (3 digits) | County FIPS code; for Connecticut, a planning region code 110–190 |
Central/Outlying County |
text | Central: the county holds all or much of the core urban area. Outlying: at least a quarter of its workers commute to the central counties, or at least a quarter of its jobs are held by commuters from them |
Cleaning steps
The script R/m00_places.R makes these decisions:
- County frame. Keep the 3,108 counties of the contiguous U.S. plus DC from the 2020 cartographic file. Its outlines stop at the shoreline, so a county polygon does not reach into Lake Michigan or the ocean. These are 2020 boundaries, so Connecticut keeps its 8 old counties.
- Groups typed in, then checked. Census regions, Census divisions, and the project’s eastern split are typed in as small tables with
tribble()so you can see them. The script stops if a typed division disagrees with the code in the Census state file. - Population from block groups. County and state populations are sums of block-group populations. A county’s representative point (
lat,lon) is its population-weighted center, computed with the Census formula from block-group centers. It is not a geometric center made withst_point_on_surface(). - One CRS. A coordinate reference system (CRS) says how coordinates map to places on Earth; EPSG codes number them. Every
.gpkgfile is converted to longitude/latitude on the WGS 84 datum (EPSG:4326), the CRS of the fire and smoke data. The Census files use NAD83 (EPSG:4269), which differs from WGS 84 by about one meter in the U.S. - Light, gap-free outlines. State outlines are simplified with a 500 m tolerance and province outlines with 1 km. “Tolerance” is the largest distance a simplified border may move from the original. A shared border is simplified once for both neighbors, so no gaps or overlaps open between two states or between two provinces. Canadian islands smaller than 1 km² are dropped first, and water enclosed by a single province (91 small holes, about 370 km² in total) becomes part of that province. The U.S. and Canadian layers come from different agencies and are simplified separately, so they do not share a border (see “Checks and cautions”).
- Province file read in pieces. The raw Canadian file is so detailed that reading it whole takes about 1.7 GB of memory. The script reads and shrinks one province at a time, which needs about 1 GB.
- Metro areas from the 2023 delineation. Each county gets its CBSA, metropolitan division, and CSA. Connecticut counties take the CBSA of their dominant planning region (chunks
m00-ct-regionstom00-ct-notes).
The first chunk creates the output folder and lists the state codes outside the contiguous U.S. Puerto Rico and the four Island Areas are territories, not states, so territories lists them on their own; the state file drops them.
# Every clean file of this module goes straight into wildfire_data/clean/.
dir.create(clean_dir, recursive = TRUE, showWarnings = FALSE)
# State FIPS codes outside the contiguous U.S.: Alaska, Hawaii, American
# Samoa, Guam, Northern Mariana Islands, Puerto Rico, U.S. Virgin Islands.
non_contiguous <- c("02", "15", "60", "66", "69", "72", "78")
# Puerto Rico and the four Island Areas: territories, not states.
territories <- c("60", "66", "69", "72", "78")The next chunk types in the Census regions and divisions and the project’s eastern split, then turns the space-separated state lists into one row per state.
# Census regions and divisions, typed in from the Census Bureau's official
# list so the grouping is visible. The next chunk checks every state against
# the division code stored in the Census state file.
census_divisions <- tribble(
~division_code, ~census_division, ~census_region, ~states,
"1", "New England", "Northeast", "CT ME MA NH RI VT",
"2", "Middle Atlantic", "Northeast", "NJ NY PA",
"3", "East North Central", "Midwest", "IL IN MI OH WI",
"4", "West North Central", "Midwest", "IA KS MN MO NE ND SD",
"5", "South Atlantic", "South", "DE DC FL GA MD NC SC VA WV",
"6", "East South Central", "South", "AL KY MS TN",
"7", "West South Central", "South", "AR LA OK TX",
"8", "Mountain", "West", "AZ CO ID MT NV NM UT WY",
"9", "Pacific", "West", "AK CA HI OR WA"
)
# The project's eastern split: four groups of eastern states used in its
# descriptive tables of Canadian smoke. It is NOT a Census grouping: its
# "Mid-Atlantic" adds DE, DC, and MD to the Census Middle Atlantic division.
east_split_states <- tribble(
~east_split, ~states,
"New England", "CT ME MA NH RI VT",
"Mid-Atlantic", "DE DC MD NJ NY PA",
"Great Lakes", "IL IN MI OH WI",
"Upper Midwest", "IA MN ND SD"
)
# One row per state: split the space-separated lists into rows, then join.
state_groups <- census_divisions |>
separate_longer_delim(states, delim = " ") |>
rename(state_abbr = states) |>
left_join(
east_split_states |>
separate_longer_delim(states, delim = " ") |>
rename(state_abbr = states),
by = "state_abbr"
) |>
mutate(east_split = replace_na(east_split, "Other"))
stopifnot(nrow(state_groups) == 51, !anyDuplicated(state_groups$state_abbr))Read the state file straight from its zip and check the typed-in divisions against the Census codes. GDAL is the library that read_sf() uses to open spatial files.
# TIGER/Line 2020 states. TIGER/Line is the Census Bureau's full-detail
# geographic database. GDAL reads a shapefile inside a .zip directly when
# the path starts with /vsizip/, so nothing needs to be unzipped.
states_raw <- read_sf(file.path(
"/vsizip", raw_dir, "boundaries", "tl_2020_us_state.zip", "tl_2020_us_state.shp"
))
# Keep the 50 states and DC, and attach the groups typed in above.
states <- states_raw |>
filter(!STATEFP %in% territories) |>
select(STATEFP, state_abbr = STUSPS, state_name = NAME, DIVISION, ALAND, AWATER) |>
left_join(state_groups, by = "state_abbr")
# The typed-in division of every state must equal the Census code.
stopifnot(
nrow(states) == 51,
!anyNA(states$census_division),
all(states$DIVISION == states$division_code)
)Read the county outlines and keep the contiguous counties as counties_frame. Land and water areas are converted from square meters to km².
# Census 2020 cartographic boundary file (1:500,000). Its county outlines are
# clipped to the shoreline, so the Great Lakes and coastal waters are not
# inside the county polygons (TIGER/Line county files extend into them).
counties_raw <- read_sf(file.path(
"/vsizip", raw_dir, "boundaries", "cb_2020_us_county_500k.zip",
"cb_2020_us_county_500k.shp"
))
counties_frame <- counties_raw |>
filter(!STATEFP %in% non_contiguous) |>
transmute(
GEOID, county_name = NAMELSAD, STATEFP, COUNTYFP,
state_abbr = STUSPS, state_name = STATE_NAME,
# ALAND and AWATER are in square meters; 1 km2 = 1,000,000 m2.
land_km2 = round(ALAND / 1e6, 2),
water_km2 = round(AWATER / 1e6, 2)
)
# 3,108 counties and county equivalents: 48 states plus DC. Connecticut keeps
# its 8 legacy counties because these are 2020 boundaries.
stopifnot(nrow(counties_frame) == 3108, !anyDuplicated(counties_frame$GEOID))Read the block-group centers. Declaring every FIPS column as character keeps codes such as 01 and 001 intact.
# Census 2020 centers of population, one row per block group (a small area
# of about 600 to 3,000 people). The file is a plain CSV despite its .txt
# name. FIPS codes must be read as text to keep their leading zeros.
bg_raw <- read_csv(
file.path(raw_dir, "census_bg", "CenPop2020_Mean_BG__dl20260927.txt"),
col_types = cols(
STATEFP = col_character(), COUNTYFP = col_character(),
TRACTCE = col_character(), BLKGRPCE = col_character(),
POPULATION = col_integer(), LATITUDE = col_double(), LONGITUDE = col_double()
)
)
bg_all <- bg_raw |>
transmute(
# 2 (state) + 3 (county) + 6 (tract) + 1 (block group) = 12 characters
bg_geoid = str_c(STATEFP, COUNTYFP, TRACTCE, BLKGRPCE),
GEOID = str_c(STATEFP, COUNTYFP),
STATEFP,
pop_2020 = POPULATION,
lat = LATITUDE,
lon = LONGITUDE
)
stopifnot(all(str_length(bg_all$bg_geoid) == 12), !anyDuplicated(bg_all$bg_geoid))Keep the contiguous block groups and write them. The checks confirm the row count, that every block group belongs to a frame county, that every county has block groups, and that every center lies inside the contiguous U.S. box of latitudes and longitudes.
# Keep the block groups of the 3,108 contiguous counties. Every one of them
# must belong to a county in the frame, and every county must have some.
bg_centers <- bg_all |>
filter(!STATEFP %in% non_contiguous) |>
select(bg_geoid, GEOID, pop_2020, lat, lon) |>
arrange(bg_geoid)
stopifnot(
nrow(bg_centers) == 238193,
all(bg_centers$GEOID %in% counties_frame$GEOID),
all(counties_frame$GEOID %in% bg_centers$GEOID),
all(bg_centers$pop_2020 >= 0),
all(between(bg_centers$lat, 24, 50)), all(between(bg_centers$lon, -125, -66))
)
# Grain: one row per block group (bg_geoid is unique).
write_csv(bg_centers, file.path(clean_dir, "census_bg_centers_2020.csv"), na = "")Sum population by county and compute each county’s population-weighted center. The longitude formula weights by the cosine of latitude, as the Census Bureau does. The weight is computed first, in its own column, and the county results get new names, so the order of the lines inside summarize() cannot change the answer.
# County population is the sum over its block groups. The county's
# population-weighted center is the average of block-group centers, each
# weighted by its population: it sits where people live, not at the middle
# of the county's land. We use the Census Bureau's formula: longitude is also
# weighted by cos(latitude), because a degree of longitude gets shorter
# toward the North Pole.
county_pop <- bg_centers |>
# Longitude weight of each block group: population x cos(latitude).
mutate(w_lon = pop_2020 * cos(lat * pi / 180)) |>
group_by(GEOID) |>
# New names for the county results, so no line reads a county value
# where it needs a block-group value.
summarize(
center_lat = weighted.mean(lat, w = pop_2020),
center_lon = weighted.mean(lon, w = w_lon),
total_pop = sum(pop_2020),
.groups = "drop"
) |>
transmute(GEOID, lat = round(center_lat, 5), lon = round(center_lon, 5),
pop_2020 = total_pop)
# Every county has residents, so every weighted mean is defined.
stopifnot(nrow(county_pop) == 3108, all(county_pop$pop_2020 > 0))Assemble the county table as a new object, counties_sf, so that counties_frame stays unchanged and the chunk can be run again; then convert it to EPSG:4326, check it, and write the CSV and GeoPackage files. A GeoPackage (.gpkg) is one file that holds both the table and the outlines; the chunk reads it back with read_sf() and counts its rows, because a GeoPackage write can fail partway on some drives (see “Checks and cautions”).
# The result gets a new name, so its input (counties_frame) stays unchanged
# and this chunk gives the same answer when you run it again.
counties_sf <- counties_frame |>
left_join(st_drop_geometry(states) |>
select(state_abbr, census_region, census_division, east_split),
by = "state_abbr") |>
left_join(county_pop, by = "GEOID") |>
select(GEOID, county_name, STATEFP, COUNTYFP, state_abbr, state_name,
census_region, census_division, east_split,
land_km2, water_km2, lat, lon, pop_2020) |>
arrange(GEOID) |>
# NAD83 (the Census datum) to WGS 84 (EPSG:4326), the CRS of the fire and
# smoke data; the two differ by about a meter in the U.S.
st_transform(4326)
# Grain: one row per county; no missing values in any column.
# 329,260,619 is the official 2020 Census count for the 48 states and DC.
stopifnot(
nrow(counties_sf) == 3108, !anyDuplicated(counties_sf$GEOID),
all(str_length(counties_sf$GEOID) == 5),
!anyNA(st_drop_geometry(counties_sf)),
all(between(counties_sf$lat, 24, 50)), all(between(counties_sf$lon, -125, -66)),
all(counties_sf$land_km2 > 0), all(counties_sf$water_km2 >= 0),
sum(counties_sf$pop_2020) == 329260619,
all(st_is_valid(counties_sf))
)
write_csv(st_drop_geometry(counties_sf), file.path(clean_dir, "counties_2020.csv"),
na = "")
write_sf(counties_sf, file.path(clean_dir, "counties_2020.gpkg"), delete_dsn = TRUE)
# A GeoPackage is a small database, and on some drives a write can fail
# partway, so read the file back and count its rows.
stopifnot(nrow(read_sf(file.path(clean_dir, "counties_2020.gpkg"))) == 3108)
count(st_drop_geometry(counties_sf), east_split)The next two chunks define helper functions. The first simplifies polygons that share borders without opening gaps or overlaps; you do not need every line of it to use the outputs.
# Simplify a set of polygons that share borders (states, provinces) without
# opening gaps or overlaps between neighbors. Simplifying each polygon on its
# own would move a shared border differently on its two sides, so a fire
# detection near the border could land in two polygons or in none. Instead:
# 1. take all outlines as lines and merge them, so each shared border is
# stored once;
# 2. simplify each line (tolerance in the units of the CRS, here meters);
# 3. rebuild polygons ("faces") from the simplified lines;
# 4. give each face to the unit that contains a point inside it, or, when no
# unit does (a thin island or a sliver), to the nearest unit.
# Side effect of step 4: a hole (water enclosed by one unit) has no owner,
# so it goes to the nearest unit and becomes part of the surrounding polygon.
# x must use a projected CRS (meters), not longitude/latitude.
simplify_shared_borders <- function(x, id, tolerance) {
lines <- x |>
st_geometry() |>
st_boundary() |>
st_union() |>
st_line_merge() |>
st_cast("LINESTRING") |>
st_simplify(preserveTopology = TRUE, dTolerance = tolerance)
faces <- st_union(lines) |>
st_polygonize() |>
st_collection_extract("POLYGON")
faces <- st_sf(geometry = faces)
inside <- st_intersects(st_point_on_surface(faces), x)
owner <- map_int(inside, \(i) if (length(i) > 0) i[1] else NA_integer_)
orphan <- is.na(owner)
owner[orphan] <- st_nearest_feature(faces[orphan, ], x)
faces[[id]] <- x[[id]][owner]
faces |>
group_by(across(all_of(id))) |>
summarize(.groups = "drop") |>
st_cast("MULTIPOLYGON")
}The second converts a layer to longitude/latitude and repairs any tiny polygon that the conversion made invalid. The repair uses GEOS, the library that sf uses for flat (planar) geometry, so it needs s2 (the spherical geometry engine that sf uses for longitude/latitude data) switched off. sf_use_s2() is a global setting, so on.exit() restores it when the function ends, even after an error.
# Convert to longitude/latitude (EPSG:4326). Reprojection can make a tiny
# simplified island cross itself, so repair with planar (GEOS) rules, then
# confirm validity under the spherical (s2) rules that sf uses for lon/lat.
to_lonlat <- function(x) {
n_rows <- nrow(x)
# sf_use_s2() is a global switch. on.exit() puts back the old setting when
# the function ends, even if a step fails, so s2 never stays off by mistake.
old_s2 <- suppressMessages(sf_use_s2(FALSE))
on.exit(suppressMessages(sf_use_s2(old_s2)), add = TRUE)
x <- st_transform(x, 4326) |>
st_make_valid() |>
st_collection_extract("POLYGON", warn = FALSE) |>
st_cast("MULTIPOLYGON")
suppressMessages(sf_use_s2(TRUE)) # check validity with the s2 rules
stopifnot(nrow(x) == n_rows, all(st_is_valid(x)))
x
}Build the state table, simplify the state outlines, and write both files.
# State population comes from all block groups, including Alaska and Hawaii.
state_pop <- bg_all |>
group_by(STATEFP) |>
summarize(pop_2020 = sum(pop_2020), .groups = "drop")
states_out <- states |>
left_join(state_pop, by = "STATEFP") |>
transmute(
STATEFP, state_abbr, state_name, census_region, census_division, east_split,
contiguous = !STATEFP %in% non_contiguous,
land_km2 = round(ALAND / 1e6, 2),
water_km2 = round(AWATER / 1e6, 2),
pop_2020
) |>
arrange(STATEFP)
# Simplify with a 500 m tolerance in CONUS Albers (EPSG:5070, meters). Alaska
# and Hawaii are far from its center, so there the tolerance is only roughly 500 m.
states_shapes <- states_out |>
select(STATEFP) |>
st_transform(5070) |>
simplify_shared_borders("STATEFP", tolerance = 500) |>
to_lonlat()
states_sf <- states_out |>
st_drop_geometry() |>
inner_join(states_shapes, by = "STATEFP") |>
st_as_sf()
# Grain: one row per state or DC; 49 of them are contiguous (48 + DC).
# 331,449,281 is the official 2020 Census count for the 50 states and DC.
stopifnot(
nrow(states_sf) == 51, !anyDuplicated(states_sf$STATEFP),
sum(states_sf$contiguous) == 49, !anyNA(st_drop_geometry(states_sf)),
all(states_sf$land_km2 > 0), sum(states_sf$pop_2020) == 331449281
)
write_csv(st_drop_geometry(states_sf), file.path(clean_dir, "us_states_2020.csv"),
na = "")
write_sf(states_sf, file.path(clean_dir, "us_states_2020.gpkg"), delete_dsn = TRUE)
stopifnot(nrow(read_sf(file.path(clean_dir, "us_states_2020.gpkg"))) == 51)Define how to read one province. An SQL query (SELECT * FROM layer WHERE PRUID = '62') makes GDAL return only the matching row of the shapefile. The function splits that province into its pieces of land, drops islands under 1 km², and thins the outlines to 50 m. It returns a list with a one-row info table and the shapes.
# Statistics Canada 2021 cartographic boundary file: one multipolygon per
# province or territory, in Statistics Canada Lambert (EPSG:3347, meters).
# It is very detailed: about 176,000 pieces of land, mostly small islands
# along the coasts and in the Great Lakes (most in Nunavut, Ontario, British
# Columbia, Quebec, and Newfoundland and Labrador). Reading it all at once
# takes about 1.7 GB of memory, so we read one province at a time with an
# SQL query and shrink it before the next one.
provinces_shp <- file.path(
"/vsizip", raw_dir, "boundaries", "lpr_000b21a_e.zip", "lpr_000b21a_e.shp"
)
# Read one province (by its code, PRUID), split it into its separate pieces
# of land, drop islands smaller than 1 km2, and thin the outlines to 50 m.
# After thinning, a shared border can differ by up to 50 m on its two sides;
# simplify_shared_borders() absorbs that difference later.
read_province <- function(pruid) {
query <- str_glue("SELECT * FROM lpr_000b21a_e WHERE PRUID = '{pruid}'")
parts <- read_sf(provinces_shp, query = query) |>
# a query names the geometry column "_ogr_geometry_"; rename it
st_set_geometry("geometry") |>
st_cast("POLYGON", warn = FALSE) |>
mutate(part_km2 = as.numeric(st_area(geometry)) / 1e6)
list(
# one row: the names, the land area, and what the 1 km2 cut removed
info = parts |>
st_drop_geometry() |>
summarize(n_islands_dropped = sum(part_km2 < 1),
km2_dropped = sum(part_km2[part_km2 < 1]),
.by = c(PRUID, PRENAME, PRFNAME, LANDAREA)),
shapes = parts |>
filter(part_km2 >= 1) |>
select(PRUID) |>
st_simplify(preserveTopology = TRUE, dTolerance = 50)
)
}Run the function for the 13 provinces and territories with map(), then stack the 13 info rows and the 13 sets of shapes. This is the slowest step of the module (about 20 seconds).
# Two-letter postal abbreviations; the file only has the longer forms
# ("N.L.", "Que.").
province_codes <- tribble(
~PRUID, ~prov_abbr,
"10", "NL", "11", "PE", "12", "NS", "13", "NB", "24", "QC", "35", "ON",
"46", "MB", "47", "SK", "48", "AB", "59", "BC", "60", "YT", "61", "NT", "62", "NU"
)
# Run read_province() for the 13 provinces and territories, then stack the
# 13 info rows and the 13 sets of shapes.
provinces_read <- map(province_codes$PRUID, read_province)
province_info <- map(provinces_read, "info") |> bind_rows()
province_parts <- map(provinces_read, "shapes") |> bind_rows()
rm(provinces_read)
stopifnot(nrow(province_info) == 13,
setequal(province_parts$PRUID, province_codes$PRUID))
# The dropped islands are about 0.05% of Canada's land.
province_info |>
summarize(n_islands = sum(n_islands_dropped), km2 = sum(km2_dropped))Simplify the shared provincial borders with a 1 km tolerance, add the names and two-letter abbreviations, and write the files.
provinces_shapes <- province_parts |>
simplify_shared_borders("PRUID", tolerance = 1000) |>
to_lonlat()
provinces_sf <- province_info |>
transmute(PRUID, prov_name = PRENAME, prov_name_fr = PRFNAME,
land_km2 = round(LANDAREA, 2)) |>
left_join(province_codes, by = "PRUID") |>
relocate(prov_abbr, .after = PRUID) |>
inner_join(provinces_shapes, by = "PRUID") |>
st_as_sf() |>
arrange(PRUID)
# Grain: one row per province or territory (13); every code and two-letter
# abbreviation is present and unique.
stopifnot(
nrow(provinces_sf) == 13, !anyDuplicated(provinces_sf$PRUID),
!anyDuplicated(provinces_sf$prov_abbr), !anyNA(st_drop_geometry(provinces_sf)),
all(provinces_sf$land_km2 > 0)
)
write_csv(st_drop_geometry(provinces_sf),
file.path(clean_dir, "canada_provinces_2021.csv"), na = "")
write_sf(provinces_sf, file.path(clean_dir, "canada_provinces_2021.gpkg"),
delete_dsn = TRUE)
stopifnot(nrow(read_sf(file.path(clean_dir, "canada_provinces_2021.gpkg"))) == 13)Read the delineation sheet as text and drop its footer rows.
# OMB July 2023 delineation of metropolitan and micropolitan areas. Rows 1
# and 2 of the sheet are titles, so the column names are in row 3 (skip = 2).
# Reading every column as text keeps codes such as "001" intact.
cbsa_raw <- read_excel(
file.path(raw_dir, "boundaries", "list1_2023.xlsx"),
sheet = "List 1", skip = 2, col_types = "text"
)
delineation <- cbsa_raw |>
rename(
cbsa_code = `CBSA Code`, metdiv_code = `Metropolitan Division Code`,
csa_code = `CSA Code`, cbsa_title = `CBSA Title`,
cbsa_kind = `Metropolitan/Micropolitan Statistical Area`,
metdiv_title = `Metropolitan Division Title`, csa_title = `CSA Title`,
delin_name = `County/County Equivalent`, st = `FIPS State Code`,
co = `FIPS County Code`, cbsa_central = `Central/Outlying County`
) |>
# The last three rows are a blank line and two notes, with no FIPS codes.
filter(!is.na(st), !is.na(co)) |>
mutate(
delin_geoid = str_c(st, co),
cbsa_type = case_when(
cbsa_kind == "Metropolitan Statistical Area" ~ "metro",
cbsa_kind == "Micropolitan Statistical Area" ~ "micro"
)
)
stopifnot(nrow(delineation) == 1915, !anyDuplicated(delineation$delin_geoid),
!anyNA(delineation$cbsa_type))Since 2022 the Census Bureau uses Connecticut’s 9 planning regions instead of its 8 old counties (Federal Register notice of June 6, 2022), and the 2023 delineation lists only the regions. As in the research project, each old county gets the CBSA of its dominant planning region (the region that holds most of its people); the first table gives that region for each county, based on the Connecticut Office of Policy and Management’s (OPM) list of municipalities by planning region.
# Since 2022 Census uses Connecticut's 9 planning regions (codes 09110-09190)
# instead of its 8 old counties, and the 2023 delineation lists the regions.
# Our 2020 frame keeps the 8 counties, and a county can straddle regions.
# Each county takes the CBSA of its dominant planning region (the one with
# most of its population). The table gives that region's code, the county's
# number of towns, and how many of them lie in that region. Source: the
# Connecticut Office of Policy and Management (OPM) list of municipalities
# by planning region.
ct_counties <- tribble(
~GEOID, ~delin_geoid, ~towns, ~towns_in_region,
"09001", "09190", 23, 16,
"09003", "09110", 29, 26,
"09005", "09160", 26, 19,
"09007", "09130", 15, 15,
"09009", "09170", 27, 15,
"09011", "09180", 21, 18,
"09013", "09110", 13, 12,
"09015", "09150", 15, 14
)The second table lists, with one row per town, the towns of each county that lie in another planning region (34 of Connecticut’s 169 towns). The checks make sure every town is counted exactly once.
# The towns of each county that lie in another planning region, with that
# region's code (from the same OPM list of municipalities by planning region).
# A long list continues on a second row; separate_longer_delim() then makes
# one row per town.
ct_other_towns <- tribble(
~GEOID, ~region, ~town,
"09001", "09120", "Bridgeport, Easton, Fairfield, Monroe, Stratford, Trumbull",
"09001", "09140", "Shelton",
"09003", "09140", "Bristol",
"09003", "09160", "Burlington, Hartland",
"09005", "09140", "Bethlehem, Plymouth, Thomaston, Watertown, Woodbury",
"09005", "09190", "Bridgewater, New Milford",
"09009", "09140", "Ansonia, Beacon Falls, Cheshire, Derby, Middlebury, Naugatuck",
"09009", "09140", "Oxford, Prospect, Seymour, Southbury, Waterbury, Wolcott",
"09011", "09130", "Lyme, Old Lyme",
"09011", "09150", "Voluntown",
"09013", "09150", "Union",
"09015", "09180", "Windham"
) |>
separate_longer_delim(town, delim = ", ")
# Each of Connecticut's 169 towns is counted once: towns in the dominant
# region plus towns elsewhere equal the county's towns.
towns_elsewhere <- map_int(ct_counties$GEOID, \(g) sum(ct_other_towns$GEOID == g))
stopifnot(
sum(ct_counties$towns) == 169, !anyDuplicated(ct_other_towns$town),
all(ct_counties$towns == ct_counties$towns_in_region + towns_elsewhere),
all(c(ct_counties$delin_geoid, ct_other_towns$region) %in% delineation$delin_geoid)
)Look up the CBSA of each town’s planning region in the delineation and keep the towns whose CBSA differs from their county’s. These go into the ct_note column, so you can see where the county-level CBSA is only approximate.
# Look up the CBSA of every planning region in the delineation. Keep the
# towns whose region is in a different CBSA than their county's dominant
# region, and write them as "Town, Town (CBSA); Town (CBSA)".
region_cbsa <- delineation |>
select(region = delin_geoid, cbsa_code, cbsa_title)
county_region_cbsa <- ct_counties |>
left_join(region_cbsa, by = join_by(delin_geoid == region)) |>
select(GEOID, county_cbsa = cbsa_code)
ct_notes <- ct_other_towns |>
left_join(region_cbsa, by = "region") |>
left_join(county_region_cbsa, by = "GEOID") |>
filter(cbsa_code != county_cbsa) |>
mutate(cbsa_name = str_remove(cbsa_title, ", CT$")) |>
summarize(towns = str_c(town, collapse = ", "), .by = c(GEOID, cbsa_name)) |>
summarize(towns_other_cbsa = str_c(towns, " (", cbsa_name, ")", collapse = "; "),
.by = GEOID)
# Only Middlesex County (09007) lies wholly in one CBSA.
stopifnot(nrow(ct_notes) == 7, !"09007" %in% ct_notes$GEOID)
ct_notesJoin each county to its delineation row and write one row per county.
county_cbsa <- st_drop_geometry(counties_sf) |>
select(GEOID, state_abbr, county_name) |>
# Connecticut counties look up their dominant region; all others, themselves.
left_join(select(ct_counties, GEOID, delin_geoid), by = "GEOID") |>
mutate(delin_geoid = coalesce(delin_geoid, GEOID)) |>
left_join(delineation |>
select(delin_geoid, delin_name, cbsa_code, cbsa_title, cbsa_type,
cbsa_central, metdiv_code, metdiv_title, csa_code, csa_title),
by = "delin_geoid") |>
left_join(ct_counties, by = c("GEOID", "delin_geoid")) |>
left_join(ct_notes, by = "GEOID") |>
mutate(
cbsa_ct_approx = state_abbr == "CT",
ct_note = if_else(
cbsa_ct_approx,
str_c("Via ", delin_name, " (", towns_in_region, " of ", towns, " towns). ",
"Towns in another CBSA: ", replace_na(towns_other_cbsa, "none"), "."),
NA_character_
)
) |>
select(GEOID, state_abbr, county_name, delin_geoid, cbsa_code, cbsa_title,
cbsa_type, cbsa_central, metdiv_code, metdiv_title, csa_code, csa_title,
cbsa_ct_approx, ct_note) |>
arrange(GEOID)
# Grain: one row per frame county. A missing cbsa_code means the county lies
# outside every metro and micro area (not a missing value).
stopifnot(
nrow(county_cbsa) == 3108, !anyDuplicated(county_cbsa$GEOID),
setequal(county_cbsa$GEOID, counties_sf$GEOID),
!anyNA(county_cbsa$cbsa_code[county_cbsa$state_abbr == "CT"]),
all(is.na(county_cbsa$cbsa_code) == is.na(county_cbsa$cbsa_type))
)
write_csv(county_cbsa, file.path(clean_dir, "county_cbsa_2023.csv"), na = "")
count(county_cbsa, cbsa_type)Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
counties_2020.csv |
3,108 | a contiguous-U.S. county or DC | 0.33 MB |
counties_2020.gpkg |
3,108 | the same county, with its outline (layer counties_2020, EPSG:4326) |
17.7 MB |
census_bg_centers_2020.csv |
238,193 | a contiguous-U.S. block group | 10.2 MB |
us_states_2020.csv |
51 | a state or DC | 0.004 MB |
us_states_2020.gpkg |
51 | the same state, with its simplified outline (layer us_states_2020, EPSG:4326) |
0.41 MB |
canada_provinces_2021.csv |
13 | a province or territory | 0.001 MB |
canada_provinces_2021.gpkg |
13 | the same province, with its simplified outline (layer canada_provinces_2021, EPSG:4326) |
1.13 MB |
county_cbsa_2023.csv |
3,108 | a contiguous-U.S. county with its metro or micro area | 0.25 MB |
Clean variables
counties_2020.csv and counties_2020.gpkg share these columns; the .gpkg adds the geometry column geom. Rows are sorted by GEOID, and no cell is missing.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (state + county), 2020 boundaries |
county_name |
chr | — | County name with its legal description, e.g., Monroe County, Orleans Parish, Richmond city (raw NAMELSAD) |
STATEFP |
chr | — | 2-digit state FIPS code |
COUNTYFP |
chr | — | 3-digit county FIPS code within the state |
state_abbr |
chr | — | 2-letter postal abbreviation |
state_name |
chr | — | State name |
census_region |
chr | — | Census region: Northeast, Midwest, South, West |
census_division |
chr | — | One of the 9 Census divisions, e.g., Middle Atlantic |
east_split |
chr | — | The research project’s eastern split, used in its descriptive tables of Canadian smoke (project scripts 05a_counties_seer.R and 06_episode_count.R): New England (CT ME MA NH RI VT), Mid-Atlantic (DE DC MD NJ NY PA), Great Lakes (IL IN MI OH WI), Upper Midwest (IA MN ND SD), Other (all remaining states) |
land_km2 |
dbl | km² | Land area (raw ALAND / 1,000,000), 2 decimals |
water_km2 |
dbl | km² | Water area inside the legal county boundary (raw AWATER / 1,000,000), including Great Lakes and coastal water |
lat |
dbl | degrees north | Latitude of the county’s population-weighted center: mean of block-group center latitudes weighted by population |
lon |
dbl | degrees east (negative = west) | Longitude of the population-weighted center, weighted by population × cos(latitude) |
pop_2020 |
dbl | persons | 2020 Census population (April 1, 2020), the sum of the county’s block groups; a whole number |
geom (.gpkg only) |
MULTIPOLYGON | EPSG:4326 | County outline from the 2020 cartographic file, clipped to the shoreline, not simplified further |
census_bg_centers_2020.csv: one row per block group, sorted by bg_geoid, no missing cells.
| Variable | Type | Units | Description |
|---|---|---|---|
bg_geoid |
chr | — | 12-character block-group code: state (2) + county (3) + tract (6) + block group (1) |
GEOID |
chr | — | 5-character county code (first 5 characters of bg_geoid); joins to counties_2020.csv |
pop_2020 |
dbl | persons | 2020 Census population; 0 for 1,310 block groups (zero weight in any average) |
lat |
dbl | degrees north | Latitude of the block group’s center of population, as published |
lon |
dbl | degrees east (negative = west) | Longitude of the center of population, as published |
us_states_2020.csv and us_states_2020.gpkg share these columns; the .gpkg adds geom. Rows are sorted by STATEFP, and no cell is missing.
| Variable | Type | Units | Description |
|---|---|---|---|
STATEFP |
chr | — | 2-digit state FIPS code |
state_abbr |
chr | — | 2-letter postal abbreviation |
state_name |
chr | — | State name |
census_region |
chr | — | Census region |
census_division |
chr | — | Census division |
east_split |
chr | — | Project grouping, as in counties_2020.csv |
contiguous |
lgl | — | TRUE for the 48 contiguous states and DC; FALSE for Alaska and Hawaii |
land_km2 |
dbl | km² | Land area from the TIGER/Line state file |
water_km2 |
dbl | km² | Water area from the TIGER/Line state file |
pop_2020 |
dbl | persons | 2020 Census population, the sum of the state’s block groups |
geom (.gpkg only) |
MULTIPOLYGON | EPSG:4326 | State outline simplified with a 500 m tolerance; includes coastal water and the state’s share of the Great Lakes, out to the international border |
canada_provinces_2021.csv and canada_provinces_2021.gpkg share these columns; the .gpkg adds geom. Rows are sorted by PRUID, and no cell is missing.
| Variable | Type | Units | Description |
|---|---|---|---|
PRUID |
chr | — | 2-digit province or territory code (raw PRUID); read it with col_types = cols(PRUID = "c") |
prov_abbr |
chr | — | 2-letter postal abbreviation: NL, PE, NS, NB, QC, ON, MB, SK, AB, BC, YT, NT, NU |
prov_name |
chr | — | English name (raw PRENAME) |
prov_name_fr |
chr | — | French name (raw PRFNAME) |
land_km2 |
dbl | km² | Land area published by Statistics Canada (raw LANDAREA) |
geom (.gpkg only) |
MULTIPOLYGON | EPSG:4326 | Outline simplified with a 1 km tolerance; islands under 1 km² dropped. Inland lakes such as Lake Winnipeg stay inside; the Great Lakes and the St. Lawrence are erased as coastal water, so they lie in no province. Small enclosed holes are filled |
county_cbsa_2023.csv: one row per county in counties_2020.csv, sorted by GEOID. Read the code columns as text: col_types = cols(cbsa_code = "c", metdiv_code = "c", csa_code = "c").
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county code |
state_abbr |
chr | — | 2-letter postal abbreviation |
county_name |
chr | — | County name, as in counties_2020.csv |
delin_geoid |
chr | — | Code looked up in the delineation: equal to GEOID, except in Connecticut, where it is the dominant planning region (09110–09190) |
cbsa_code |
chr | — | 5-digit CBSA code, July 2023. Empty = the county is in no metro or micro area (a real answer, not missing data) |
cbsa_title |
chr | — | CBSA name; empty outside a CBSA |
cbsa_type |
chr | — | metro (metropolitan) or micro (micropolitan); empty outside a CBSA |
cbsa_central |
chr | — | Central or Outlying county of its CBSA (for Connecticut, the planning region’s status); empty outside a CBSA |
metdiv_code |
chr | — | 5-digit metropolitan division code; empty when the CBSA has no divisions |
metdiv_title |
chr | — | Metropolitan division name; empty when none |
csa_code |
chr | — | 3-digit CSA code; empty when the CBSA belongs to no CSA |
csa_title |
chr | — | CSA name; empty when none |
cbsa_ct_approx |
lgl | — | TRUE for the 8 Connecticut counties, whose CBSA comes from the dominant planning region and is therefore approximate; FALSE elsewhere |
ct_note |
chr | — | Connecticut only: the planning region used, its share of the county’s towns, and the towns that lie in another CBSA; empty elsewhere |
Checks and cautions
- County frame matches the project.
counties_2020.csvhas the same 3,108 GEOIDs as the project’scounties_2020.parquet(built from the TIGER/Line county file). County names, state codes and names, Census regions and divisions, andeast_splitagree for all 3,108 rows. Land and water areas agree within 0.005 km² (rounding).east_splitcounts: Great Lakes 437, Mid-Atlantic 178, New England 67, Upper Midwest 305, Other 2,121. - Population totals are exact. County populations sum to 329,260,619, which is the official 2020 Census count for the 50 states and DC (331,449,281) minus Alaska (733,391) and Hawaii (1,455,271). The state file sums to 331,449,281, and each contiguous state equals the sum of its counties. The research project also uses July 1, 2020 county population estimates from SEER, a National Cancer Institute program that republishes Census Bureau estimates (we used only its population counts, and the file is not in the student bundle). Against those estimates, the total differs by 0.04% and the median county by 0.18%. Census counts and estimates measure slightly different things, so do not mix them in one analysis.
- Population centers. 3,095 of the 3,108 population-weighted centers lie inside their own county. The other 13 are expected: the point marks where people live on average, not a spot inside the county. Five fall in water (Monroe County, Florida, in the Keys; Hancock County, Maine; Perquimans County, North Carolina; Island and Pacific Counties, Washington), six fall in a city that the county surrounds (Baltimore County, Maryland, and five Virginia counties around independent cities), and two fall in a neighboring county across a river (Lee County, Iowa; Pepin County, Wisconsin). The median distance from the Census internal point (a point inside the county) is 5.7 km.
- Block groups. 238,193 rows, one per block group, all with a 12-character
bg_geoidthat starts with a countyGEOIDin the frame. 1,310 have zero population and are kept with weight 0. - 2020 Census noise. The Census Bureau added small amounts of random noise to 2020 counts below the state level to protect privacy. County totals are reliable; counts for single block groups are less precise.
- State outlines. Simplification cut the state file from 967,124 to 17,524 vertices (the corner points that make up an outline), and every simplified state keeps its area within 0.7% of the original. No two states overlap. As a test, 99.992% of the 238,193 block-group centers fall in their own state. Of the other 19, nine already lie outside their own state in the full-detail file (an average point can sit across a river border), and ten lie within 340 m of a state line, where simplification moved the border. State outlines include coastal water and the Great Lakes, while county outlines stop at the shore, so the two layers do not line up along coasts. Use the county layer for land maps.
- Province outlines. The raw file has 175,779 separate pieces of land. Nunavut has 36% of them; Ontario, British Columbia, Quebec, and Newfoundland and Labrador have most of the rest. The script dropped the 172,159 pieces (islands) smaller than 1 km² (about 4,850 km² in total, 0.05% of Canada). Water enclosed by a single province (91 holes in the pieces of land that were kept, about 370 km² in total, mostly in Nunavut and Ontario) was filled and now counts as part of that province. The simplified layer has 64,433 vertices, no holes, and no overlaps between provinces. As a test, 78,157 random points on the full-detail land were labelled with the simplified layer: 99.67% got the right province; 0.30% (236 points) fell in no province, mostly near coasts (median 208 m from the nearest province; the farthest, 16 km, sat on a dropped island); and 0.03% (24 points, all within 790 m of a border) got a neighbor. When you label fire detections, give points that fall in no polygon the nearest province with
st_nearest_feature(): in the test, this gave the right province for all 236 such points. - Province area. Province polygons include inland lakes such as Lake Winnipeg and Great Slave Lake, so their area is larger than
land_km2(Quebec: about 1,502,000 km² of polygon, 1,298,600 km² of land). Useland_km2for land area. - U.S. and Canadian layers do not share a border. The state and province layers come from two agencies and were simplified separately (500 m and 1 km). Along the international border they overlap in 18 state–province pairs, 318 km² in total (the largest are Alaska–Yukon, 112 km², and Alaska–British Columbia, 37 km²), and elsewhere they leave thin gaps. In a test with 63,625 random land points within 5 km of the border, about 0.4% fell in both layers, and 3.8% of the Canadian points (0.5% of the U.S. points) fell in neither. A point outside its own country’s layer was usually close to it (median 160 m; 99% within 1.4 km). The farthest, up to 4.3 km, sat on small islands that the script dropped (Gulf Islands, Thousand Islands, Rainy Lake, Lake of the Woods).
- Border lakes and rivers. The two agencies treat water differently. U.S. states include their half of the Great Lakes and of border rivers, out to the international line. Statistics Canada treats the Great Lakes and the St. Lawrence as coastal water and erases them, so their Canadian part lies in no polygon at all. Test points in Georgian Bay, on the Canadian side of Lakes Superior, Huron, Erie, Ontario, and St. Clair, and in the St. Lawrence (Lake Saint-Pierre and the estuary) fall in no state and no province. Other lakes are inside their province: the Canadian part of Lake of the Woods (a border lake) is in Ontario, and Lake Nipigon and Lake Winnipeg are in Ontario and Manitoba.
- How to label fire detections near the border. Take each detection’s country from the FIRMS file it came from (the yearly
_Canadaor_United_Statesfile), label it only with that country’s layer, and give a point that falls in no polygon the nearest state or province withst_nearest_feature(). In the test above, this rule gave the right state or province for 63,622 of 63,625 points (99.995%); the 3 misses lie on a state line (Montana–North Dakota, North Dakota–Minnesota) right at the border. Testing each point against both layers instead would give some border points two labels and leave others with none. - Metro areas match the project.
county_cbsa_2023.csvagrees with the project’scounty_cbsa_2023.parquetin every column for all 3,108 counties (0 mismatches). 1,833 counties are in a CBSA (1,179 metro, 654 micro) and 1,275 are in none. These hold 94.6% of the 2020 population (metro 86.2%, micro 8.4%). 139 counties are in a metropolitan division and 1,269 in a CSA. - Connecticut is approximate. In all 8 Connecticut counties, the dominant planning region has both the most towns and a majority of the 2020 population: from about 55% in Litchfield County to 100% in Middlesex County. We calculated these shares from 2020 town populations, which are not in the bundle. For 7 of the 8 Connecticut counties, some towns lie in a different CBSA than the one assigned (see
ct_note). The Waterbury-Shelton CBSA (47930) has no county in this file: Waterbury, in New Haven County, is assigned to the New Haven CBSA. Any source that uses Connecticut planning regions (codes09110–09190, used from 2022 on) must be mapped back to the 8 counties before joining. The town lists typed into chunkm00-ct-townsagree town by town with the research project’s lists and with the OPM list of municipalities by planning region (all 34 towns, and the number of towns in each of the 9 regions). The CBSA of each town’s planning region is looked up in the delineation file, not typed in. - Reading the files.
read_csv()keeps codes with a leading zero (GEOID,STATEFP,bg_geoid) as text, but it turnsPRUID,cbsa_code,metdiv_code, andcsa_codeinto numbers unless you givecol_types. It reads every whole number, such aspop_2020, as dbl. - Writing GeoPackages on external or synced drives. A GeoPackage is a small SQLite database (SQLite is a database stored in a single file). On some drives, such as an exFAT external drive or a cloud-synced folder, SQLite can refuse the last step of a write. GDAL then prints “attempt to write a readonly database”. We built the bundle on an exFAT drive, where 2 of 8 test writes of
counties_2020.gpkghit this. The rows were all there, but the file missed one of its internal triggers (rules that keep the stored row count up to date). RunningSys.setenv(OGR_SQLITE_JOURNAL = "MEMORY")before the write chunks fixed it (0 failures in 24 test writes). Writing to a folder on your computer’s main drive also avoids it. - Memory and time. The whole script runs in about 25 seconds. Its peak memory use is about 2 GB (1.7 to 2.2 GB in our runs), so close other large R sessions first. Most of the peak comes from simplifying the full-detail state outlines in
m00-write-states.
SEER county population
The Surveillance, Epidemiology, and End Results (SEER) program of the National Cancer Institute (NCI) publishes population estimates for every U.S. county and every year from 1969 to 2024. Each number estimates how many people lived in a county on July 1 of that year, by race, sex, and single year of age. The estimates come from the Census Bureau’s population estimates, which start from the last census and add births, subtract deaths, and add people who moved in minus people who moved out. SEER adjusts a few county codes and areas so that each county means the same place in every year.
In this project these counts are the denominators: they turn county totals into per-person numbers and give population weights, for example to compute the share of U.S. residents who had a smoke day. The age columns let you describe exposure for children and for adults 65 and older. They are model estimates for July 1, not census counts, and they cannot tell you where people actually were on a given day (commuting, travel, or seasonal residents).
| Provider | National Cancer Institute, SEER Program, from Census Bureau population estimates (2010–2019 years made for NCI by Woods & Poole Economics, Inc.) |
| Coverage | raw: 1969–2024, clean: 2006–2024 · raw: 50 states and DC; clean: the 3,108 counties of the contiguous U.S. and DC (2020 boundaries) |
| Raw unit | one row = the July 1 population of one county × year × race × sex × single year of age |
| Clean unit | one row = a county × year (3,108 counties × 19 years) |
| Licence and citation | Free public download; the provider states no licence terms. Cite: National Cancer Institute, SEER Program, U.S. County Population Data, 1969–2024 (single ages through 89 and 90+, Katrina/Rita adjusted), released February 2026, https://seer.cancer.gov/popdata/. SEER’s suggested citation for the 2010–2019 years names the Woods & Poole Economics intercensal estimates (revised estimates for the years between two censuses) by age, sex, and bridged race (January 2026) |
| Documentation | SEER county population data; data dictionary (copy in raw/seer/); county code modifications; single-year-of-age methods; download page |
Raw files
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
seer/us.1969_2024.singleages.through89.90plus.adjusted.txt.gz |
284.1 MB | gzip-compressed fixed-width ASCII text: 26 characters per line, no header, no separators, Windows (CR/LF) line ends; 69,655,933 lines, about 2 GB unzipped | the July 1 population of one county × year × race × sex × single year of age |
seer/popdic__dl20260927.html |
0.03 MB | HTML web page: a copy of SEER’s data dictionary, downloaded 2026-09-27 | not a data file: it gives the start column, length, and codes of each field |
Raw variables
The data file us.1969_2024.singleages.through89.90plus.adjusted.txt.gz has no column names. Each field sits at fixed character positions (a fixed-width file), as listed in the data dictionary popdic__dl20260927.html. The names in the first column are the dictionary’s names. The code values below are the dictionary’s; we checked which ones occur in this file by reading every line.
| Variable | Type | Description |
|---|---|---|
| Year | numeric, columns 1–4 | Year of the estimate, 1969–2024. Each estimate is for July 1 of that year |
| State postal abbreviation | character, columns 5–6 | Two-letter postal code, e.g., NY. KR is a dummy state SEER created for Hurricane Katrina/Rita evacuees; it appears only in 2005 (540 lines) |
| State FIPS code | numeric, columns 7–8 | 2-digit state FIPS code (Federal Information Processing Standards: the federal numeric code of a state), zero-filled, e.g., 36 = New York; 99 (9-filled) for the KR dummy state |
| County FIPS code | numeric, columns 9–11 | 3-digit county code within the state, e.g., 055; 999 for the KR dummy state. Columns 7–11 together form the 5-character county code. In 2006–2024, two codes in the contiguous U.S. differ from the 2020 Census: 46113 (Oglala Lakota County, SD, called Shannon County until 2015; Census code 46102) and 51917 (Bedford County, VA, combined with Bedford city, 51515, in all years; Census code 51019). Alaska also uses older codes that differ from the 2020 Census (02261 and 02270 in all of 2006–2024; 02201, 02232, and 02280 in 2006–2013), but Alaska is dropped |
| (columns 12–13) | blank | Two spaces in every line; not documented by the provider |
| Race | numeric, column 14 | Bridged race. Since 2000 the Census lets people report more than one race; “bridged race” assigns each person to one race so that the series stays comparable over time. This file is SEER’s “1969+” White/Black/Other file: 1 White, 2 Black, 3 Other. The dictionary does not define “Other” further. In the dictionary’s 1990+ codes, the groups other than White and Black are 3 American Indian/Alaska Native and 4 Asian or Pacific Islander; those codes belong to other SEER files and do not occur here |
| Origin | numeric, column 15 | Hispanic origin: 0 non-Hispanic, 1 Hispanic, 9 not applicable in the 1969+ White/Black/Other files. Every line of this file has 9, so it has no Hispanic-origin detail |
| Sex | numeric, column 16 | 1 male, 2 female |
| Age | numeric, columns 17–18 | Single year of age: 00–89 = age in years, 90 = 90 and older. All years in this file use these codes. (The dictionary also lists codes 00–19 for SEER’s age-group files: 00 = 0 years, 01 = 1–4, 02 = 5–9, … , 19 = 90+; they are not used here) |
| Population | numeric, columns 19–26 | Estimated number of people on July 1, zero-filled to 8 digits (e.g., 00000159 = 159). Groups with no people are left out: no line has a population of 0 |
The dictionary page popdic__dl20260927.html has no variables of its own; it is the documentation for the table above.
Cleaning steps
The code below makes these decisions:
- Stream the file instead of loading it. Unzipped, the file is about 2 GB, and the full table would need several GB of memory. The code reads 1 million lines at a time through a connection (an open file that R reads step by step) and keeps only a small summary of each piece. Pieces from before 2006 are skipped without being parsed. The run takes about 2 minutes and about 1 GB of memory.
readr::read_lines()andread_lines_chunked()are not used because, for a.gzfile, both first unzip the whole file (about 2 GB) to a temporary file on disk. - Cut columns by position.
str_sub()takes the characters at the positions given in the data dictionary. - Sum over race and origin, and group the ages. The clean file is one row per county and year, so race and origin are summed away, and single ages are grouped into 0–4, 5–17, 18–64, 65–74, 75–84, and 85+. These six groups add up to every age column of the clean file.
- Keep the contiguous U.S. Alaska and Hawaii are dropped. The
KRdummy state exists only in 2005, but the filter would drop it too. - Recode two SEER county codes.
46113becomes46102(Oglala Lakota County, SD) and51917becomes51019(Bedford County, VA). In both cases the SEER area is the same land as the 2020 county. - Complete panel. Every county gets every year. A missing county-year would stop the script, because it would be an error, not a zero. A missing sex-and-age group inside a county-year is a true zero, because SEER leaves out groups with no people.
- Check the download. The script counts the lines it read. A file that stopped downloading early is still read, with only a warning, so the line count is the check.
The first chunk names the raw file, sets the years to keep, and reads the county list written by module m00.
dir.create(clean_dir, recursive = TRUE, showWarnings = FALSE)
seer_file <- file.path(raw_dir, "seer",
"us.1969_2024.singleages.through89.90plus.adjusted.txt.gz")
# The years we keep. The file starts in 1969; this module keeps 2006-2024.
years <- 2006:2024
# The county universe: 3,108 contiguous-U.S. counties plus DC, 2020 borders.
counties <- read_csv(file.path(clean_dir, "counties_2020.csv"),
col_types = cols_only(GEOID = col_character(),
county_name = col_character(),
state_abbr = col_character()))
stopifnot(nrow(counties) == 3108, !anyDuplicated(counties$GEOID))Before writing any parsing code, look at a few raw lines. gzfile() opens the compressed file, and readLines(..., n = 5) reads only its first 5 lines.
# readLines() on a gzfile() connection unzips only what it reads, so this
# looks at the first 5 lines without touching the other 70 million.
first_lines <- readLines(gzfile(seer_file), n = 5)
first_lines
# Every line has 26 characters and no separators: a fixed-width record.
nchar(first_lines)In the first line, 1969AL01001 1910000000159, you can read the year (1969), the state (AL), the county code (01001), two blanks, race 1, origin 9, sex 1, age 00, and population 00000159. The next function cuts many lines at once with str_sub(x, start, end), which takes characters start to end of every string in x.
# Cut fixed-width lines into columns. Positions come from the SEER record
# layout in raw/seer/popdic__dl20260927.html. Columns 12-13 are blank in
# this file, so we skip them.
parse_seer_lines <- function(lines) {
tibble(
year = as.integer(str_sub(lines, 1, 4)),
state_abbr = str_sub(lines, 5, 6),
seer_code = str_sub(lines, 7, 11), # state FIPS (2) + county FIPS (3)
race = as.integer(str_sub(lines, 14, 14)),
origin = as.integer(str_sub(lines, 15, 15)),
sex = as.integer(str_sub(lines, 16, 16)),
age = as.integer(str_sub(lines, 17, 18)), # 0-89 = years, 90 = 90+
pop = as.numeric(str_sub(lines, 19, 26))
)
}
parse_seer_lines(first_lines)The next function summarises one piece of the file. It first keeps only the lines from 2006–2024, by looking at the year text, and only then parses them, because parsing is the slow step.
# What we keep from one piece of the file: population summed over race and
# origin, by year x county x sex x age group. Six age groups are enough to
# build every age column of the clean file.
summarise_seer_chunk <- function(lines, years) {
# Filter on the year text first: cutting a million lines into eight
# columns is the slow step, so only do it for the lines we keep.
lines <- lines[as.integer(str_sub(lines, 1, 4)) %in% years]
if (length(lines) == 0) return(NULL)
parse_seer_lines(lines) |>
mutate(age_group = case_when(
age < 5 ~ "0-4",
age < 18 ~ "5-17",
age < 65 ~ "18-64",
age < 75 ~ "65-74",
age < 85 ~ "75-84",
.default = "85+"
)) |>
summarise(pop = sum(pop), .by = c(year, state_abbr, seer_code, sex, age_group))
}The loop reads the file one piece at a time and stores each summary in pieces, a list made in advance with room for 100 summaries, so the loop fills it instead of growing it. next jumps to the next piece, and break leaves the loop at the end of the file.
# The unzipped file is about 2 GB (69.7 million lines), too big to load
# at once. We read 1 million lines at a time, keep a small summary of each
# piece, and throw the lines away before reading the next piece.
# (readr's read_lines() and read_lines_chunked() are not used: for a .gz
# file, both first unzip the whole file to a temporary file on disk.)
# This takes about 2 minutes and about 1 GB of memory.
con <- gzfile(seer_file, open = "rt")
pieces <- vector("list", 100) # room for 100 pieces; the file needs 70
n_pieces <- 0
n_lines <- 0
repeat {
lines <- readLines(con, n = 1e6)
if (length(lines) == 0) break # end of the file
n_pieces <- n_pieces + 1
n_lines <- n_lines + length(lines)
# The file is sorted by year, so if the LAST line of a piece is from
# before 2006, the whole piece is: skip it without parsing.
last_year <- as.integer(str_sub(lines[length(lines)], 1, 4))
if (n_pieces %% 10 == 0) message("Piece ", n_pieces, ": read through ", last_year)
if (last_year < min(years)) next
pieces[[n_pieces]] <- summarise_seer_chunk(lines, years)
}
close(con)
rm(lines)
# A download that stopped early is still read, with only a warning and
# fewer lines. This release has exactly 69,655,933 lines; if the check
# fails, download the file again.
n_lines
stopifnot(n_lines == 69655933, last_year == 2024)A county-year can start at the end of one piece and continue in the next, so the summaries are combined and summed again. Each year has about 37,700 rows: about 3,140 county codes (including Alaska and Hawaii) × 2 sexes × 6 age groups.
# One county-year can be split across two pieces, so sum the pieces again.
seer_groups <- bind_rows(pieces) |>
summarise(pop = sum(pop), .by = c(year, state_abbr, seer_code, sex, age_group))
rm(pieces)
# Grain: one row per year x SEER county code x sex x age group.
stopifnot(!anyDuplicated(select(seer_groups, year, seer_code, sex, age_group)),
all(seer_groups$year %in% years), all(seer_groups$pop >= 0))
seer_groups |> count(year)Next, drop the states outside the contiguous U.S. and give SEER’s two special codes their 2020 Census codes. The why column documents each recode; it is not used in the join.
# SEER keeps two county codes that differ from the 2020 Census codes. In
# both cases the SEER area is the same land as the 2020 county.
seer_recode <- tribble(
~seer_code, ~GEOID, ~why,
"46113", "46102", "Oglala Lakota County, SD, was Shannon County (46113) until 2015",
"51917", "51019", "Bedford County, VA, plus Bedford city (51515), which joined it in 2013"
)
seer_county <- seer_groups |>
# Alaska and Hawaii are outside the contiguous U.S. "KR" is SEER's dummy
# state for 2005 hurricane evacuees; it does not appear after 2005.
filter(!state_abbr %in% c("AK", "HI", "KR")) |>
left_join(select(seer_recode, seer_code, GEOID), by = "seer_code") |>
mutate(GEOID = coalesce(GEOID, seer_code))
# Every remaining SEER code must now be a county in our universe. Other
# years have more combined codes (see the guide), so this check stops the
# script if you change `years` and meet one of them.
stopifnot(all(seer_county$GEOID %in% counties$GEOID))sum(pop[condition]) adds up only the rows where the condition is true, so one summarise() builds all ten population columns.
# Sum over sex and age groups to get one row per county and year. SEER
# leaves out cells with zero people, so a group with no rows sums to 0,
# which is a true zero.
seer_wide <- seer_county |>
summarise(
pop_total = sum(pop),
pop_female = sum(pop[sex == 2]),
pop_male = sum(pop[sex == 1]),
pop_under5 = sum(pop[age_group == "0-4"]),
pop_under18 = sum(pop[age_group %in% c("0-4", "5-17")]),
pop_lt65 = sum(pop[age_group %in% c("0-4", "5-17", "18-64")]),
pop_65p = sum(pop[age_group %in% c("65-74", "75-84", "85+")]),
pop_65_74 = sum(pop[age_group == "65-74"]),
pop_75_84 = sum(pop[age_group == "75-84"]),
pop_85p = sum(pop[age_group == "85+"]),
.by = c(GEOID, year)
)Now attach the populations to the full county × year grid and check that nothing is missing. The last lines list the counties with an older age group of zero people.
# Start from every county x year and attach the populations. A county-year
# with no SEER rows would be an error, not a zero, so we check for NA.
seer_county_year <- expand_grid(GEOID = counties$GEOID, year = years) |>
left_join(seer_wide, by = c("GEOID", "year")) |>
arrange(GEOID, year)
stopifnot(nrow(seer_county_year) == 3108 * length(years),
!anyNA(seer_county_year),
nrow(seer_wide) == nrow(seer_county_year))
# Small counties can have no one in an older age group in some years.
seer_county_year |>
filter(pop_65_74 == 0 | pop_75_84 == 0 | pop_85p == 0) |>
left_join(counties, by = "GEOID") |>
count(GEOID, county_name, state_abbr, name = "years_with_a_zero_age_group")Before writing, check the grain (one row per county and year) and the identities that must hold between the columns: females + males = total, under 65 + 65 and older = total, and so on.
# Grain: one row per county (GEOID) x year, 3,108 x 19 = 59,052 rows.
pop_cols <- names(seer_county_year)[str_starts(names(seer_county_year), "pop_")]
stopifnot(
!anyDuplicated(select(seer_county_year, GEOID, year)),
all(nchar(seer_county_year$GEOID) == 5),
all(as.matrix(seer_county_year[pop_cols]) >= 0),
with(seer_county_year, all(pop_total == pop_female + pop_male)),
with(seer_county_year, all(pop_total == pop_lt65 + pop_65p)),
with(seer_county_year, all(pop_65p == pop_65_74 + pop_75_84 + pop_85p)),
with(seer_county_year, all(pop_under5 <= pop_under18 & pop_under18 <= pop_lt65)),
all(seer_county_year$pop_total > 0)
)
write_csv(seer_county_year, file.path(clean_dir, "seer_county_year_2006_2024.csv"),
na = "")A last look at the result: the contiguous U.S. grows from about 296 million people in 2006 to 338 million in 2024, and the share aged 65 and older rises from 12.5% to 18.0%.
# A quick look: total population and the share aged 65+ in the contiguous
# U.S. The 65+ share rises every year as the baby boom generation ages.
seer_county_year |>
summarise(pop_total = sum(pop_total), pop_65p = sum(pop_65p), .by = year) |>
mutate(share_65p = round(100 * pop_65p / pop_total, 1))Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
seer_county_year_2006_2024.csv |
59,052 | a county × year (3,108 counties × 19 years, July 1 populations) | 3.74 MB |
Clean variables
seer_county_year_2006_2024.csv: one row per county and year, sorted by GEOID and year. No cell is missing, and every population is a whole number.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (2-digit state + 3-digit county), 2020 boundaries; matches counties_2020.csv |
year |
dbl | year | Year of the estimate (2006–2024); the population is for July 1 of that year |
pop_total |
dbl | persons | All ages, both sexes |
pop_female |
dbl | persons | Females, all ages (SEER sex code 2) |
pop_male |
dbl | persons | Males, all ages (SEER sex code 1); pop_female + pop_male = pop_total |
pop_under5 |
dbl | persons | Ages 0–4 |
pop_under18 |
dbl | persons | Ages 0–17 (includes pop_under5) |
pop_lt65 |
dbl | persons | Ages 0–64 (includes pop_under18) |
pop_65p |
dbl | persons | Ages 65 and older; pop_lt65 + pop_65p = pop_total and pop_65p = pop_65_74 + pop_75_84 + pop_85p |
pop_65_74 |
dbl | persons | Ages 65–74 |
pop_75_84 |
dbl | persons | Ages 75–84 |
pop_85p |
dbl | persons | Ages 85 and older (SEER single ages 85–89 plus the 90+ group) |
Checks and cautions
- Same numbers as the research project. Our validation (an instructor-side check against the research project’s cleaned file,
seer_county_year.parquet) found the same numbers. The project file was built with a different method (a shellawkprogram). For all 59,052 county-years,pop_total,pop_lt65,pop_65p,pop_65_74,pop_75_84, andpop_85pare identical (0 differences). The project file has no sex, under-5, or under-18 columns, so the check also rebuilt 2006, 2013, and 2024 a third way (gzipandgrepto pick the years, thenread_fwf(), then sums over single ages). All 10 population columns agree for all 9,324 county-years (0 differences). - What is in the raw file. We read every line: 69,655,933 lines, 26,730,643 of them from 2006–2024. Every line has 26 characters. Race codes are
1–3only, origin is always9, sex is1or2, age runs00–90, columns 12–13 are always blank, and no line has a population of 0. - Every county, every year. All 3,108 counties have all 19 years, with no missing values. In 2006–2024, SEER uses only two county codes in the contiguous U.S. that differ from the 2020 Census (
46113,51917; see Cleaning steps). Alaska has five more (02201,02232,02261,02270,02280), but Alaska is not in the clean file. The bundle copy of the raw file is byte-identical to the project’s. - Zeros are real. Loving County, Texas (
48301), is the smallest county (46 to 82 people). It haspop_85p = 0in all 19 years,pop_75_84 = 0in 10 years, andpop_under5 = 0in 2 years. No other county has a zero in any column. Do not divide by an age group without checking for zeros. - Estimates, not census counts. Compared with the 2020 Census counts (April 1, 2020) in
counties_2020.csv, the SEER July 1, 2020 estimates total 329,393,451 people against 329,260,619 (0.04% more). The median county differs by 0.18%, and 99% of counties differ by less than 1.7%. The largest gaps are Luce County, Michigan (+20.1%), Hampshire County, Massachusetts (−8.5%), Tattnall County, Georgia (+6.5%), and Tompkins County, New York (−5.6%). Hampshire and Tompkins have large college populations. Use one source consistently in an analysis; do not mix census counts and estimates. - Different producers for different years. SEER’s 2006–2009 estimates come from the Census Bureau, the 2010–2019 estimates were made for NCI by Woods & Poole Economics, and the 2020–2024 estimates are the Census Bureau’s Vintage 2024 estimates (a vintage is the year a series of estimates was released; each new vintage revises earlier years). Numbers on the Census website can therefore differ slightly from these.
- Connecticut. For 2020–2024, the Census Bureau’s estimates use Connecticut’s 9 planning regions. Woods & Poole converted them back to the 8 old counties for SEER, using population shares. Connecticut county values for these years are therefore modeled, not published Census numbers.
- Small groups are uncertain. SEER warns that county estimates by single year of age have limited reliability. The clean file uses broad age groups, but
pop_under5and the oldest groups in small counties are still rough. - Race and Hispanic origin. The clean file sums over race. This raw file has only three race groups (White, Black, Other) and no Hispanic-origin detail.
- Katrina/Rita adjustment. The “adjusted” in the file name refers to SEER’s changes for 2005 in Alabama, Louisiana, Mississippi, and Texas, after hurricanes Katrina and Rita. It does not affect 2006–2024.
- Other years. If you change
yearsto include years before 2006, the check at the end of chunkm06-recodestops the script. According to the research project’s build, SEER uses more combined or changed codes in earlier years, for example08911–08914(Adams, Boulder, Jefferson, and Weld counties, Colorado, each with part of Broomfield, 1990–2001) and04910(Yuma and La Paz counties, Arizona, 1990–1993). Some of these cannot be split into 2020 counties. - Reading the file. With no
col_types,read_csv()readsGEOIDas chr, butyearand every population as dbl. Usecol_types = cols(GEOID = col_character(), year = col_integer(), .default = col_double())if you wantyearas an integer. - Memory and time. On our computer (a 10-core Mac) the code ran in 100 to 220 seconds (the longer times while other programs were busy), with a peak memory use of 0.9 to 1.1 GB.
NASA FIRMS active fires (MODIS)
MODIS (Moderate Resolution Imaging Spectroradiometer) is a sensor on two NASA satellites, Terra and Aqua. Between them they observe almost every place at least four times a day: Terra crosses the equator at about 10:30 and 22:30 local time, and Aqua at about 13:30 and 01:30. An algorithm flags each 1 km pixel that is much hotter than its surroundings as an active fire detection and records its location, time, a confidence score, and its fire radiative power (FRP): the heat the fire gives off as radiation at that moment, in megawatts (MW). NASA’s Fire Information for Resource Management System (FIRMS) publishes these detections; this bundle has its yearly files for Canada, the United States, and Mexico, 2003–2024.
In this project the detections are the “source” side of the smoke story: where Canadian fires burned, on which days, and how hard. FRP summed over a day is a rough measure of how much smoke a region’s fires could produce, and Canada’s 2023 season stands far above every other year. The detections cannot tell you how much smoke was made, where it went, or how much land burned. A satellite sees only what burns during its few daily passes, and clouds or thick smoke can hide a fire, so a day with no detection is not proof of no fire.
| Provider | NASA FIRMS (Fire Information for Resource Management System), part of NASA’s Earth Science Data and Information System (ESDIS). The detections are the MODIS active fire product (MCD14ML) of the MODIS fire team at the University of Maryland, in standard (science-quality) processing |
| Coverage | 2003–2024 (years of the UTC date; UTC is Coordinated Universal Time, the clock at Greenwich) · Canada, the United States (with Alaska and Hawaii), and Mexico, one file per country and year |
| Raw unit | one row = one fire detection: one MODIS pixel (about 1 km × 1 km, larger near the edge of the satellite’s view) flagged as burning during one Terra or Aqua overpass |
| Clean unit | detection file: one row = one detection; province-day file: one row = one Canadian province or territory × one local solar day; country-day file: one row = one country × one local solar day. A local solar day is the calendar day by the sun at the fire (see “Cleaning steps”) |
| Licence and citation | NASA data are open, with no restrictions on use. FIRMS asks users to acknowledge it: “We acknowledge the use of data and/or imagery from NASA’s Fire Information for Resource Management System (FIRMS) (https://earthdata.nasa.gov/firms), part of NASA’s Land, Atmosphere Near real-time Capability for Earth observations (LANCE) (https://earthdata.nasa.gov/lance) and NASA’s Earth Science Data and Information System (ESDIS).” Data DOI: 10.5067/FIRMS/MODIS/MCD14ML. Algorithm: Giglio, L., Schroeder, W., and Justice, C.O. (2016), “The collection 6 MODIS active fire detection algorithm and fire products,” Remote Sensing of Environment 178, 31–41 |
| Documentation | FIRMS attribute fields for MODIS · FIRMS FAQ · MODIS C6/C6.1 Active Fire Product User’s Guide (copy hosted by NASA’s LP DAAC) · FIRMS yearly country files · firms/AttributesReadme.txt (shipped) |
Raw files
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
firms/modis_<year>_Canada__v<date>.csv (22 files, 2003–2024) |
0.98–26.47 MB each; 119.6 MB in all | comma-separated text with a header row; 15 columns | one detection that FIRMS places in Canada (1,487,842 rows) |
firms/modis_<year>_United_States__v<date>.csv (22 files) |
7.57–13.53 MB each; 224.1 MB in all | same | one detection that FIRMS places in the United States, Alaska and Hawaii included (2,825,073 rows) |
firms/modis_<year>_Mexico__v<date>.csv (22 files) |
3.25–8.62 MB each; 123.1 MB in all | same | one detection that FIRMS places in Mexico (1,556,116 rows) |
firms/AttributesReadme.txt |
0.003 MB | plain text | provider README: links to the attribute definitions, the coordinate system (WGS 84 latitude/longitude), and the citation and disclaimer |
FIRMS posts each file as, for example, modis_2003_Canada.csv, and replaces it when it reprocesses the data. The research project added __v<date> when it downloaded the files: the date FIRMS last updated the file. It is v20231214 for 2003–2022, v20240603 for 2023, and v20250530 for 2024. Only the newest copy of each year × country is shipped (the project keeps older re-posted copies in a _superseded folder; there are none for these MODIS files). <year> is the UTC year of the overpass, and all three countries’ files for a year must be read together to get that year’s full picture. The 66 files hold 5,869,031 rows and all have the same 15 columns.
Three things to know about these files:
- The version is not the same in every year. The project’s folder for these files is called
modis_c61, after MODIS Collection 6.1, but theversioncolumn says otherwise:6.2in the 2003–2017 files,6.03in the 2018–2022 files, and61.03in the 2023–2024 files. (The project’s source notes give the6.03years as 2017–2022, but no 2017 row carries6.03.) The MODIS user guide names the underlying product filesMCD14ML.YYYYMM.CCC.VV, with CCC the collection and VV the product version, so6.03reads as Collection 6.0, version 03, and61.03as Collection 6.1, version 03. FIRMS does not document the string6.2; the research project treats it, like6.03, as Collection 6.0 standard processing. A version without anNRTsuffix is standard processing, not the quicker near-real-time (NRT) product. The changes between versions are small, but a series that crosses 2017/2018 or 2022/2023 crosses a processing change (see “Checks and cautions”). - There is no 2025 file. FIRMS had not posted 2025 yearly MODIS files when the project downloaded them. The project’s main MODIS source is a separate FIRMS Archive Download request (request 813444, 2003–2025, made with the researcher’s NASA Earthdata login; 559 MB). It is the only copy of 2025 standard-processing data the project has, and it is not shipped. So this module stops at 31 December 2024.
- VIIRS is not shipped. The project also has fire detections from the VIIRS sensor (375 m pixels, many more detections) on the NOAA-20 satellite (2018–2025) and on Suomi NPP (2012–2024), about 2.2 GB in all. The project uses them only for checks, and they are not in this bundle.
Raw variables
All 66 CSV files have the 15 columns below, in this order. Descriptions follow the FIRMS attribute page and the MODIS user guide; value ranges and counts are over all 5,869,031 rows.
| Variable | Type | Description |
|---|---|---|
latitude |
decimal number | Latitude of the center of the 1 km fire pixel, in degrees north (WGS 84, the coordinate system GPS uses). FIRMS notes it is not necessarily the location of the fire itself, since one or more fires can burn anywhere inside the pixel. 14.56 to 82.34 in these files |
longitude |
decimal number | Longitude of the pixel center, in degrees east; negative values are west of Greenwich. −176.61 to −52.89 |
brightness |
decimal number | Brightness temperature of the fire pixel in MODIS channel 21/22 (a 4 µm infrared band), in kelvin (K). 300.0 to 510.5 K |
scan |
decimal number | Size of the pixel along the scan (east–west), in km. The algorithm works on 1 km pixels, but MODIS pixels get bigger toward the edge of the scan; scan and track give the actual size. 1.0 to 4.8 km |
track |
decimal number | Size of the pixel along the satellite’s track (north–south), in km. 1.0 to 2.0 km |
acq_date |
date text, YYYY-MM-DD |
Date of the overpass in UTC, which runs 2.5 to 10 hours ahead of local clock time in Canada, the U.S., and Mexico |
acq_time |
4-digit text, HHMM |
Time of the overpass in UTC, hours and minutes, e.g., 0606 = 06:06 UTC. The leading zero is written in these files |
satellite |
text | Terra or Aqua (5,869,031 rows: 2,838,800 Terra, 3,030,231 Aqua). The FIRMS attribute page lists the codes T and A; these files spell the names out |
instrument |
text | Always MODIS. Not listed in the provider’s MODIS attribute table |
confidence |
integer, 0–100 | Detection confidence in percent, built from intermediate quantities of the detection algorithm, to help users judge the quality of each detection. The user guide sorts it into three classes: low (0–29), nominal (30–79), and high (80–100). 446,238 rows are below 30 |
version |
decimal-looking text | Collection and processing source of the detection: 6.2 (2003–2017 files), 6.03 (2018–2022), 61.03 (2023–2024). See the note above |
bright_t31 |
decimal number | Brightness temperature of the fire pixel in MODIS channel 31 (an 11 µm infrared band), in K. 263.5 to 400.1 K |
frp |
decimal number | Fire radiative power: the radiant heat output of the whole pixel, in MW. −59.2 to 16,146.4 MW; 14,875 rows are 0 and one row is negative (see “Checks and cautions”) |
daynight |
text | D = daytime fire, N = nighttime fire. The user guide calls it the day/night flag of the detection algorithm, which uses different tests by day and by night (4,562,070 D, 1,306,961 N) |
type |
integer, 0–3 | Inferred hot-spot type: 0 = presumed vegetation fire (5,621,753 rows), 1 = active volcano (13,500), 2 = other static land source (224,999), 3 = offshore (8,779). Per the user guide, type 2 covers hot spots detected at the same place on 16 or more days in a calendar year (gas flares, industrial plants) and hot spots in urban areas, and type 3 covers every hot spot over water, presumably offshore gas flares |
AttributesReadme.txt has no variables; it points to the documentation above.
Cleaning steps
The script makes these decisions:
- Newest file only. It reads the newest
__v<date>copy of each year × country, so a re-posted file can never be counted twice. That gives 66 files. - Country from the file, region from the map. FIRMS has already put each detection in a country, and the script keeps that. It then tests Canadian points only against the province outlines and U.S. points only against the state outlines from module m00 (point-in-polygon: is the point inside the polygon?). A point that falls in no polygon, on a coast or right at a border, gets the nearest polygon, and
region_dist_kmrecords how far away it was. Mexican points getMX, because the bundle has no Mexican state outlines. - Borders follow parallels, so the outlines are densified first. Long borders such as the 49th parallel are stored as a few corners several degrees apart. On the sphere, sf joins two corners by a great circle (the shortest path on the globe), which bows up to about 5 km toward the pole and would put points near the border in the wrong polygon (without this step, 239 Canadian detections land in the wrong province, most of them Ontario points labeled Manitoba). Adding a corner every 0.05° along the straight longitude/latitude line keeps each border on its parallel.
- One duplicate removed. One Aqua detection in Manitoba on 29 June 2006 appears twice: same pixel, same time, same satellite, with two slightly different FRP values (1,037.4 and 1,029.6 MW). The script keeps the first row, leaving 5,869,030 detections. It counts the raw lines and checks that nothing else is dropped, without hard-coding the totals, so a refreshed FIRMS file does not break it.
- Local solar date. Daily panels in this project count fires by the day at the fire, not by the UTC date. Local solar time is UTC plus longitude/15 hours (4 minutes per degree), the clock the sun keeps at that longitude. For example, a detection at 03:00 UTC on 2 July at longitude −120° happened at 19:00 local solar time on 1 July. This rule needs no time-zone map and treats every place the same way; it is the rule in the research project’s
local_solar_date()function. For 924,679 detections (15.8%), almost all from Terra’s late-evening pass (906,304), the local date is the day before the UTC date. - Keep every detection; filter in the totals. The detection file keeps all types and all confidence levels, with the
typeandconfidencecolumns, so you can choose your own filter. The province-day and country-day files count only presumed vegetation fires (type == 0) with nominal or high confidence (confidence >= 30), as the research project does. Types 1–3 are volcanoes, gas flares, industrial sites, urban hot spots, and offshore platforms, not wildfires. Below 30 is the provider’s low-confidence class; the user guide suggests that users who want fewer false alarms keep only nominal- and high-confidence pixels. This keeps 5,196,099 of the 5,869,030 detections (88.5%). - Complete grids. The two daily files cover every local day from 2003-01-01 to 2024-12-31 (8,036 days), with zeros on days without a detection.
- What is dropped or changed.
instrumentis dropped (alwaysMODIS).acq_timestays text so the leading zero survives. Detection values keep their raw precision; daily FRP sums are rounded to 0.1 MW.
List the 66 raw files and read the year, country, and provider update date from each file name.
# FIRMS publishes one CSV per year and country. The file name holds the year,
# the country, and (after "__v") the date the provider last updated the file.
# If the provider re-posts a file, two versions of the same year x country can
# sit side by side; keeping only the newest one stops us from counting the
# same fire twice.
firms_files <- tibble(
path = list.files(file.path(raw_dir, "firms"),
pattern = "^modis_[0-9]{4}_.+__v[0-9]{8}\\.csv$",
full.names = TRUE)
) |>
mutate(
file = basename(path),
file_year = as.integer(str_sub(file, 7, 10)),
country = str_match(file, "^modis_[0-9]{4}_(.+)__v")[, 2] |>
str_replace_all("_", " "),
vintage = ymd(str_match(file, "__v([0-9]{8})\\.csv$")[, 2])
) |>
slice_max(vintage, n = 1, by = c(file_year, country)) |>
arrange(file_year, country)
# Expect 22 years (2003-2024) x 3 countries, one file each.
stopifnot(
nrow(firms_files) == 66,
!anyDuplicated(select(firms_files, file_year, country)),
setequal(firms_files$country, c("Canada", "Mexico", "United States")),
setequal(firms_files$file_year, 2003:2024)
)
count(firms_files, vintage)Set the column types once, and look at one file before reading all of them.
# Spell out every column type. acq_time is text so that "0606" (06:06 UTC)
# keeps its leading zero, and version is text because it is a label
# ("6.03", "61.03"), not a number to do math with.
modis_cols <- cols(
latitude = col_double(),
longitude = col_double(),
brightness = col_double(),
scan = col_double(),
track = col_double(),
acq_date = col_date(),
acq_time = col_character(),
satellite = col_character(),
instrument = col_character(),
confidence = col_integer(),
version = col_character(),
bright_t31 = col_double(),
frp = col_double(),
daynight = col_character(),
type = col_integer()
)
# Look at one file before reading 66 of them.
read_csv(firms_files$path[1], col_types = modis_cols) |>
glimpse()Load the province and state outlines that module m00 wrote, and add corners along their long straight edges so that borders on a parallel stay on that parallel.
# Outlines written by module m00, in longitude/latitude (WGS 84), the same
# coordinate system as the FIRMS points. We need only the two-letter code.
provinces <- read_sf(file.path(clean_dir, "canada_provinces_2021.gpkg")) |>
select(region = prov_abbr)
states <- read_sf(file.path(clean_dir, "us_states_2020.gpkg")) |>
select(region = state_abbr)
# 13 provinces and territories; 50 states plus DC (Alaska and Hawaii included).
stopifnot(nrow(provinces) == 13, nrow(states) == 51)
# Why add points to the borders? Many borders follow a parallel (a line of
# constant latitude), such as the 49th parallel between Canada and the U.S.
# The outlines store such a border as a few corners up to 6 degrees of
# longitude apart. For longitude/latitude data sf works on the sphere (the
# s2 library) and joins two corners by the shortest path on the globe, a
# great circle. Between corners far apart, that path bows toward the pole by
# up to about 5 km, so points near the border land in the wrong polygon.
# st_segmentize() adds a corner every 0.05 degrees along each edge. We drop
# the coordinate system for this step (st_set_crs(NA)) so the new corners
# are spaced along the straight line in longitude/latitude, then put it
# back. With corners this close, the bow is under 1 meter.
densify <- function(polygons) {
polygons |>
st_set_crs(NA) |>
st_segmentize(dfMaxLength = 0.05) |>
st_set_crs(4326)
}
provinces <- densify(provinces)
states <- densify(states)The next four chunks only define functions. The first gives each detection its province or state code: the sf function st_intersects() finds the polygon that contains each point, and st_nearest_feature() finds the closest polygon.
# Give each detection the code of the polygon it falls in. The country comes
# from the FIRMS file name, so Canadian points are tested only against the
# province layer and U.S. points only against the state layer. (Testing both
# layers would give some border points two labels and others none; see m00.)
# A point in no polygon (on a coast, in a border lake, or near a simplified
# border) gets the nearest polygon, and region_dist_km records how far away
# that polygon is. A point inside a polygon gets region_dist_km = 0.
label_regions <- function(det, polygons) {
pts <- st_as_sf(det, coords = c("longitude", "latitude"), crs = 4326,
remove = FALSE)
# st_intersects() lists, for each point, the rows of the polygons that
# contain it. rows[1] takes the first one and gives NA when the list is empty.
# Because sf works on the sphere here, st_distance() returns meters along
# the Earth's surface.
poly_row <- map_int(st_intersects(pts, polygons), \(rows) rows[1])
dist_km <- rep(0, nrow(det))
outside <- is.na(poly_row)
if (any(outside)) {
nearest <- st_nearest_feature(pts[outside, ], polygons)
poly_row[outside] <- nearest
dist_km[outside] <- as.numeric(
st_distance(pts[outside, ], polygons[nearest, ], by_element = TRUE)
) / 1000
}
det |>
mutate(region = polygons$region[poly_row],
region_dist_km = round(dist_km, 3))
}The second function cleans one UTC year: it reads the three country files, removes the duplicate, labels regions, and adds the local solar date.
# Clean one year at a time: three files (about 270,000 rows) fit easily in
# memory, and the spatial step stays fast.
clean_one_year <- function(yr) {
files_yr <- filter(firms_files, file_year == yr)
# id = "path" adds a column with the file each row came from, so the
# country in the file name can be attached to every row.
det <- read_csv(files_yr$path, col_types = modis_cols, id = "path") |>
left_join(select(files_yr, path, country), by = "path") |>
# Same pixel, same time, same satellite = the same detection. One such
# pair exists (Canada, 29 June 2006); keep the first row.
distinct(latitude, longitude, acq_date, acq_time, satellite,
.keep_all = TRUE)
canada <- det |> filter(country == "Canada") |> label_regions(provinces)
usa <- det |> filter(country == "United States") |> label_regions(states)
# No sub-national layer for Mexico: every Mexican detection gets "MX".
mexico <- det |>
filter(country == "Mexico") |>
mutate(region = "MX", region_dist_km = NA_real_)
bind_rows(canada, usa, mexico) |>
mutate(
# Local solar time = UTC + longitude / 15 hours, i.e. 4 minutes per
# degree. West of Greenwich the longitude is negative, so the local
# date can be the day before the UTC date.
utc_minutes = as.integer(str_sub(acq_time, 1, 2)) * 60 +
as.integer(str_sub(acq_time, 3, 4)),
local_date = acq_date + floor((utc_minutes + 4 * longitude) / 1440),
year = as.integer(year(local_date))
) |>
select(country, region, region_dist_km, latitude, longitude,
acq_date, acq_time, local_date, year, satellite, confidence,
frp, brightness, bright_t31, scan, track, daynight, type,
version) |>
arrange(country, acq_date, acq_time)
}The third function checks one cleaned year. stopifnot() stops the script with an error if any condition is false.
# Checks for one cleaned year; stopifnot() stops the script if any is FALSE.
check_year <- function(det) {
stopifnot(
# One row per detection.
!anyDuplicated(select(det, latitude, longitude, acq_date, acq_time,
satellite)),
# Only Mexico has no distance (no polygon test); nothing else is missing.
!anyNA(select(det, -region_dist_km)),
all(is.na(det$region_dist_km) == (det$country == "Mexico")),
all(str_detect(det$acq_time, "^[0-9]{4}$")),
all(det$satellite %in% c("Terra", "Aqua")),
all(det$daynight %in% c("D", "N")),
all(det$type %in% 0:3),
all(between(det$confidence, 0, 100)),
# Every point lies west of Greenwich, so the local date is the UTC date
# or the day before.
all(as.numeric(det$local_date - det$acq_date) %in% c(-1, 0))
)
invisible(det)
}The fourth function sums one year of detections into totals by region and local day.
# The daily files count presumed vegetation fires (type 0) seen with nominal
# or high confidence (confidence >= 30), as in the research project. This
# function sums one year of detections by country, region, and local day.
# n_rows counts every detection of any type or confidence; we use it only to
# check that no row was lost.
summarize_fires <- function(det) {
det |>
mutate(veg = type == 0 & confidence >= 30) |>
summarize(
n_rows = n(),
n_detections = sum(veg),
frp_total_mw = sum(frp[veg]),
frp_day_mw = sum(frp[veg & daynight == "D"]),
frp_night_mw = sum(frp[veg & daynight == "N"]),
n_terra = sum(veg & satellite == "Terra"),
n_aqua = sum(veg & satellite == "Aqua"),
.by = c(country, region, local_date)
)
}Try the functions on 2020 before running all 22 years. In 2020, 18 Canadian and 10 U.S. detections fell outside every polygon (all within 0.77 km of one), and the largest region-days are California and Oregon on 7–9 September 2020.
# Try the functions on one year before running all 22.
try_2020 <- clean_one_year(2020) |> check_year()
try_2020 |>
summarize(detections = n(), outside_polygon = sum(region_dist_km > 0),
max_dist_km = max(region_dist_km), .by = country)
summarize_fires(try_2020) |>
arrange(desc(frp_total_mw)) |>
head(5)Run all 22 years. Each year is written to the detection file as soon as it is cleaned, so memory stays low. The chunk first counts the raw data lines, then checks that only exact duplicates were removed.
# All 22 years hold about 5.9 million detections, close to 1 GB in R. To keep
# memory low, each year is cleaned, checked, appended to one compressed file
# on disk, and summed by region and day; only the small daily sums stay in
# memory. Takes about 5 minutes.
# File grain: one row per detection (key: latitude, longitude, acq_date,
# acq_time, satellite), all types, all confidence levels, all three
# countries. Each raw file holds one UTC year, so a key cannot repeat across
# years. As plain text the file would be about 600 MB, so it is gzipped;
# read_csv() opens .csv.gz files directly.
det_file <- file.path(clean_dir, "firms_modis_detections_2003_2024.csv.gz")
# Count the data lines in the raw files (each file has one header line), so
# we can check below that cleaning drops only exact duplicates.
raw_rows <- map_int(firms_files$path, \(f) length(read_lines(f)) - 1L) |>
sum()
region_day_pieces <- map(2003:2024, \(yr) {
det_yr <- clean_one_year(yr) |> check_year()
stopifnot(all(year(det_yr$acq_date) == yr))
# 2003 creates the file with a header line; append = TRUE adds each later
# year below it, without a header.
write_csv(det_yr, det_file, na = "", append = yr > 2003)
summarize_fires(det_yr)
}) |>
list_rbind()
# A night detection at 03:00 UTC on 1 January has a local date of
# 31 December, so two UTC years can both add to the same local day. Summing
# again by region and day joins those two pieces.
region_day_all <- region_day_pieces |>
summarize(across(n_rows:n_aqua, sum), .by = c(country, region, local_date))
# Every raw row is kept except exact duplicates. With the files shipped in
# 2026 that is 5,869,031 raw rows and 1 duplicate removed (5,869,030 kept).
# A refreshed FIRMS file changes these numbers, so we do not hard-code them.
n_removed <- raw_rows - sum(region_day_all$n_rows)
stopifnot(between(n_removed, 0, 100),
!anyDuplicated(select(region_day_all, country, region, local_date)))
c(raw_rows = raw_rows, duplicates_removed = n_removed)
region_day_all |>
summarize(all_rows = sum(n_rows), veg_fires = sum(n_detections),
.by = country)Keep the local days of 2003–2024. Only two vegetation-fire detections fall on 31 December 2002 (one Canadian, one U.S.).
# Keep local days 2003-01-01 to 2024-12-31. The only days outside are
# 31 Dec 2002 (detections from the first UTC hours of 1 Jan 2003).
first_day <- ymd("2003-01-01")
last_day <- ymd("2024-12-31")
region_day_all |>
filter(!between(local_date, first_day, last_day)) |>
summarize(veg_fires = sum(n_detections), .by = c(country, local_date))
region_day <- filter(region_day_all, between(local_date, first_day, last_day))
all_days <- seq(first_day, last_day, by = "day")Build the complete province × day grid, check it, and write it.
# Complete grid: every province x every local day. A day with no detection
# has no row in region_day, so after the join it gets zeros: no fire seen.
province_day <- expand_grid(prov_abbr = provinces$region,
local_date = all_days) |>
left_join(region_day |>
filter(country == "Canada") |>
select(prov_abbr = region, local_date, n_detections:n_aqua),
by = c("prov_abbr", "local_date")) |>
mutate(
across(c(n_detections, n_terra, n_aqua), \(x) replace_na(x, 0L)),
across(starts_with("frp_"), \(x) round(replace_na(x, 0), 1))
) |>
arrange(prov_abbr, local_date)
# Grain: one row per province x local day; 13 x 8,036 days.
stopifnot(
nrow(province_day) == 13 * 8036,
!anyDuplicated(select(province_day, prov_abbr, local_date)),
!anyNA(province_day),
sum(province_day$n_detections) ==
sum(region_day$n_detections[region_day$country == "Canada"]),
all(province_day$n_terra + province_day$n_aqua ==
province_day$n_detections),
all(abs(province_day$frp_day_mw + province_day$frp_night_mw -
province_day$frp_total_mw) < 0.15),
all(province_day$frp_total_mw >= 0)
)
write_csv(province_day,
file.path(clean_dir, "firms_canada_province_day_2003_2024.csv"),
na = "")Sum by country and local day, and split the U.S. into the contiguous states and Alaska.
# Country totals by local day (only days with at least one detection).
country_day <- region_day |>
summarize(n_detections = sum(n_detections),
frp_total_mw = sum(frp_total_mw),
.by = c(country, local_date))
# U.S. split: contiguous U.S. (48 states + DC) and Alaska. The rest of the
# U.S. total is Hawaii. Smoke from Alaska rarely reaches the lower 48, so
# users often want the contiguous part on its own.
us_split <- region_day |>
filter(country == "United States") |>
mutate(contig = !region %in% c("AK", "HI")) |>
summarize(
n_contig = sum(n_detections[contig]),
frp_contig_mw = sum(frp_total_mw[contig]),
n_alaska = sum(n_detections[region == "AK"]),
frp_alaska_mw = sum(frp_total_mw[region == "AK"]),
.by = local_date
) |>
mutate(country = "United States")Build the complete country × day grid, check it, and write it.
# Complete grid: every country x every local day. A missing count means no
# fire seen (0). The split columns do not apply to Canada and Mexico (NA).
country_day_full <- expand_grid(
country = c("Canada", "United States", "Mexico"),
local_date = all_days
) |>
left_join(country_day, by = c("country", "local_date")) |>
left_join(us_split, by = c("country", "local_date")) |>
mutate(
n_detections = replace_na(n_detections, 0L),
frp_total_mw = round(replace_na(frp_total_mw, 0), 1),
across(c(n_contig, n_alaska),
\(x) if_else(country == "United States", replace_na(x, 0L), NA)),
across(c(frp_contig_mw, frp_alaska_mw),
\(x) if_else(country == "United States",
round(replace_na(x, 0), 1), NA))
)
# Grain: one row per country x local day; 3 x 8,036 days.
us_rows <- filter(country_day_full, country == "United States")
stopifnot(
nrow(country_day_full) == 3 * 8036,
!anyDuplicated(select(country_day_full, country, local_date)),
!anyNA(select(country_day_full, country, local_date, n_detections,
frp_total_mw)),
!anyNA(us_rows),
sum(country_day_full$n_detections) == sum(region_day$n_detections),
all(us_rows$n_contig + us_rows$n_alaska <= us_rows$n_detections)
)
write_csv(country_day_full,
file.path(clean_dir, "firms_country_day_2003_2024.csv"),
na = "")A first look: Canadian vegetation-fire detections and FRP by year. 2023 has 280,489 detections, more than twice 2024 and more than three times any earlier year.
# A first look: Canadian fire activity by year. 2023 stands out.
province_day |>
mutate(year = year(local_date)) |>
summarize(n_detections = sum(n_detections),
frp_total_gw = round(sum(frp_total_mw) / 1000, 1),
.by = year) |>
print(n = 22)How to read the detection file in your own work: name only the columns you need, and read the file in pieces so that memory stays low. The checks confirm that the file has every Canadian detection and that the vegetation fires among them add up to the province-day file.
# How to read the detection file later. All 19 columns of 5.9 million rows
# need more than 2 GB of memory while reading. Two habits keep it small:
# cols_only() reads just the columns you name, with their types, and
# read_csv_chunked() reads 250,000 rows at a time and keeps only what the
# callback function returns (here, the Canadian rows). If you add acq_time
# or version, read them with col_character(): acq_time must keep its 4
# characters, and version is a label ("6.2", "61.03"), not a number.
fires_ca <- read_csv_chunked(
det_file, chunk_size = 250000,
col_types = cols_only(country = col_character(), region = col_character(),
local_date = col_date(), confidence = col_integer(),
frp = col_double(), type = col_integer()),
callback = DataFrameCallback$new(\(x, pos) filter(x, country == "Canada"))
)
# Every Canadian detection is there, and the vegetation fires among them
# add up to the province-day file.
veg_ca <- filter(fires_ca, type == 0, confidence >= 30,
between(local_date, first_day, last_day))
stopifnot(
nrow(fires_ca) ==
sum(region_day_all$n_rows[region_day_all$country == "Canada"]),
nrow(veg_ca) == sum(province_day$n_detections),
abs(sum(veg_ca$frp) - sum(province_day$frp_total_mw)) < 1
)
count(veg_ca, region, sort = TRUE)Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
firms_modis_detections_2003_2024.csv.gz |
5,869,030 | one MODIS detection: all types, all confidence levels, all three countries; sorted by country, acq_date, acq_time |
112.1 MB (614.3 MB uncompressed) |
firms_canada_province_day_2003_2024.csv |
104,468 | one Canadian province or territory × one local solar day (13 × 8,036 days); sorted by prov_abbr, local_date |
3.0 MB |
firms_country_day_2003_2024.csv |
24,108 | one country × one local solar day (3 × 8,036 days); sorted by country (Canada, United States, Mexico), then local_date |
0.9 MB |
Clean variables
firms_modis_detections_2003_2024.csv.gz: one row per detection; the key is latitude, longitude, acq_date, acq_time, satellite. Types are those you get with col_types = cols(acq_time = col_character(), version = col_character(), year = col_integer(), confidence = col_integer(), type = col_integer()). Left to guess, read_csv() reads year, confidence, type, and version as dbl, which turns the version labels into numbers. Reading all 19 columns at once takes about 0.8 GB in R and more than 2 GB while reading; chunk m01-read-back shows how to read only what you need.
| Variable | Type | Units | Description |
|---|---|---|---|
country |
chr | — | Country of the FIRMS file the detection came from: Canada, United States, or Mexico |
region |
chr | — | Two-letter code of the province or territory (Canada; 13 codes, e.g., BC, QC) or state (U.S.; 50 states and DC, with AK and HI) that contains the point, or the nearest one when no polygon contains it; MX for every Mexican detection. Codes join to prov_abbr in canada_provinces_2021.csv and state_abbr in us_states_2020.csv |
region_dist_km |
dbl | km | Distance from the point to the polygon in region: 0 inside a polygon; above 0 for points given the nearest polygon (639 Canadian and 204 U.S. detections; at most 1.91 km). Empty (NA) for Mexico: not applicable, because no polygon test is made |
latitude |
dbl | degrees north | Center of the fire pixel (raw) |
longitude |
dbl | degrees east | Center of the fire pixel (raw); negative = west |
acq_date |
date | — | UTC date of the overpass (raw); 2003-01-01 to 2024-12-31 |
acq_time |
chr | HHMM | UTC time of the overpass, always 4 characters, e.g., 0606 |
local_date |
date | — | Local solar date at the fire: acq_date and acq_time plus longitude/15 hours; 2002-12-31 to 2024-12-31. Equal to acq_date or one day earlier |
year |
int | — | Year of local_date; 2002 for the 12 detections on 31 December 2002 |
satellite |
chr | — | Terra or Aqua |
confidence |
int | % | Detection confidence, 0–100 (raw): low 0–29, nominal 30–79, high 80–100 |
frp |
dbl | MW | Fire radiative power of the pixel (raw); −59.2 to 16,146.4 |
brightness |
dbl | K | Channel 21/22 brightness temperature (raw) |
bright_t31 |
dbl | K | Channel 31 brightness temperature (raw) |
scan |
dbl | km | Pixel size along the scan, east–west (raw) |
track |
dbl | km | Pixel size along the track, north–south (raw) |
daynight |
chr | — | D = daytime fire, N = nighttime fire (raw) |
type |
int | — | Hot-spot type (raw): 0 presumed vegetation fire, 1 active volcano, 2 other static land source, 3 offshore |
version |
chr | — | Collection and processing label (raw): 6.2, 6.03, or 61.03 |
The two daily files count only presumed vegetation fires (type == 0) with confidence >= 30, by local solar day. read_csv() guesses every type correctly; counts come in as dbl.
firms_canada_province_day_2003_2024.csv:
| Variable | Type | Units | Description |
|---|---|---|---|
prov_abbr |
chr | — | Two-letter province or territory code (13 codes; joins to canada_provinces_2021.csv) |
local_date |
date | — | Local solar day, 2003-01-01 to 2024-12-31 |
n_detections |
dbl | count | Number of vegetation-fire detections. 0 means no detection, not necessarily no fire |
frp_total_mw |
dbl | MW | Sum of frp over those detections, rounded to 0.1. A sum over separate snapshots, not an energy total (see below) |
frp_day_mw |
dbl | MW | Part of frp_total_mw from daytime detections (daynight == "D") |
frp_night_mw |
dbl | MW | Part of frp_total_mw from nighttime detections (daynight == "N") |
n_terra |
dbl | count | Detections by Terra |
n_aqua |
dbl | count | Detections by Aqua; n_terra + n_aqua == n_detections |
firms_country_day_2003_2024.csv:
| Variable | Type | Units | Description |
|---|---|---|---|
country |
chr | — | Canada, United States, or Mexico (the FIRMS file’s country) |
local_date |
date | — | Local solar day, 2003-01-01 to 2024-12-31 |
n_detections |
dbl | count | Number of vegetation-fire detections; 0 = no detection |
frp_total_mw |
dbl | MW | Sum of frp over those detections, rounded to 0.1 |
n_contig |
dbl | count | U.S. rows: detections in the 48 contiguous states and DC. Empty (NA) for Canada and Mexico: not applicable |
frp_contig_mw |
dbl | MW | U.S. rows: FRP in the contiguous states and DC; NA for Canada and Mexico |
n_alaska |
dbl | count | U.S. rows: detections in Alaska; NA for Canada and Mexico |
frp_alaska_mw |
dbl | MW | U.S. rows: FRP in Alaska; NA for Canada and Mexico. Hawaii is the rest: n_detections − n_contig − n_alaska |
Checks and cautions
- Agreement with the research project (Canada).
R/check_m01.Rcompares the Canadian totals with the project’s main MODIS file, which comes from a different FIRMS delivery (archive request 813444) and places detections in countries and provinces with the project’s own outlines. By local year, the counts here differ from the project’s by −0.01% to +0.66%, and FRP by −0.01% to +1.23%. Over 2003–2024 this module has 1,279,815 Canadian vegetation-fire detections against 1,277,685 in the project (+0.17%), and 149.30 million MW of summed FRP against 149.18 million (+0.08%). The yearly table is inlogs/check_m01_canada_by_year.csv. - Why the two differ. From 2018 on, the country files and the archive carry the same version, and the detections match one for one: all 624,552 Canadian vegetation-fire detections of 2018–2024 here are in the project file, and 624,331 of them (99.96%) get the same province. The project puts 160 of the rest in the United States (153 of them are points this module snapped to the nearest province) and 61 in a different province. Before 2018 the country files (version
6.2) and the archive (6.03) are different processing runs, and the country files have 0.1–0.7% more Canadian detections per year. By province over 2003–2024 the gap is under 0.3% for the seven provinces and territories with the most fires; Ontario is +1.65% (45,687 against 44,947), and each of the small Atlantic provinces is within 1%. - Agreement with the project’s cleaning of these same files. The project also cleaned the same 66 country files as a cross-check (
firms_archive_vs_country_check.csv). Against it, the country × local-year counts agree within 0.15% for Canada, 0.08% for the U.S. without Hawaii (the project drops Hawaii), and 0.04% for Mexico; FRP within 1.23%, 0.51%, and 0.05%. The small gaps come from the country rule: the project moves each detection to the country whose polygon contains it, and this module keeps the FIRMS country. - Detections across the border. FIRMS’s country borders are not exactly the Census and Statistics Canada outlines. 639 detections from the Canadian files and 204 from the U.S. files lie outside every polygon of their own country (half of them within about 0.35 km, all within 1.91 km) and got the nearest province or state. 549 of the 639 Canadian ones lie inside a U.S. state polygon; 494 of those are vegetation fires, with 84,717 MW over 22 years. On the 49th-parallel stretch of the border, 556 of the 568 snapped Canadian points lie south of 49.0°N, so they really are on the U.S. side; the other 90 snapped Canadian points lie in no polygon of either country (on coasts, in lakes, or in slivers between the two outlines). The largest group is British Columbia in 2022: 43 vegetation-fire detections with 41,662 MW, all just south of 49.0°N. That is exactly why Canada’s 2022 FRP is 1.23% above the project’s, which places those points in the United States. Only 1 of the 204 snapped U.S. points lies inside a province. In the other direction, 12 detections that the project places in Yukon are in the FIRMS U.S. file, labeled
AK. If you want a strict border, drop rows withregion_dist_km > 0: for Canada that removes 549 points that lie in the U.S. and 90 points that are in neither country’s outline, a negligible share of any province’s total. - What a zero or a missing value means. In the two daily files, 0 means “no vegetation-fire detection that day”, not “no fire”: clouds, thick smoke, and the hours between passes hide fires. 25,452 of the 104,468 province-days have at least one detection. In the detection file, the only missing values are
region_dist_kmfor Mexico (not applicable). In the country-day file, the four split columns are missing for Canada and Mexico (not applicable). - Days when a satellite was out. On 52 UTC days, one satellite has no detection at all in the three countries’ files: Terra on 35 days and Aqua on 17, never both. The long outages are Terra 17–23 December 2003, Terra 19–27 February 2016, Aqua 1–16 April 2022, and Terra 11–23 October 2022. On those days counts and FRP are roughly half of what both satellites would have seen. These are the same 50 days on which the research project finds a satellite absent in its archive file (plus 14 March 2003 and 1 January 2023, when the project’s file has a few detections). The project fills the missing satellite’s share; this module does not.
- Counts of snapshots, not of fires. Each detection is one pixel at one moment. One large fire produces many detections on every pass, and pixels near the edge of the satellite’s view are larger (
scanup to 4.8 km), so counts are not numbers of fires. Summing FRP over a day’s detections adds up four or more snapshots of power (MW), not the energy released over the day; usefrp_total_mwas an index of fire activity. Night detections carry 24.5% of Canada’s FRP over 2003–2024 (from 10.7% in 2007 to 35.9% in 2019), and Aqua makes 47.4% of Canada’s detections. - Processing changes. The
versionchanges between the 2017 and 2018 files (6.2to6.03) and again for 2023 (Collection 6.0 to 6.1). For 2003–2017 the country files have 1.9–3.9% more U.S. and 0.9–2.3% more Mexican detections per year than the archive’s6.03processing of the same years, while for 2018–2024 the two agree within 0.05%. So part of any drop in the U.S. and Mexican counts from 2017 to 2018 can come from processing, not from fewer fires. For Canada the same gap is at most 0.7%. - The edges of the window. The detection file has 12 detections on local 31 December 2002 (
year= 2002), from the first UTC hours of 1 January 2003; the daily files leave them out. The last day, 31 December 2024, lacks the detections from the first UTC hours of 1 January 2025, which are in no shipped file. The project’s archive has 4 such detections, 2 in the U.S. and 2 in Mexico, none in Canada. - Odd values. One detection has negative FRP: −59.2 MW (Idaho, 10 August 2013, confidence 0, so it is not in the daily totals). 14,875 detections have FRP 0; 13,840 of them are vegetation fires with confidence of at least 30, so they add to
n_detectionsbut not to FRP. - The biggest days. Canada’s 2023 season has 280,489 vegetation-fire detections, more than twice 2024 (127,920) and more than three times any earlier year; 2020 is the quietest year (11,035). The largest province-days by FRP are Alberta and British Columbia on 22 September 2023 (4,642 and 4,383 detections, 1.64 and 1.53 million MW). About 5,700 of these detections come from two Terra night passes over British Columbia and Alberta (04:15 and 05:53 UTC on 23 September), so check the raw rows before you build a story on that day. The research project’s archive has the same detections.
- Running time. The script takes about 4 minutes and needs up to about 1.6 GB of memory, most of it in the
m01-clean-all-yearsandm01-read-backchunks.
Canadian burned area (CWFIS NBAC)
The National Burned Area Composite (NBAC) maps the areas burned by wildland fires in Canada in each year from 1972 to 2025. Each fire is a polygon (its outline on the map) with an area in hectares (1 ha = 10,000 m² = 0.01 km²) and a few facts about it: start and end dates, cause, and whether it was a prescribed burn. The Canadian Forest Service of Natural Resources Canada builds it for the Canadian Wildland Fire Information System (CWFIS). For each fire, a set of rules picks the best available outline: either one drawn by the provincial, territorial, or Parks Canada fire agency, or one that the Canadian Forest Service mapped from satellite images.
For this project, NBAC tells you how much of Canada burned, in which province or territory, and in which year. You can use it to rank fire seasons (2023 is by far the largest on record) and to line up big Canadian fire years with smoke in U.S. counties. It cannot tell you on which days a fire grew or how much smoke it made: each record has at most a start date and an end date, and many early records have no date at all. For daily fire activity, use the satellite fire detections (FIRMS).
| Provider | Canadian Forest Service, Natural Resources Canada, Canadian Wildland Fire Information System (CWFIS). Release 2026-05-13 (layer NBAC_merged_1972_to_2025_20260513), downloaded on 2026-09-27 |
| Coverage | fire years 1972–2025 · all of Canada (10 provinces and 3 territories) |
| Raw unit | one row (polygon) = one fire inside one jurisdiction: a fire that crosses a provincial, territorial, or national park boundary is cut into one record per jurisdiction |
| Clean unit | one row = an NBAC record (fires file); a province or territory × fire year (province-year file) |
| Licence and citation | Open Government Licence – Canada (licence.txt). Cite: Canadian Forest Service. Canadian Wildland Fire Information System (CWFIS), Natural Resources Canada, Canadian Forest Service, Northern Forestry Centre, Edmonton, Alberta. https://cwfis.cfs.nrcan.gc.ca, and name the release (NBAC 20260513) |
| Documentation | NBAC_1972to2025_20260513_shp_metadata.pdf in the raw folder; CWFIS NBAC download folder; Skakun et al. (2021), area adjustment; MAFiMS burned area mapping |
Raw files
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
nbac/NBAC_1972to2025_20260513_shp.zip |
1,198.8 MB | zip archive holding one Esri shapefile (9 files, 1.9 GB unzipped; listed below) | a burned-area polygon: one fire inside one jurisdiction (52,610 records) |
nbac/NBAC_1972to2025_20260513_shp_metadata.pdf |
3.3 MB | PDF, 14 pages (a printed web page; the text is an image and cannot be searched) | the provider’s metadata: field definitions, code lists, and version history (no data) |
nbac/licence.txt |
0.001 MB | plain text, English and French | the licence and the citation the provider asks for (no data) |
A shapefile is not one file but several files that share one name and differ in their extension. The zip holds these nine:
| File inside the zip | Size (unzipped) | Format | What it holds |
|---|---|---|---|
NBAC_merged_1972_to_2025_20260513.shp |
1,789.0 MB | Esri shapefile geometry | the fire outlines (polygons), one shape per record |
NBAC_merged_1972_to_2025_20260513.shx |
0.4 MB | Esri shape index | where each record’s shape starts in the .shp file |
NBAC_merged_1972_to_2025_20260513.dbf |
133.3 MB | dBASE table | the attribute table: one row per polygon, 19 columns (described below) |
NBAC_merged_1972_to_2025_20260513.prj |
< 0.01 MB | text | the map projection: Canada Lambert Conformal Conic on the NAD83 datum, in meters |
NBAC_merged_1972_to_2025_20260513.cpg |
< 0.01 MB | text | the text encoding of the .dbf: UTF-8 |
NBAC_merged_1972_to_2025_20260513.sbn, .sbx |
0.5 MB, 0.02 MB | Esri spatial index | speeds up map searches; not needed to read the data |
NBAC_merged_1972_to_2025_20260513.shp.xml |
0.04 MB | ArcGIS metadata, XML | the layer’s metadata in machine-readable form |
NBAC_merged_1972_to_2025_20260513.pdf |
3.3 MB | the same file as the metadata PDF in the raw folder (identical bytes) |
Raw variables
The metadata PDF and licence.txt hold no data. This table covers the 19 columns of the attribute table (.dbf) inside the zip. The descriptions follow the metadata PDF; counts are from this release. The metadata also lists two fields that are not columns of the .dbf: FID, an internal feature number that ArcGIS generates, and Shape, the polygon itself (stored in the .shp file). foreign::read.dbf() returns dBASE F fields as dbl, C fields as chr, and D fields as Date.
| Variable | Type | Description |
|---|---|---|
YEAR |
number (dBASE F, double) |
The fire year, 1972–2025 |
NFIREID |
number (dBASE F, double) |
Fire number. Unique for each fire within one year (not across years). It is the common ID that links the pieces of a fire that crosses a provincial, territorial, or national park boundary |
BASRC |
text (dBASE C) |
Burned area source: the product the outline comes from. MAFiMS = Multi-Acquisition Fire Mapping System, a burned area product of the Canadian Forest Service, doi:10.1071/WF19201 (13,909 records); C2C = Composite to Change, a burned area product of the Canadian Forest Service, doi:10.1016/j.rse.2017.03.035 (6,511); TFMS = Tundra Fire Mapping System, a burned area product of the Canadian Forest Service, doi:10.3390/rs16020230 (205); Agency = an outline from a provincial, territorial, or Parks Canada fire management agency (31,985) |
FIREMAPS |
text (dBASE C) |
Data source or platform used to identify the burn. Undefined = not defined; Ground survey = ground or field survey; Aerial survey = from a helicopter or fixed-wing aircraft; Aerial photography = digital or film aerial photographs; Satellite hotspots = fire detections from satellites (MODIS, AVHRR, VIIRS); Satellite undefined = satellite, sensor not defined; MODIS = MODIS satellite images; Landsat = Landsat 5, 7, 8, or 9 images (30 m pixels); Landsat MSS = Landsat Multi-Spectral Scanner images (60 m pixels); Sentinel-2 = Sentinel-2 satellite images; Other sources = a defined source outside these categories (e.g., FireMapper); Drone (UAV) = unmanned aerial vehicle |
FIREMAPM |
text (dBASE C) |
Method used to draw the outline. Undefined = not defined; Manually delineated = sketched or drawn by a person on a map or screen; Direct from GPS = traced with a GPS unit; Processed imagery = drawn by automated methods from satellite or aerial images; Buffered points = a circle drawn around the fire’s location (no real outline) |
FIRECAUS |
text (dBASE C) |
Cause of ignition recorded by the agency. Natural = most often lightning (26,443 records); Human = for example industrial or non-industrial activity, prescribed burns, and arson (13,208); Undetermined = not known (12,959) |
HS_SDATE |
date (dBASE D) |
Date of the first satellite hotspot inside the fire’s outline. A hotspot is a spot on the ground that a satellite sensor detects as burning. Empty if no hotspot was detected. AVHRR hotspots are used for 1989–2000 and MODIS hotspots from 2001; there are none before 1989. Filled for 16,123 records |
HS_EDATE |
date (dBASE D) |
Date of the last satellite hotspot inside the fire’s outline. Same rules as HS_SDATE |
AG_SDATE |
date (dBASE D) |
Fire start date reported by the agency. It may also be the date the agency recorded or detected the fire. For a fire that crosses a border, NBAC uses the earliest of the agencies’ dates. Empty if the agency gave no date (12,463 records) |
AG_EDATE |
date (dBASE D) |
Fire end date reported by the agency. For a fire that crosses a border, NBAC uses the latest date. Empty if the agency gave no date (33,638 records) |
CAPDATE |
date (dBASE D) |
Date the source data were collected: the GPS track, the air photo, or the satellite image. Empty if not provided (27,892 records). It is not a fire date: 4,169 records have an image from the year after the fire |
POLY_HA |
number (dBASE F, double) |
Area of the polygon in hectares, calculated in the Canada Albers Equal Area Conic projection |
ADJ_HA |
number (dBASE F, double) |
Adjusted area burned, in hectares: the official burned area. It equals POLY_HA except where ADJ_FLAG is true; there a model reduces the area of an agency outline drawn by conventional methods, which tends to include unburned patches. The models are described in Skakun et al. (2021, Environmental Research Letters 16: 064014) |
ADJ_FLAG |
text (dBASE C) |
true when an area adjustment model was applied (2,579 records, all from agencies); empty otherwise |
ADMIN_NAME |
text (dBASE C) |
Where the outline is mapped: a province or territory (AB Alberta, BC British Columbia, MB Manitoba, NB New Brunswick, NL Newfoundland and Labrador, NS Nova Scotia, NT Northwest Territories, NU Nunavut, ON Ontario, PE Prince Edward Island, QC Quebec, SK Saskatchewan, YT Yukon), PC = Parks Canada (2,315 records), or DND = Department of National Defence (102 records) |
ADMIN_DIV |
text (dBASE C) |
For PC and DND records only: a 2-letter code for the national park, national historic site, or military base (Canadian Forces Base, CFB) where the outline is mapped. Empty for province and territory records. 33 codes appear in the data (26 Parks Canada, 7 Defence); the metadata lists them all. Two traps: GB means Georgian Bay Islands National Park for PC but CFB Goose Bay for DND, and NA (Nahanni National Park Reserve) is a real code, not a missing value |
PRESCRIBED |
text (dBASE C) |
true when the agency reports a prescribed fire (a fire set on purpose, for example to reduce fuel or restore habitat); empty otherwise. 411 records |
VERSION |
text (dBASE C) |
Version of the annual dataset: 20260513 on every record |
GID |
text (dBASE C) |
Global identifier: the fire year and NFIREID joined by _, e.g., 2023_346. Unique to one fire across all years; the pieces of one fire share it |
Cleaning steps
The script R/m10_nbac.R makes these decisions:
- Read the attribute table, not the map. The polygons take 1,789 MB once unzipped, but fire counts and areas need only the 133 MB
.dbftable. The script unzips that one file to R’s temporary folder and reads it withforeign::read.dbf(). The only polygons it reads are the 770 records of Wood Buffalo National Park (step 3). - Keep every record and keep it once. 450 of the 52,152 fires cross a provincial, territorial, or national park boundary, so they have two or three records that share one
fire_id(908 records in all). These are pieces of one fire, not duplicates: each lies in a different jurisdiction, and their areas add up. The clean file keeps one row per piece, so each piece keeps its own province, andn_recordstells you how many pieces the fire has. No fire has two records in the same jurisdiction, so nothing is dropped. - Give every record a province or territory. For 50,193 records,
ADMIN_NAMEis already a province or territory. 2,417 records belong to Parks Canada or National Defence; a lookup table gives the province of each park or base. Wood Buffalo National Park lies in both Alberta and the Northwest Territories, so for its 770 records the script takes a point inside each fire’s outline and finds the province or territory that contains it.prov_sourcerecords which of the three rules was used. - Use the official area.
area_hais the adjusted area (ADJ_HA), the area that NBAC reports. The polygon area (POLY_HA) is kept asmapped_area_ha. Both are rounded to 0.01 ha. - One start date, with its source.
start_dateis the agency start date when it falls in the fire year; otherwise it is the date of the first satellite hotspot; otherwise it is empty. Agencies know when a fire was reported, but hotspots exist only from 1989 on. All four raw dates are kept as well. - A complete province × year panel. The panel has all 13 provinces and territories × 54 years. A province-year with no fire is a row of zeros, not a missing row. Prescribed burns are left out of
n_firesandburned_area_haand reported in their own columns. The area of fires that started in May–September gets its own column, and so does the area of fires with no start date, so you can see how much is unknown.
The first chunk names the zip file and the layer inside it, and reads the 13 provinces and territories written by module m00.
dir.create(clean_dir, recursive = TRUE, showWarnings = FALSE)
nbac_zip <- file.path(raw_dir, "nbac", "NBAC_1972to2025_20260513_shp.zip")
# Every file inside the zip has this name, with a different extension.
nbac_layer <- "NBAC_merged_1972_to_2025_20260513"
years <- 1972:2025
# The 13 provinces and territories, written by module m00.
provinces <- read_csv(file.path(clean_dir, "canada_provinces_2021.csv"),
col_types = cols_only(prov_abbr = col_character(),
prov_name = col_character()))
stopifnot(nrow(provinces) == 13, !anyDuplicated(provinces$prov_abbr))List the files inside the zip without unzipping it. The outlines (.shp) take 1,789 MB; the attribute table (.dbf) takes 133 MB.
# A shapefile is several files that share one name. The .shp file holds the
# polygons (1,789 MB unzipped); the .dbf file holds the attribute table, one
# row per polygon (133 MB). For fire counts and areas we need only the .dbf.
unzip(nbac_zip, list = TRUE) |>
mutate(MB = round(Length / 1024^2, 1)) |>
select(Name, MB)Unzip only the .dbf file and read it as a table. The check confirms the 52,610 records and 19 columns that the metadata promises; glimpse() shows the columns and their types.
# Unzip only the .dbf into R's temporary folder, then read it as a table.
# as.is = TRUE keeps text as text instead of turning it into factors.
dbf_file <- unzip(nbac_zip, files = paste0(nbac_layer, ".dbf"), exdir = tempdir())
nbac_raw <- read.dbf(dbf_file, as.is = TRUE) |>
as_tibble()
# The provider's metadata reports 52,610 features (polygons) and 19 attributes.
stopifnot(nrow(nbac_raw) == 52610, ncol(nbac_raw) == 19)
glimpse(nbac_raw)Most fires have one record, but some have two or three, one for each jurisdiction they burned in: 51,702 fires have one record, 442 have two, and 8 have three. The checks confirm that GID is always the year plus NFIREID, and that no fire appears twice in the same jurisdiction.
# GID = fire year + "_" + NFIREID identifies one fire. When a fire crosses a
# provincial, territorial, or national park boundary, NBAC cuts it into one
# record per jurisdiction, and all pieces share the GID. So a GID can have
# several rows. No GID has two rows for the same jurisdiction.
nbac_raw |>
count(GID, name = "records") |>
count(records, name = "fires")
stopifnot(!anyDuplicated(select(nbac_raw, GID, ADMIN_NAME, ADMIN_DIV)),
all(nbac_raw$GID == paste0(nbac_raw$YEAR, "_", nbac_raw$NFIREID)))ADMIN_NAME is not always a province. This table lists the 33 parks and military bases that appear in the data and their number of records. Wood Buffalo (WB, 770 records), Banff (BA, 290), and Jasper (JA, 239) have the most.
# ADMIN_NAME is the jurisdiction where a record is mapped: a province or
# territory code, or PC (Parks Canada) or DND (Department of National
# Defence). For PC and DND rows, ADMIN_DIV names the park or military base.
# Careful: Nahanni National Park Reserve has the code "NA". read.dbf() keeps
# it as the text "NA", which is not the same as a missing value (<NA>).
nbac_raw |>
filter(ADMIN_NAME %in% c("PC", "DND")) |>
count(ADMIN_NAME, ADMIN_DIV)A small lookup table, typed in from the metadata PDF, gives the province or territory of each park and base. The check stops the script if a park or base in the data is missing from the lookup.
# Province or territory of each park and base that appears in the data. The
# names are shortened from the metadata PDF, which also lists parks that never
# appear in the data. The metadata uses the code "GB" twice,
# for Georgian Bay Islands National Park (PC) and for CFB Goose Bay (DND),
# so we join on both columns. Wood Buffalo National Park (WB) lies in both
# Alberta and the Northwest Territories, so its province is left empty here
# and found from the polygons in the next chunk.
park_lookup <- tribble(
~ADMIN_NAME, ~ADMIN_DIV, ~park_or_base, ~park_prov,
"DND", "DD", "CFB Dundurn", "SK",
"DND", "GB", "CFB Goose Bay", "NL",
"DND", "GT", "CFB Gagetown", "NB",
"DND", "PW", "CFB Petawawa", "ON",
"DND", "SF", "CFB Suffield", "AB",
"DND", "VA", "CFB Valcartier", "QC",
"DND", "WW", "CFB Wainwright", "AB",
"PC", "BA", "Banff National Park", "AB",
"PC", "CB", "Cape Breton Highlands National Park", "NS",
"PC", "EI", "Elk Island National Park", "AB",
"PC", "GH", "Gwaii Haanas National Park Reserve", "BC",
"PC", "GL", "Glacier National Park", "BC",
"PC", "GR", "Grasslands National Park", "SK",
"PC", "IV", "Ivvavik National Park", "YT",
"PC", "JA", "Jasper National Park", "AB",
"PC", "KE", "Kejimkujik National Park", "NS",
"PC", "KG", "Kouchibouguac National Park", "NB",
"PC", "KL", "Kluane National Park Reserve", "YT",
"PC", "KO", "Kootenay National Park", "BC",
"PC", "LM", "La Mauricie National Park", "QC",
"PC", "MI", "Mingan Archipelago National Park Reserve", "QC",
"PC", "NA", "Nahanni National Park Reserve", "NT",
"PC", "PA", "Prince Albert National Park", "SK",
"PC", "PP", "Point Pelee National Park", "ON",
"PC", "PU", "Pukaskwa National Park", "ON",
"PC", "RE", "Mount Revelstoke National Park", "BC",
"PC", "RM", "Riding Mountain National Park", "MB",
"PC", "SE", "Saoyú-ʔehdacho National Historic Site", "NT",
"PC", "TN", "Terra Nova National Park", "NL",
"PC", "WB", "Wood Buffalo National Park", NA,
"PC", "WL", "Waterton Lakes National Park", "AB",
"PC", "WP", "Wapusk National Park", "MB",
"PC", "YO", "Yoho National Park", "BC"
)
# Every PC and DND row must find its park or base in the lookup.
pc_dnd <- nbac_raw |>
filter(ADMIN_NAME %in% c("PC", "DND")) |>
distinct(ADMIN_NAME, ADMIN_DIV)
stopifnot(nrow(anti_join(pc_dnd, park_lookup, by = c("ADMIN_NAME", "ADMIN_DIV"))) == 0,
all(park_lookup$park_prov %in% c(provinces$prov_abbr, NA)))Wood Buffalo National Park needs a map. The script reads only its 770 polygons from inside the zip, takes one point inside each fire, and asks which province or territory contains it. 618 records fall in Alberta and 152 in the Northwest Territories.
# Read the polygons of the Wood Buffalo records only (770 of 52,610), straight
# from the zip: "/vsizip/" lets sf look inside a zip file, and the SQL query
# keeps only the rows we ask for. This takes about 20 seconds.
wb_query <- paste("SELECT GID FROM", nbac_layer, "WHERE ADMIN_DIV = 'WB'")
wb_polygons <- read_sf(file.path("/vsizip", nbac_zip, paste0(nbac_layer, ".shp")),
query = wb_query)
# Take one point inside each fire (st_point_on_surface) and find the
# province or territory that contains it. A few polygons are not valid
# shapes, so repair them first. The province outlines (m00) are moved into
# the NBAC map projection so that both layers use the same coordinates.
prov_shapes <- read_sf(file.path(clean_dir, "canada_provinces_2021.gpkg")) |>
select(prov_abbr) |>
st_transform(st_crs(wb_polygons))
wb_points <- st_sf(
GID = wb_polygons$GID,
geometry = st_point_on_surface(st_make_valid(st_geometry(wb_polygons)))
)
wb_prov <- st_join(wb_points, prov_shapes) |>
st_drop_geometry() |>
as_tibble() |>
rename(wb_prov = prov_abbr)
stopifnot(nrow(wb_prov) == 770, !anyDuplicated(wb_prov$GID), !anyNA(wb_prov$wb_prov))
count(wb_prov, wb_prov)Now build the clean fire table: one row per record, with its province or territory, the best start date, the official area, and the other useful fields under clearer names. The last line counts how each record got its province.
# One row per NBAC record (a fire, or the piece of a fire in one
# jurisdiction). The best start date is the agency start date when it falls
# in the fire year; otherwise it is the first satellite hotspot date. (36
# records from 2024-2025 have an agency start date in an earlier year: some
# are fires that kept burning over the winter, some look like typing errors.)
nbac_fires <- nbac_raw |>
left_join(park_lookup, by = c("ADMIN_NAME", "ADMIN_DIV")) |>
left_join(wb_prov, by = "GID") |>
mutate(
agency_start_ok = !is.na(AG_SDATE) & year(AG_SDATE) == YEAR,
prov_abbr = case_when(
ADMIN_NAME %in% provinces$prov_abbr ~ ADMIN_NAME,
ADMIN_DIV %in% "WB" ~ wb_prov,
.default = park_prov
),
prov_source = case_when(
ADMIN_NAME %in% provinces$prov_abbr ~ "agency code",
ADMIN_DIV %in% "WB" ~ "polygon location",
.default = "park or base"
),
start_date = if_else(agency_start_ok, AG_SDATE, HS_SDATE),
start_date_source = case_when(
agency_start_ok ~ "agency",
!is.na(HS_SDATE) ~ "hotspot",
.default = NA
)
) |>
arrange(YEAR, NFIREID, ADMIN_NAME, ADMIN_DIV) |>
add_count(GID, name = "n_records") |>
transmute(
fire_id = GID,
year = as.integer(YEAR),
n_records,
agency = ADMIN_NAME,
park_or_base,
prov_abbr,
prov_source,
start_date,
start_date_source,
agency_start_date = AG_SDATE,
agency_end_date = AG_EDATE,
hotspot_start_date = HS_SDATE,
hotspot_end_date = HS_EDATE,
area_ha = round(ADJ_HA, 2),
mapped_area_ha = round(POLY_HA, 2),
area_adjusted = ADJ_FLAG %in% "true",
cause = FIRECAUS,
prescribed = PRESCRIBED %in% "true",
source = BASRC,
map_source = FIREMAPS,
map_method = FIREMAPM
)
nbac_fires |> count(prov_source)Check the grain (one row per record, no duplicated key) and the values, then write the file.
# Grain: one row per NBAC record, 52,610 rows. The key is fire_id + agency +
# park_or_base: a fire has one row per jurisdiction it burned in.
stopifnot(
nrow(nbac_fires) == 52610,
!anyDuplicated(select(nbac_fires, fire_id, agency, park_or_base)),
all(nbac_fires$year %in% years),
all(nbac_fires$prov_abbr %in% provinces$prov_abbr),
all(nbac_fires$area_ha >= 0), all(nbac_fires$area_ha <= nbac_fires$mapped_area_ha),
all(year(nbac_fires$start_date) == nbac_fires$year, na.rm = TRUE),
all(nbac_fires$cause %in% c("Natural", "Human", "Undetermined"))
)
write_csv(nbac_fires, file.path(clean_dir, "nbac_fires_1972_2025.csv"), na = "")Sum the records to province or territory × year. Wildfires and prescribed burns are summed separately, and expand_grid() makes sure that every province has every year.
# Province or territory x year. Prescribed burns are set apart. The May-
# September area uses the best start date; area with no start date is shown
# in its own column, so the gap is visible.
burns <- nbac_fires |>
mutate(may_sep = month(start_date) %in% 5:9)
wildfire_py <- burns |>
filter(!prescribed) |>
summarise(
n_fires = n_distinct(fire_id),
burned_area_ha = sum(area_ha),
burned_area_may_sep_ha = sum(area_ha[may_sep]),
burned_area_no_start_ha = sum(area_ha[is.na(start_date)]),
.by = c(prov_abbr, year)
)
prescribed_py <- burns |>
filter(prescribed) |>
summarise(n_prescribed = n_distinct(fire_id), prescribed_area_ha = sum(area_ha),
.by = c(prov_abbr, year))
# Start from every province x year, so that a year with no mapped burn is
# a row of zeros instead of a missing row.
nbac_prov_year <- expand_grid(provinces, year = years) |>
left_join(wildfire_py, by = c("prov_abbr", "year")) |>
left_join(prescribed_py, by = c("prov_abbr", "year")) |>
mutate(
across(c(n_fires, n_prescribed), ~ as.integer(replace_na(.x, 0))),
across(ends_with("_ha"), ~ round(replace_na(.x, 0), 1))
) |>
arrange(prov_abbr, year)Check the panel: 702 rows, no duplicated province-year, no missing cell, and the same total area as the fire file. Then write it.
# Grain: one row per province or territory x year, 13 x 54 = 702 rows.
stopifnot(
nrow(nbac_prov_year) == 13 * length(years),
!anyDuplicated(select(nbac_prov_year, prov_abbr, year)),
!anyNA(nbac_prov_year),
with(nbac_prov_year, all(burned_area_may_sep_ha + burned_area_no_start_ha <=
burned_area_ha + 0.1)),
abs(sum(nbac_prov_year$burned_area_ha) -
sum(nbac_fires$area_ha[!nbac_fires$prescribed])) < 1
)
write_csv(nbac_prov_year, file.path(clean_dir, "nbac_province_year_1972_2025.csv"),
na = "")A first look at the result. 2023 burned 14.8 million hectares (Mha), about twice the next-largest year (2025, 7.3 Mha). In 2023, Quebec (4.26 Mha), the Northwest Territories (3.56 Mha), Alberta (2.78 Mha), and British Columbia (2.22 Mha) burned the most.
# A quick look: Canada's burned area by year, in million hectares (Mha).
# Provincial pieces of a fire do not overlap, so areas add up across
# provinces. Fire counts do not: a fire in two provinces counts in both.
nbac_national <- nbac_prov_year |>
summarise(burned_area_mha = sum(burned_area_ha) / 1e6, .by = year)
nbac_national |>
slice_max(burned_area_mha, n = 5)
# 2023 by province or territory, largest first.
nbac_prov_year |>
filter(year == 2023) |>
select(prov_abbr, n_fires, burned_area_ha, burned_area_may_sep_ha) |>
arrange(desc(burned_area_ha))Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
nbac_fires_1972_2025.csv |
52,610 (52,152 distinct fires) | an NBAC record: one fire, or the piece of a fire inside one province, territory, national park, or military base | 7.0 MB |
nbac_province_year_1972_2025.csv |
702 (13 provinces and territories × 54 years) | a province or territory × fire year | 0.03 MB |
Clean variables
nbac_fires_1972_2025.csv: one row per NBAC record, sorted by year, fire number, agency, and park or base code. The key is fire_id + agency + park_or_base. Four raw columns are left out: NFIREID (it is part of fire_id), ADMIN_DIV (replaced by the park or base name), CAPDATE (the date of the source image, not a fire date), and VERSION (the same on every row).
| Variable | Type | Units | Description |
|---|---|---|---|
fire_id |
chr | — | Fire identifier, the raw GID: fire year and fire number joined by _, e.g., 2023_346. Unique to one fire across all years. It repeats when a fire has several records |
year |
dbl | — | Fire year (raw YEAR), 1972–2025 |
n_records |
dbl | records | Number of records of this fire in the file, 1–3. More than 1 means the fire crossed a provincial, territorial, or national park boundary |
agency |
chr | — | Raw ADMIN_NAME: the jurisdiction where the record is mapped. A province or territory code, PC (Parks Canada), or DND (Department of National Defence). It is not always a province: use prov_abbr for that |
park_or_base |
chr | — | For PC and DND records, the name of the national park, national historic site, or military base, from the raw ADMIN_DIV code; names are shortened from the metadata. Empty for province and territory records |
prov_abbr |
chr | — | 2-letter code of the province or territory where the record lies. Never empty. Matches prov_abbr in canada_provinces_2021.csv. Records from before 1999 in today’s Nunavut are coded NT (see “Checks and cautions”) |
prov_source |
chr | — | How prov_abbr was found: agency code (agency is a province or territory, 50,193 records), park or base (the province of the park or base, 1,647), or polygon location (Wood Buffalo National Park: the province or territory that contains a point inside the fire’s outline, 770) |
start_date |
date | — | Best start date: agency_start_date if it falls in the fire year, otherwise hotspot_start_date. Empty when neither exists (10,094 records, 3,884 of them from the 1970s) |
start_date_source |
chr | — | Where start_date comes from: agency (40,111 records) or hotspot (2,405). Empty when start_date is empty |
agency_start_date |
date | — | Fire start date reported by the agency (raw AG_SDATE); the earliest date for a fire that crosses a border. Empty if not reported. 36 records from 2024–2025 have a date from an earlier year |
agency_end_date |
date | — | Fire end date reported by the agency (raw AG_EDATE); the latest date for a fire that crosses a border. Empty if not reported. It can fall in the next year |
hotspot_start_date |
date | — | Date of the first satellite hotspot inside the fire’s outline (raw HS_SDATE). Empty if there was none; always empty before 1989 |
hotspot_end_date |
date | — | Date of the last satellite hotspot inside the fire’s outline (raw HS_EDATE). Empty if there was none |
area_ha |
dbl | ha | Official burned area of the record (raw ADJ_HA), 2 decimals. Use this column for burned area |
mapped_area_ha |
dbl | ha | Area of the polygon before any adjustment (raw POLY_HA), 2 decimals. Equal to area_ha unless area_adjusted is TRUE |
area_adjusted |
lgl | — | TRUE if area_ha was reduced by the area adjustment model (raw ADJ_FLAG = true, 2,579 records) |
cause |
chr | — | Cause of ignition (raw FIRECAUS): Natural, Human, or Undetermined |
prescribed |
lgl | — | TRUE for a prescribed burn reported by the agency (raw PRESCRIBED = true, 411 records) |
source |
chr | — | Product the outline comes from (raw BASRC): Agency, MAFiMS, C2C, or TFMS |
map_source |
chr | — | Data source or platform used to identify the burn (raw FIREMAPS), e.g., Landsat or Aerial survey |
map_method |
chr | — | Method used to draw the outline (raw FIREMAPM), e.g., Processed imagery or Buffered points |
nbac_province_year_1972_2025.csv: one row per province or territory × fire year, sorted by prov_abbr and year. Every province and territory has all 54 years, and no cell is missing: a province-year with no mapped fire has zeros.
| Variable | Type | Units | Description |
|---|---|---|---|
prov_abbr |
chr | — | 2-letter province or territory code, as in canada_provinces_2021.csv |
prov_name |
chr | — | Province or territory name in English |
year |
dbl | — | Fire year, 1972–2025 |
n_fires |
dbl | fires | Number of distinct wildfires (prescribed burns excluded) with at least one record in this province and year. A fire that burned in two provinces counts in both, so do not add these counts across provinces |
burned_area_ha |
dbl | ha | Total area_ha of the wildfire records (prescribed burns excluded), 1 decimal. Adds up across provinces |
burned_area_may_sep_ha |
dbl | ha | The part of burned_area_ha from records whose start_date falls in May–September. It is the area of fires that started in those months, not the area that burned during them |
burned_area_no_start_ha |
dbl | ha | The part of burned_area_ha from records with no start_date. These fires may have started in any month |
n_prescribed |
dbl | fires | Number of distinct prescribed burns with a record in this province and year |
prescribed_area_ha |
dbl | ha | Total area_ha of the prescribed burns, 1 decimal |
Checks and cautions
- Validation script.
R/check_m10.R(not part of the student path) runs every check below. It reads the raw zip, the clean files, and the research project’s copies of the raw files. - Raw copies. The three raw files are byte-identical (same MD5 checksum) to the research project’s copies, which were downloaded from the CWFIS download folder on 2026-09-27. The PDF inside the zip is the same file as the metadata PDF.
- Two readers agree.
foreign::read.dbf()and GDAL (sf::read_sf()) read the same 52,610 × 19 table, and all 19 columns agree. One difference: GDAL turns an empty dBASE date into the impossible date-001-11-30instead ofNA(36,487 emptyHS_SDATEvalues, for example).read.dbf()returnsNA, which is one reason the script uses it. - Independent rebuild. A second build in base R, using provinces taken from the polygons themselves, reproduces every column of the fire file exactly (areas within the 0.005 ha rounding). In the province-year file,
n_firesandn_prescribedmatch exactly, and the areas differ by at most 1.2 ha, because the script sums areas that were already rounded to 0.01 ha. - Provinces checked on the map. For every Parks Canada and Defence record (2,417) and every record of 1995, 2014, and 2023 (4,056 province and territory records), a point inside the polygon was placed on the provincial map. It falls in the assigned province or territory for 2,416 of the 2,417 park and base records (the point of
2005_1352falls outside every provincial outline) and for 4,042 of the 4,056 other records. Of the remaining 14, 13 points fall outside every provincial outline, and one 1995 record codedNTlies in today’s Nunavut (see the next point). All the records of each park or base lie in one province. - Nunavut before 1999. Nunavut was split from the Northwest Territories in 1999. NBAC codes a record by the territory of its time: there is no
NUrecord before 1999. 94 of the 2,350NTrecords from 1972–1998 lie in today’s Nunavut: 281,617 ha of 14.6 Mha (1.9%). SoNUis 0 in 1972–1998 by construction. For a consistent series over time, addNTandNUtogether. - Wood Buffalo National Park. Its 770 records go to Alberta (618) or the Northwest Territories (152). For all 770, the province that contains the point is also the province that holds the largest part of the fire. Only 11 records have less than 99% of their area in one province (the lowest share is 54%). Splitting those fires by area instead would move about 18,000 ha from the Northwest Territories to Alberta over 1972–2025, about 0.5% of the park’s 3.4 Mha.
- Published totals: National Forestry Database. The National Forestry Database (NFD) holds the area burned that each agency reports. Its regional totals for 1990–2024 are published in Table A.3 of Environment and Climate Change Canada (2025), Canadian Environmental Sustainability Indicators: Forest management and disturbances (source: Canadian Council of Forest Ministers, National Forestry Database, 2025), in km² and without Nunavut. Without Nunavut, NBAC’s national area is 77% to 111% of the NFD figure in each year (median 91%); over 1990–2024 it sums to 87% of the NFD total, and the two series correlate at 0.998 across years. By region, NBAC is 103% of NFD in Quebec, 121% in the Atlantic provinces, 86% in the Prairies, 85% in the territories, 82% in British Columbia, and 80% in Ontario. One reason NBAC is usually lower: outlines that agencies draw by hand include unburned patches inside the fire, while satellite outlines and the area adjustment leave them out (Skakun et al. 2021, Environmental Research Letters 16: 064014).
- 2023. NBAC gives 14.80 Mha for 2023 (14.79 Mha without Nunavut), the largest year since 1972. The NFD gives 17.61 Mha (176,065 km², without Nunavut). Jain et al. (2024, Nature Communications 15, doi:10.1038/s41467-024-51154-7) report about 15 Mha for the record 2023 season. The 7 prescribed burns of 2023 match the 7 excluded in the Lecture 6 map extract.
- No other project file to compare. The research project has no cleaned file built from NBAC, so this module’s outputs are checked against the raw data and the published totals above, not against a project build.
- Fire counts do not add up across provinces. A fire that burned in two provinces counts once in each. Summed over all province-years,
n_firesis 335 higher than the number of distinct wildfires. For national counts, count distinctfire_idin the fire file. Areas do add up: the pieces of a fire do not overlap. - Early years have fewer dates and fewer small fires. Before 1989 there are no satellite hotspots, so
start_datecomes only from agencies, and many records have none: more than 10% of the area burned has no start date in 1972–1980, 1984, 1985, and 1988. Useburned_area_may_sep_hawith care before 1989. From 1989 on, 78% to 100% of each year’s area comes from fires that started in May–September (more than 90% in 35 of 37 years; the exceptions are 2006 and 2024). Fire counts are also hard to compare across decades: the metadata’s version history says that agency fires under 10 ha were added only from 2004 on, so later years include more small fires. - Dates that cross years. 36 records from 2024–2025 have an agency start date in an earlier year. Some are fires that smoldered through the winter (holdover fires); others look like typing errors. For these,
start_datefalls back to the hotspot date, or stays empty.agency_end_datefalls in the next year for 75 records. - Possible double mapping. Two pairs of records share the same year, province, area, and dates (
1985_326and1985_327;2014_2163and2014_2242). Their outlines do not overlap (the two fires of each pair are 25.5 km and 1,108 km apart), so all four are kept as separate fires. - Tiny records. 213 records are smaller than 0.005 ha and show
area_ha= 0.00 after rounding; most are buffered points or GPS traces. They still count inn_fires. - Area adjustment is small in this release. 2,579 agency records have an adjusted area; on those records
area_hais 71% to 100% ofmapped_area_ha. Over all records, the adjustment removes 0.59% of the mapped area. - Burned area is not smoke. NBAC tells you where and how much burned in a year, not when the fire grew or how much smoke it made. For timing, use the daily fire detections (FIRMS) and the smoke data. The annual totals are revised when new releases come out; cite the release (20260513).
- Need the outlines? The clean files have no geometry. To map fires, read the polygons from the zip with
sf::read_sf()and an SQL query, as in chunkm10-wood-buffalo, or download one year at a time (NBAC_<year>_20260513.zip) from the CWFIS download folder. All years at once need several GB of memory. - Reading the files.
read_csv()guesses every column type correctly (fire_idas text, dates as dates,area_adjustedandprescribedas logical), but you can still givecol_types. Empty cells mean “not reported” for dates and “not a park or base” forpark_or_base. - Memory and time. The student script runs in about 1 minute, with a peak memory use of about 0.6 GB. The validation script takes about 4 to 6 minutes and peaks at about 1.7 GB.
NOAA HMS smoke plumes
The Hazard Mapping System (HMS) is a daily map of fire and smoke over North America made by NOAA’s Office of Satellite and Product Operations (OSPO). Trained analysts look at visible daytime images from the GOES weather satellites and draw an outline (a polygon) around every smoke plume they can see. Each outline records the satellite, the start and end time of the images used, and, from 2007–2010 on, a density class: Light, Medium, or Heavy. NOAA publishes one zipped shapefile per year with every outline drawn that year.
In this project HMS answers the question “was there smoke over this county on this day?”, and it reaches back to August 2005 with one consistent method on both sides of the border. It is the screen that other smoke products (Childs et al., CSU) use to decide which days are smoke days. It cannot tell you how much smoke people breathed. Analysts see smoke anywhere in the column of air above the ground, and in the eastern United States Canadian smoke often travels high aloft. A plume marks smoke overhead, not smoke at breathing height. The density class is a rough visual judgment of thickness, not a measured concentration.
| Provider | NOAA/NESDIS Office of Satellite and Product Operations (OSPO), Hazard Mapping System |
| Coverage | plumes 5 August 2005 – 31 December 2025 (UTC dates) · North America and nearby oceans (across all years, outlines reach from 180° W to 24° E and from 1° S to 87° N; the largest 2023–2025 plumes reach Europe) |
| Raw unit | one polygon = one smoke plume drawn by an analyst from one set of satellite images |
| Clean unit | plume table: one row = one plume polygon; calendar: one row = one UTC calendar day; county-day: one row = one county × one UTC day with at least one plume (sparse); county-month: one row = one county × one month (complete) |
| Licence and citation | U.S. Government work, public domain. Cite as NOAA/NESDIS OSPO, Hazard Mapping System Fire and Smoke Product, smoke polygons (annual bundles, accessed 27 September 2026). Method: Ruminski, M., Kondragunta, S., Draxler, R., and Zeng, J. (2006), “Recent changes to the Hazard Mapping System,” 15th International Emission Inventory Conference, U.S. EPA |
| Documentation | HMS product page (OSPO) · annual shapefile bundles |
Raw files
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
hms/hms_smoke2005__v20250204.zip |
0.66 MB | zip archive: one ESRI shapefile hms_smoke2005.shp + .shx, .dbf, .prj |
one smoke polygon (6,289 polygons) |
hms/hms_smoke2006__v20250204.zip |
1.81 MB | zip archive: one ESRI shapefile hms_smoke2006.shp + .shx, .dbf, .prj |
one smoke polygon (15,443 polygons) |
hms/hms_smoke2007__v20250204.zip |
2.32 MB | zip archive: one ESRI shapefile hms_smoke2007.shp + .shx, .dbf, .prj |
one smoke polygon (19,872 polygons) |
hms/hms_smoke2008__v20250204.zip |
2.71 MB | zip archive: one ESRI shapefile hms_smoke2008.shp + .shx, .dbf, .prj |
one smoke polygon (23,277 polygons) |
hms/hms_smoke2009__v20250204.zip |
2.51 MB | zip archive: one ESRI shapefile hms_smoke2009.shp + .shx, .dbf, .prj |
one smoke polygon (23,473 polygons) |
hms/hms_smoke2010__v20250204.zip |
2.91 MB | zip archive: one ESRI shapefile hms_smoke2010.shp + .shx, .dbf, .prj |
one smoke polygon (27,230 polygons) |
hms/hms_smoke2011__v20250204.zip |
3.88 MB | zip archive: one ESRI shapefile hms_smoke2011.shp + .shx, .dbf, .prj |
one smoke polygon (33,720 polygons) |
hms/hms_smoke2012__v20250204.zip |
3.28 MB | zip archive: one ESRI shapefile hms_smoke2012.shp + .shx, .dbf, .prj |
one smoke polygon (27,966 polygons) |
hms/hms_smoke2013__v20250204.zip |
2.63 MB | zip archive: one ESRI shapefile hms_smoke2013.shp + .shx, .dbf, .prj |
one smoke polygon (23,151 polygons) |
hms/hms_smoke2014__v20250204.zip |
2.00 MB | zip archive: one ESRI shapefile hms_smoke2014.shp + .shx, .dbf, .prj |
one smoke polygon (18,562 polygons) |
hms/hms_smoke2015__v20250204.zip |
1.69 MB | zip archive: one ESRI shapefile hms_smoke2015.shp + .shx, .dbf, .prj |
one smoke polygon (16,349 polygons) |
hms/hms_smoke2016__v20250204.zip |
2.25 MB | zip archive: one ESRI shapefile hms_smoke2016.shp + .shx, .dbf, .prj |
one smoke polygon (21,267 polygons) |
hms/hms_smoke2017__v20250204.zip |
5.19 MB | zip archive: one ESRI shapefile hms_smoke2017.shp + .shx, .dbf, .prj |
one smoke polygon (26,187 polygons) |
hms/hms_smoke2018__v20250204.zip |
7.83 MB | zip archive: one ESRI shapefile hms_smoke2018.shp + .shx, .dbf, .prj |
one smoke polygon (41,294 polygons) |
hms/hms_smoke2019__v20250204.zip |
7.70 MB | zip archive: one ESRI shapefile hms_smoke2019.shp + .shx, .dbf, .prj |
one smoke polygon (42,951 polygons) |
hms/hms_smoke2020__v20250204.zip |
8.51 MB | zip archive: one ESRI shapefile hms_smoke2020.shp + .shx, .dbf, .prj |
one smoke polygon (45,287 polygons) |
hms/hms_smoke2021__v20250204.zip |
5.88 MB | zip archive: one ESRI shapefile hms_smoke2021.shp + .shx, .dbf, .prj |
one smoke polygon (27,445 polygons) |
hms/hms_smoke2022__v20250204.zip |
5.45 MB | zip archive: one ESRI shapefile hms_smoke2022.shp + .shx, .dbf, .prj |
one smoke polygon (21,768 polygons) |
hms/hms_smoke2023__v20250204.zip |
6.27 MB | zip archive: one ESRI shapefile hms_smoke2023.shp + .shx, .dbf, .prj |
one smoke polygon (20,191 polygons) |
hms/hms_smoke2024__v20250409.zip |
5.05 MB | zip archive: one ESRI shapefile hms_smoke2024.shp + .shx, .dbf, .prj |
one smoke polygon (12,527 polygons) |
hms/hms_smoke2025__v20260101.zip |
8.98 MB | zip archive: one ESRI shapefile hms_smoke2025.shp + .shx, .dbf, .prj |
one smoke polygon (24,238 polygons) |
In all, the 21 files take 89.5 MB and hold 518,487 polygons. Inside each zip, .shp holds the outlines, .shx is an index, .dbf is the attribute table, and .prj gives the coordinate system (WGS 84 longitude/latitude).
NOAA posts each year as hms_smoke<year>.zip. The research project added __v<date> when it downloaded the files on 27 September 2026: the date NOAA last posted the file. It is v20250204 for 2005–2023, v20250409 for 2024, and v20260101 for 2025. NOAA regenerated its whole archive when it upgraded HMS in 2022, so older years are re-posted from time to time; check this date if you download a fresh copy. The project also has a partial 2026 file, which is not shipped.
Raw variables
All 21 shapefiles have the same four attributes, all stored as text in the .dbf file, plus the outline itself. Descriptions follow the OSPO product page; counts are over all 518,487 polygons.
| Variable | Type | Description |
|---|---|---|
Satellite |
text (15 characters) | Satellite whose images the analyst used. GOES (2005 – 31 October 2016; the provider does not say which GOES), then GOES-EAST or GOES-WEST (from 20 October 2016). Five plumes in 2024–2025 name a polar-orbiting satellite: NOAA 20, NOAA 21, or Suomi NPP |
Start |
text (12 characters), YYYYDDD HHMM |
Time of the first image used for the plume, in UTC (Coordinated Universal Time, the clock at Greenwich, 4 to 8 hours ahead of U.S. clocks). YYYY is the year, DDD the day of the year (001 = 1 January), HHMM hours and minutes. Example: 2023156 1300 = 5 June 2023, 13:00 UTC |
End |
text (12 characters), YYYYDDD HHMM |
Time of the last image used for the plume, in UTC, same format. Equal to Start for 41,084 of the 518,487 raw polygons (drawn from one image) |
Density |
text (6 characters) | Analyst’s judgment of how thick the smoke looks: Light, Medium, or Heavy; the text NA when the plume has no label. Labels are missing for all plumes in 2005–2006 and for most in 2007, 2009, and early 2010 (see “Checks and cautions”). The OSPO page says the classes were stored as numbers before July 2022 (it lists them as 5, 16, 21); Liu et al. (2024; full reference under “Checks and cautions”) report them as 5, 16, and 27 µg/m³, rough smoke PM2.5 equivalents that the 2022 update dropped. The archive NOAA posts today uses the words in every year |
| geometry | polygon | Outline of the plume, longitude/latitude in degrees (WGS 84). One ring per plume, without holes. 751 outlines (in 2009, 2010, 2012, and 2015) are not closed, and 90 are points, lines, or flat shapes with no area |
Cleaning steps
The code makes these decisions:
- Day = UTC date of the plume’s start time. This is the research project’s convention and the way NOAA dates its daily analysis. About 4% of plumes start between 00:00 and 04:59 UTC, which is still the evening of the previous day in U.S. time; they count for the next day.
- Repair the outlines, then drop the ones with no area. Open rings are closed,
st_make_valid()fixes self-crossing outlines, and 90 of 518,487 polygons that are single points, lines, or flat shapes are dropped. - Flat geometry in longitude/latitude.
sf_use_s2(FALSE)makes sf treat longitude and latitude as flat x and y, as the project does. It is faster and handles the hand-drawn outlines; at county scale the answer is practically the same as on a sphere. - Counties are land areas. The county outlines from module m00 are clipped to the shoreline, so a plume over Lake Erie or the Atlantic does not count for a coastal county.
- Population shares use block-group centers. Each Census block group (a neighborhood of roughly 600 to 3,000 people) is represented by its 2020 center of population. A county’s share under a plume is the population of the centers inside the plume divided by the county’s population.
- County panels start in 2006. HMS starts on 5 August 2005, so 2005 is in the plume table and calendar but not in the county panels.
- Density code 0 = plume without a label. In the county files,
max_densityis the densest plume over the county that day: 0 unlabeled, 1 Light, 2 Medium, 3 Heavy. - Write the county-day file one year at a time. The full county-day table has about 4 million rows. Each year’s rows are checked, appended to the end of the file, and summarized by month right away, so the whole table is never in memory at once.
First, turn off spherical geometry and set the years.
# HMS plume outlines are stored in longitude/latitude. Like the research
# project, we test overlaps with flat (planar) geometry, not with sf's default
# spherical engine (s2). Planar tests are much faster here, and they accept the
# hand-drawn outlines once we repair them. At county scale the two engines
# give practically the same answer.
sf_use_s2(FALSE)
hms_dir <- file.path(raw_dir, "hms")
years <- 2005:2025 # plume table and calendar
panel_years <- 2006:2025 # county panels: the 2005 file starts on 5 August
dir.create(clean_dir, recursive = TRUE, showWarnings = FALSE)List the 21 yearly zip files and check there is exactly one per year.
# One zipped shapefile per year. The end of the name is the date NOAA posted
# the file (v20250204 = 4 February 2025). NOAA re-posts a year when it
# revises it, so check this date when you download a newer copy.
hms_files <- tibble(
path = list.files(hms_dir, pattern = "^hms_smoke\\d{4}__v\\d{8}\\.zip$",
full.names = TRUE)
) |>
mutate(year = as.integer(str_sub(basename(path), 10, 13)))
# Exactly one file for each year.
stopifnot(setequal(hms_files$year, years), !anyDuplicated(hms_files$year))
hms_filesThe reading function opens the shapefile straight from the zip (GDAL’s /vsizip/ path), repairs the outlines, and converts the times and density labels.
# 751 outlines (in 2009, 2010, 2012, and 2015) are "open": the last corner
# is not a copy of the first. GDAL warns "Non closed ring detected",
# and GEOS, the geometry engine behind sf, refuses to work with an open ring.
# close_ring() repeats the first corner at the end of the ring when needed.
close_ring <- function(xy) {
if (any(xy[1, ] != xy[nrow(xy), ])) xy <- rbind(xy, xy[1, ])
xy
}
read_hms_year <- function(year) {
shp <- file.path("/vsizip", hms_files$path[hms_files$year == year],
str_glue("hms_smoke{year}.shp"))
# Silence only the open-ring warning, because close_ring() repairs those
# rings below. Any other warning still shows.
raw <- withCallingHandlers(
read_sf(shp),
warning = function(w) {
if (str_detect(conditionMessage(w), "Non closed ring")) {
invokeRestart("muffleWarning")
}
}
)
# Every outline is one ring. The .prj file says WGS 84 longitude/latitude,
# which is EPSG:4326.
closed <- map(st_geometry(raw), \(poly) st_polygon(map(poly, close_ring)))
st_geometry(raw) <- st_sfc(closed, crs = 4326)
raw |>
mutate(
# plume_id = year + row number in the raw file, so you can find the
# plume again in the shapefile.
plume_id = str_c(year, "_", str_pad(row_number(), 5, pad = "0")),
# A ring needs 4 points (3 corners + the first one again) to enclose
# an area. 77 outlines are single points or there-and-back lines.
n_points = map_int(geometry, \(poly) nrow(poly[[1]]))
) |>
filter(n_points >= 4) |>
st_make_valid() |>
# Repair turns 13 flat outlines (all corners on one line) into lines.
# A line has no area, so keep polygons only.
filter(st_geometry_type(geometry) %in% c("POLYGON", "MULTIPOLYGON")) |>
mutate(
# Start and End are "YYYYDDD HHMM": year, day of the year, and the
# hour and minute in UTC (Coordinated Universal Time, the clock at
# Greenwich, 4 to 8 hours ahead of U.S. clocks).
start_utc = as.POSIXct(Start, format = "%Y%j %H%M", tz = "UTC"),
end_utc = as.POSIXct(End, format = "%Y%j %H%M", tz = "UTC"),
# The project's day: the UTC date of the plume's start time.
date = as_date(start_utc),
satellite = str_trim(Satellite),
# Unlabeled plumes store the text "NA"; make it a real missing value.
density = na_if(str_trim(Density), "NA"),
# Position in the list = code: Light 1, Medium 2, Heavy 3; unlabeled 0.
density_code = coalesce(match(density, c("Light", "Medium", "Heavy")), 0L)
) |>
select(plume_id, date, start_utc, end_utc, satellite, density, density_code)
}Try the function on one year and look at the result before running all years.
# Look at one year before running all of them. 2023 is the year of the
# eastern Canadian fires.
plumes_2023 <- read_hms_year(2023)
plumes_2023
# Every time parses, and every plume starts in its file's year.
stopifnot(!anyNA(plumes_2023$start_utc), !anyNA(plumes_2023$end_utc),
all(year(plumes_2023$date) == 2023),
all(plumes_2023$end_utc >= plumes_2023$start_utc))
count(st_drop_geometry(plumes_2023), satellite, density)The plume table keeps the attributes, the area in km², and the center of each plume, computed in an equal-area projection.
# One row per plume, without its outline. Area and center are computed in
# CONUS Albers (EPSG:5070), an equal-area map projection in meters, so
# st_area() gives true areas. The center is moved back to longitude/latitude.
plume_table <- function(plumes) {
outlines <- st_transform(st_geometry(plumes), 5070)
centers <- outlines |>
st_centroid() |>
st_transform(4326) |>
st_coordinates()
plumes |>
st_drop_geometry() |>
transmute(
plume_id, date, start_utc, end_utc, satellite, density,
area_km2 = round(as.numeric(st_area(outlines)) / 1e6, 1), # m² to km²
centroid_lon = round(centers[, "X"], 4),
centroid_lat = round(centers[, "Y"], 4)
)
}
plume_table(plumes_2023)Read the county outlines and block-group population centers written by module m00.
# County outlines from module m00 are clipped to the shoreline, so a plume
# that covers only the Great Lakes or the ocean does not count for a county.
counties <- read_sf(file.path(clean_dir, "counties_2020.gpkg")) |>
select(GEOID)
# Block-group centers of population stand for where people live. Block
# groups with no residents add nothing to a population share, so drop them.
bg_points <- read_csv(
file.path(clean_dir, "census_bg_centers_2020.csv"),
col_types = cols(bg_geoid = col_character(), GEOID = col_character(),
pop_2020 = col_double(), lat = col_double(), lon = col_double())
) |>
filter(pop_2020 > 0) |>
st_as_sf(coords = c("lon", "lat"), crs = 4326)
county_pop <- bg_points |>
st_drop_geometry() |>
summarize(county_pop = sum(pop_2020), .by = GEOID)
stopifnot(nrow(counties) == 3108, nrow(county_pop) == 3108,
setequal(county_pop$GEOID, counties$GEOID))Find which plumes touch which county, using st_intersects() and a lookup instead of a full spatial join.
# Which plumes touch which county? st_intersects(plumes, counties) answers
# with a list: for plume 1, the row numbers of the counties it touches; then
# plume 2; and so on. as.data.frame() turns that list into one row per
# (plume, county) pair: row.id is the plume's row and col.id the county's
# row. We look up each pair's GEOID, date, and density, then count plumes
# per county and day and keep the densest one. (st_join() gives the same
# pairs but copies a county outline for every pair, which is slow and needs
# much more memory.) sf prints a note that it treats longitude/latitude as
# flat; we chose that above, so the note is silenced.
county_overlap <- function(plumes) {
pairs <- suppressMessages(st_intersects(plumes, counties)) |>
as.data.frame()
tibble(
GEOID = counties$GEOID[pairs$col.id],
date = plumes$date[pairs$row.id],
density_code = plumes$density_code[pairs$row.id]
) |>
summarize(n_plumes = n(), max_density = max(density_code),
.by = c(GEOID, date))
}
county_overlap(plumes_2023) |>
arrange(desc(n_plumes))Find which population centers lie under a plume.
# Which block-group centers lie inside a plume? Same idea, with points:
# row.id is the plume's row and col.id the center's row. A center under
# several plumes on the same day keeps the densest one: sort the densest
# first, and distinct() keeps the first row of each center-day.
# (summarize(max()) gives the same answer but is slow and needs a lot of
# memory with millions of tiny groups.) Then we add up the population of
# the centers per county-day. `points` is the set of centers to test.
pop_under_plumes <- function(plumes, points) {
pairs <- suppressMessages(st_intersects(plumes, points)) |>
as.data.frame()
tibble(
bg = pairs$col.id,
date = plumes$date[pairs$row.id],
density_code = plumes$density_code[pairs$row.id]
) |>
arrange(desc(density_code)) |>
distinct(bg, date, .keep_all = TRUE) |>
mutate(GEOID = points$GEOID[bg], pop = points$pop_2020[bg]) |>
summarize(
pop_any = sum(pop),
pop_medium_plus = sum(pop[density_code >= 2]),
pop_heavy = sum(pop[density_code == 3]),
.by = c(GEOID, date)
)
}Combine the two into one row per county and day. To keep memory low, county_day() works through each year in 5-day windows. Inside a window it tests the population centers one day at a time, and only those in counties a plume touches. It returns the rows sorted by date and county.
# One row per county and day on which at least one plume touches the
# county's land. Population shares come from the block-group centers. A
# plume can touch the edge of a county and miss every center, so a share
# can be 0 on a plume day.
county_day_window <- function(plumes) {
overlap <- county_overlap(plumes)
# Test the centers one day at a time, and only those in counties that a
# plume touches that day: other centers cannot count. A smoky day can
# still give a million (plume, center) pairs; a whole window at once would
# give several million.
shares <- map(unique(plumes$date), \(d) {
points <- bg_points[bg_points$GEOID %in% overlap$GEOID[overlap$date == d], ]
pop_under_plumes(plumes[plumes$date == d, ], points)
}) |>
list_rbind()
# left_join() keeps only county-days in `overlap`. A center in water just
# off the coast can sit under a plume that misses the county's land; that
# county-day is not in `overlap`, so the plume does not count for it.
overlap |>
left_join(shares, by = c("GEOID", "date")) |>
left_join(county_pop, by = "GEOID") |>
mutate(
across(c(pop_any, pop_medium_plus, pop_heavy), \(x) replace_na(x, 0)),
pop_share_any = round(pop_any / county_pop, 4),
pop_share_medium_plus = round(pop_medium_plus / county_pop, 4),
pop_share_heavy = round(pop_heavy / county_pop, 4)
) |>
select(GEOID, date, n_plumes, max_density,
pop_share_any, pop_share_medium_plus, pop_share_heavy)
}
# In a smoky year a center can sit under plumes on a hundred days: 2020
# gives about 26 million (plume, center) pairs. So we work through the year
# in 5-day windows (days 1-5 of the year, 6-10, ...) to keep memory low.
# (Testing the 3,108 county outlines is slow, so we do that once per window,
# not once per day.) Every plume belongs to one date, so no county-day is
# split across windows.
county_day <- function(plumes) {
window <- (yday(plumes$date) - 1) %/% 5
map(sort(unique(window)), \(w) county_day_window(plumes[window == w, ])) |>
list_rbind() |>
arrange(date, GEOID)
}The county-day panel is checked one year at a time, inside the main loop. This function holds the grain checks.
# SPARSE panel. Grain: one row per county and day with at least one plume
# over the county's land. A missing county-day means no plume touched the
# county that day, which counts as "no smoke seen" only if hms_calendar
# says the day was analysed. We check one year at a time, inside the main
# loop. A key (GEOID, date) cannot repeat across years, so unique keys in
# every year mean unique keys in the whole file.
check_county_day <- function(cd, year) {
stopifnot(
nrow(distinct(cd, GEOID, date)) == nrow(cd),
all(cd$GEOID %in% counties$GEOID),
all(between(cd$date, make_date(year, 1, 1), make_date(year, 12, 31))),
all(cd$n_plumes >= 1),
all(cd$max_density %in% 0:3),
all(between(cd$pop_share_any, 0, 1)),
all(cd$pop_share_medium_plus <= cd$pop_share_any),
all(cd$pop_share_heavy <= cd$pop_share_medium_plus)
)
}The county-month panel needs only monthly counts, so each year’s county-days are summarized right away.
# For the county-month panel we only need, per county and month, how many
# days had a plume. Counting right after each year is built means we never
# have to hold all 4 million county-days in memory at once.
month_counts <- function(cd) {
cd |>
mutate(year = year(date), month = month(date)) |>
summarize(
plume_days = n(),
medium_plus_days = sum(max_density >= 2),
heavy_days = sum(max_density == 3),
pop_weighted_plume_days = round(sum(pop_share_any), 3),
.by = c(GEOID, year, month)
)
}Run all years. This loop takes about 15 minutes. It writes the sparse county-day file year by year, appending each year to the end of the file, so the 4 million county-days are never in memory at once.
# The main loop takes about 15 minutes. For each year it reads the plumes and
# keeps the plume table. From 2006 on it also builds the county-day rows,
# checks them, adds them to the end of the county-day file, and keeps only
# their monthly counts. Writing the county-day rows year by year keeps
# memory low: the full table has about 4 million rows. Results go into
# lists made in advance, one slot per year. The 2023 demo object is no
# longer needed, so free its memory first.
rm(plumes_2023)
county_day_file <- file.path(clean_dir, "hms_county_day_2006_2025.csv.gz")
plume_parts <- vector("list", length(years))
month_parts <- vector("list", length(years))
county_day_rows <- integer(length(years))
for (i in seq_along(years)) {
plumes <- read_hms_year(years[i])
stopifnot(!anyNA(plumes$start_utc), !anyNA(plumes$end_utc),
all(year(plumes$date) == years[i]))
plume_parts[[i]] <- plume_table(plumes)
if (years[i] %in% panel_years) {
cd <- county_day(plumes)
check_county_day(cd, years[i])
# The first panel year starts a new file (with column names); later
# years are appended below it. read_csv() reads the result as one file.
write_csv(cd, county_day_file, na = "", append = years[i] > panel_years[1])
month_parts[[i]] <- month_counts(cd)
county_day_rows[i] <- nrow(cd)
rm(cd)
}
message(years[i], ": ", nrow(plumes), " plumes, ",
county_day_rows[i], " county-days")
# Hand the memory of this year's plumes back before reading the next year.
rm(plumes)
gc()
}
hms_plumes <- list_rbind(plume_parts)
plume_months <- list_rbind(month_parts)
rm(plume_parts, month_parts)
# Total county-day rows written. The file is about 110 MB as plain text, so
# it is compressed (.csv.gz); read_csv() reads .csv.gz files directly.
sum(county_day_rows)Check and write the plume table.
# Grain: one row per plume polygon; plume_id is unique. Every plume from
# 2 June 2010 on has a density label. Before that, most plumes have none
# (see "Checks and cautions").
stopifnot(
!anyDuplicated(hms_plumes$plume_id),
all(between(hms_plumes$date, as_date("2005-08-05"), as_date("2025-12-31"))),
all(hms_plumes$density %in% c("Light", "Medium", "Heavy", NA)),
!anyNA(hms_plumes$satellite),
all(hms_plumes$area_km2 >= 0),
all(between(hms_plumes$centroid_lat, -90, 90)),
all(between(hms_plumes$centroid_lon, -180, 180))
)
count(hms_plumes, year = year(date))
write_csv(hms_plumes, file.path(clean_dir, "hms_smoke_plumes_2005_2025.csv"), na = "")Build the calendar of days with and without plumes.
# One row per calendar day, 2005-2025. A day with no plume is ambiguous:
# either the analysts saw no smoke, or nobody analysed that day (the record
# starts on 5 August 2005, and a few days later on are missing). We cannot
# tell the two apart, so "analysed" only means "at least one plume".
hms_calendar <- tibble(date = seq(as_date("2005-01-01"), as_date("2025-12-31"),
by = "day")) |>
left_join(
hms_plumes |>
summarize(n_plumes = n(), share_unlabeled = round(mean(is.na(density)), 3),
.by = date),
by = "date"
) |>
mutate(n_plumes = replace_na(n_plumes, 0L), analysed = n_plumes > 0) |>
select(date, n_plumes, analysed, share_unlabeled)
# Grain: one row per day; share_unlabeled is missing exactly on no-plume days.
stopifnot(
nrow(hms_calendar) == 7670, !anyDuplicated(hms_calendar$date),
sum(hms_calendar$n_plumes) == nrow(hms_plumes),
all(is.na(hms_calendar$share_unlabeled) == !hms_calendar$analysed)
)
count(hms_calendar, year = year(date), analysed) |>
pivot_wider(names_from = analysed, values_from = n, names_prefix = "analysed_")
write_csv(hms_calendar, file.path(clean_dir, "hms_calendar_2005_2025.csv"), na = "")Build the complete county-month panel from the monthly counts and the calendar.
# COMPLETE panel: every county x every month, 2006-2025. The monthly counts
# come from the sparse county-day rows (plume_months, built in the loop), so
# a county-month with no plume day gets 0.
month_days <- hms_calendar |>
filter(year(date) %in% panel_years) |>
mutate(year = year(date), month = month(date)) |>
summarize(analysed_days = sum(analysed), n_days = n(), .by = c(year, month))
hms_county_month <- expand_grid(GEOID = counties$GEOID, month_days) |>
left_join(plume_months, by = c("GEOID", "year", "month")) |>
mutate(
across(c(plume_days, medium_plus_days, heavy_days), \(x) replace_na(x, 0L)),
pop_weighted_plume_days = replace_na(pop_weighted_plume_days, 0)
) |>
select(GEOID, year, month, plume_days, medium_plus_days, heavy_days,
pop_weighted_plume_days, analysed_days, n_days) |>
arrange(GEOID, year, month)
# Grain: 3,108 counties x 240 months, no missing cells. Every month in
# 2006-2025 has analysed days, so a 0 is a real zero, not a gap.
stopifnot(
nrow(hms_county_month) == 3108 * 240,
nrow(distinct(hms_county_month, GEOID, year, month)) == nrow(hms_county_month),
!anyNA(hms_county_month),
all(hms_county_month$analysed_days > 0),
all(hms_county_month$plume_days <= hms_county_month$analysed_days),
all(hms_county_month$heavy_days <= hms_county_month$medium_plus_days),
all(hms_county_month$medium_plus_days <= hms_county_month$plume_days),
all(hms_county_month$pop_weighted_plume_days <= hms_county_month$plume_days)
)
write_csv(hms_county_month, file.path(clean_dir, "hms_county_month_2006_2025.csv"),
na = "")When you come back to these files later, read them with explicit column types.
# How to read the clean files later. Give col_types for the plume table:
# read_csv() guesses types from the first 1,000 rows, and those (August 2005)
# have no density label, so it would guess "logical" and turn every later
# "Light"/"Medium"/"Heavy" into NA. GEOID must stay text to keep its
# leading zero (01001, not 1001). First drop the copies built above, so the
# file and the in-memory copy are not both held at once.
rm(hms_plumes, hms_county_month, plume_months)
invisible(gc())
hms_plumes <- read_csv(
file.path(clean_dir, "hms_smoke_plumes_2005_2025.csv"),
col_types = cols(plume_id = col_character(), density = col_character(),
satellite = col_character(), date = col_date())
)
hms_county_month <- read_csv(
file.path(clean_dir, "hms_county_month_2006_2025.csv"),
col_types = cols(GEOID = col_character())
)
stopifnot(nrow(hms_plumes) == sum(hms_calendar$n_plumes),
sum(!is.na(hms_plumes$density)) > 400000,
nrow(hms_county_month) == 3108 * 240)
count(hms_plumes, density)Check the county-day file without loading all of it: read it in pieces of 500,000 rows and count the rows per year.
# In your own work, read the county-day file in one go, in a fresh R session
# (it needs about 1 GB of memory):
# read_csv(county_day_file,
# col_types = cols(GEOID = col_character(), date = col_date()))
# Here, after the big loop, we only check it. read_csv_chunked() reads
# 500,000 rows at a time and keeps just a count of rows per year from each
# piece. Each year must hold exactly the rows the loop appended for it.
rows_per_year <- read_csv_chunked(
county_day_file,
DataFrameCallback$new(\(piece, pos) count(piece, year = year(date))),
chunk_size = 500000,
col_types = cols(GEOID = col_character(), date = col_date())
) |>
summarize(n = sum(n), .by = year)
stopifnot(nrow(rows_per_year) == length(panel_years),
all(rows_per_year$year == panel_years),
all(rows_per_year$n == county_day_rows[years %in% panel_years]))
rows_per_yearClean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
hms_smoke_plumes_2005_2025.csv |
518,397 | one plume polygon, without its outline | 52.1 MB |
hms_calendar_2005_2025.csv |
7,670 | one UTC calendar day, 1 January 2005 – 31 December 2025 | 0.16 MB |
hms_county_day_2006_2025.csv.gz |
3,969,604 | one county × one UTC day with at least one plume over the county’s land (sparse) | 15.6 MB (gzip-compressed; about 111 MB as plain text) |
hms_county_month_2006_2025.csv |
745,920 | one county × one month, 2006–2025: 3,108 counties × 240 months (complete) | 21.7 MB |
Clean variables
hms_smoke_plumes_2005_2025.csv: one row per plume; the key is plume_id. Read it with col_types as in chunk m02-read-back. Left to guess, read_csv() reads density as lgl, because the first 1,000 rows (2005) have no label, and every label becomes NA.
| Variable | Type | Units | Description |
|---|---|---|---|
plume_id |
chr | — | Year and row number of the polygon in that year’s raw shapefile, e.g., 2023_00001. Row numbers count the raw file, so the 90 dropped polygons leave gaps |
date |
date | — | UTC date of the plume’s start time (the project’s day); 2005-08-05 to 2025-12-31 |
start_utc |
datetime | UTC | Time of the first image used (raw Start) |
end_utc |
datetime | UTC | Time of the last image used (raw End); never before start_utc, at most 23 hours after it |
satellite |
chr | — | GOES, GOES-EAST, GOES-WEST, NOAA 20, NOAA 21, or Suomi NPP (raw, spaces trimmed) |
density |
chr | — | Light, Medium, or Heavy; empty (NA) when the analyst gave no label. Every plume from 2 June 2010 on has a label |
area_km2 |
dbl | km² | Area of the repaired outline in CONUS Albers (EPSG:5070), an equal-area projection; median 636 km², up to 52.3 million km². Rounded to 0.1, so 8 tiny plumes show 0 |
centroid_lon |
dbl | degrees east | Longitude of the outline’s center (computed in EPSG:5070, then converted back); negative = west |
centroid_lat |
dbl | degrees north | Latitude of the outline’s center |
hms_calendar_2005_2025.csv: one row per UTC calendar day; the key is date. read_csv() reads n_plumes as dbl.
| Variable | Type | Units | Description |
|---|---|---|---|
date |
date | — | Calendar day, 2005-01-01 to 2025-12-31 |
n_plumes |
dbl | count | Plume polygons with that start date (after the 90 empty outlines are dropped); 0 on days with none |
analysed |
lgl | — | TRUE if at least one plume is dated that day. FALSE is ambiguous: either the analysts saw no smoke, or nobody analysed the day. The files do not say which |
share_unlabeled |
dbl | share (0–1) | Share of that day’s plumes without a density label; empty (NA, not applicable) on days with no plume |
hms_county_day_2006_2025.csv.gz: one row per county and UTC day with at least one plume over the county’s land; the key is GEOID + date. Rows are sorted by date, then GEOID. The script writes the file one year at a time, appending each year to the end; read_csv() reads it as one file. A county-day that is absent means no plume overlapped the county that day. That is “no smoke seen” only if the calendar says the day was analysed. read_csv() reads the counts as dbl.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (2-digit state + 3-digit county), 2020 boundaries; joins to counties_2020.csv |
date |
date | — | UTC date of the plumes’ start time, 2006-01-01 to 2025-12-31 |
n_plumes |
dbl | count | Number of plume polygons that touch the county’s land that day (1 to 46). Outlines drawn from GOES-East and GOES-West images, or at different times of day, can cover the same smoke, so this is not a count of separate smoke events |
max_density |
dbl | code | Densest plume over the county that day: 0 = no label, 1 = Light, 2 = Medium, 3 = Heavy |
pop_share_any |
dbl | share (0–1) | Share of the county’s 2020 population whose block-group center lies inside any plume that day. Can be 0 when a plume touches only the edge of the county |
pop_share_medium_plus |
dbl | share (0–1) | Same, for plumes labeled Medium or Heavy |
pop_share_heavy |
dbl | share (0–1) | Same, for plumes labeled Heavy |
hms_county_month_2006_2025.csv: one row per county and month; the key is GEOID + year + month. Every county-month is present, and a 0 is a real zero: no plume touched the county on any analysed day of the month. read_csv() reads every column except GEOID as dbl.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code |
year |
dbl | — | Calendar year, 2006–2025 |
month |
dbl | — | Month, 1–12 |
plume_days |
dbl | days | Days with at least one plume over the county (rows in the county-day file) |
medium_plus_days |
dbl | days | Days whose densest plume is Medium or Heavy (max_density >= 2) |
heavy_days |
dbl | days | Days whose densest plume is Heavy (max_density == 3) |
pop_weighted_plume_days |
dbl | days | Sum of pop_share_any over the month: plume days weighted by the share of residents under the plume |
analysed_days |
dbl | days | Days in the month with at least one HMS plume anywhere (from the calendar); the same for every county |
n_days |
dbl | days | Days in the month (28–31) |
Checks and cautions
- Smoke overhead is not smoke at breathing height. Analysts draw plumes from satellite pictures of the whole air column. Liu et al. (2024) compared HMS with airport visibility reports, air-quality monitors, and model estimates of near-surface smoke for 2010–2021: outside the West, surface smoke PM2.5 inside plumes is low and barely differs across the Light, Medium, and Heavy classes. Treat an eastern plume day as “smoke was overhead”, and use a surface measure (AQS monitors, Childs, CSU) for what people breathed. Reference: Liu, T., Panday, F. M., Caine, M. C., Kelp, M., Pendergrass, D. C., Mickley, L. J., Ellicott, E. A., Marlier, M. E., Ahmadov, R., and James, E. P. (2024), “Is the smoke aloft? Caveats regarding the use of the Hazard Mapping System (HMS) smoke product as a proxy for surface smoke presence across the United States,” International Journal of Wildland Fire 33(10), WF23148, doi:10.1071/WF23148.
- Agreement with the research project’s build (
05c_hms.R, which uses data.table and county outlines simplified to about 200 m): the county-day file matches 3,969,409 of the project’s 3,969,528 county-days for 2006–2025. 195 county-days appear only here and 119 only in the project; all of them have one plume that grazes the county edge and covers no population center, so they come from the outline simplification. On matched rows,n_plumesagrees on 99.98% (662 differ) andmax_densityon 99.997% (117 differ), and the three population shares differ by at most 0.00005 (rounding). These differences have two sources. A few come from the 13 flat outlines the project keeps: repair turns them into lines, which can touch counties without covering any area. Only 19 of the 662n_plumesdifferences fall on the 10 dates with such lines, and none of themax_densitydifferences do. The rest come from the simplified county edges: a plume that just grazes a county can touch the exact outline but miss the simplified one, or the other way round. On the remaining 643 county-days this file has one plume more (435) or one fewer (208). In the county-month file,plume_daysdiffers in 314 of 745,920 cells (by at most 1 day), andanalysed_daysandn_daysagree everywhere. The calendar’sanalysedflag is identical to the project’s; the project counts 13 more polygons on 10 days because it keeps 13 flat outlines that this script drops. - Raw versus clean plumes. The 21 raw files hold 518,487 polygons; the plume table keeps 518,397. The 90 dropped are 77 single points or there-and-back lines and 13 flat outlines; none has an area.
- When density labels begin. No plume has a label in 2005 or 2006. The first labeled plume is dated 26 April 2007. Labels stay patchy: 19,614 of 19,872 raw plumes are unlabeled in 2007, 5,076 of 23,277 in 2008, 23,325 of 23,473 in 2009, and 7,437 of 27,230 in 2010. The last unlabeled plume is dated 1 June 2010; from 2 June 2010 every plume is labeled. Before then,
max_density == 0and amedium_plus_daysof 0 do not mean the smoke was thin, only that nobody labeled it. Density-based measures are comparable over time only from mid-2010. A published block-group smoke series built from HMS also starts in June 2010: Vargo, J. A. (2020), “Time series of potential US wildland fire smoke exposures,” Frontiers in Public Health 8:126, doi:10.3389/fpubh.2020.00126. - A day with no plume is ambiguous. The calendar has 61 days without any plume between 2006 and 2025 (for example 25 December in several years). On those days either the analysts saw no smoke or the day was not analysed, and the files cannot tell these apart. For example, 12 May 2023, during the Alberta fires, has no plume at all; that day was almost certainly not analysed, so treat it as missing, not as smoke-free. HMS starts on 5 August 2005, so the 216 days before that have no plume because there is no analysis; three days after 5 August 2005 also have none. In the county-month file, 42 months have
analysed_days < n_days; divide byanalysed_days, notn_days, if you want a share of days. - What a missing county-day means. The county-day file is sparse: a county and day that is not in the file had no plume over its land. On an analysed day that is a real zero (“no smoke seen”); on a day that is not analysed it is unknown. Before joining it to a complete panel, fill
n_plumeswith 0 and the shares with 0 only for analysed days. - A plume day can have a population share of 0. 278,631 of county-days (7.0%) have
pop_share_any == 0: the plume touches the county’s land but misses every block-group center. In 79% of county-days the whole population is under a plume. - More plumes in recent years. County-days per year rise from about 70,000–200,000 in 2006–2020 to 320,000–530,000 in 2021–2025 (the peak, 532,963, is 2023). Much of this is more smoke (2021 and 2023 were big fire years in Canada and the West), but the images and the analysts’ practice also changed: GOES-16 replaced the older GOES-East in December 2017, and GOES-19 replaced GOES-16 on 7 April 2025. Compare years with care, and use year fixed effects in regressions.
- Satellite labels change in October 2016. Until 31 October 2016 every plume says
GOES; from 20 October 2016 plumes sayGOES-EASTorGOES-WEST. Do not read the switch as a change in coverage. - Plume size varies hugely. Areas run from under 1 km² (13 plumes) to 52.3 million km², with a median of 636 km². 1,062 plumes are larger than 10 million km²; the largest are Light plumes from the 2023 and 2025 Canadian seasons that stretch far beyond North America. For such huge outlines the area is only approximate, because the edges are drawn as straight lines between corners. Because one plume can cover a whole region, plume counts say more about how analysts split smoke into outlines than about how much smoke there was.
- Reading the files. Read the plume table with
col_types(chunkm02-read-back); otherwisedensityis read as all NA. KeepGEOIDas text. Reading the whole county-day file withread_csv()needs about 1 GB of memory, so do it in a fresh R session (the comment in chunkm02-read-county-dayshows the call). The file is written in yearly pieces; this is invisible toread_csv(),gzip, and Python’spandas. - Run time and memory. On the instructor’s Mac (with other jobs running) the full script took about 15 minutes (883 and 884 seconds in two clean runs). To keep memory low it works in 5-day windows inside each year, tests population centers one day at a time, and writes the county-day file one year at a time instead of holding all 4 million rows. In those two runs the R process used at most 1.7 to 1.8 GB of memory (maximum resident set size); the “Memory” figure in Activity Monitor peaked at 0.8 to 1.0 GB. Plan on about 2 GB of free memory, and close other programs before you run the loop.
CSU kriged smoke PM2.5
This product estimates how much of the fine-particle pollution (PM2.5: particles 2.5 micrometers across or smaller) in the air near the ground came from smoke, every day from 2006 to 2025, on a grid of cells about 15 km wide over the contiguous U.S. A team of atmospheric scientists based mainly at Colorado State University (CSU) makes it. They start from the 24-hour averages measured at EPA ground monitors and fill in the map between monitors by kriging, a statistical method that predicts each cell from nearby monitors and gives more weight to monitors that are closer. They then use NOAA’s Hazard Mapping System (HMS), in which analysts trace smoke plumes on satellite images every day: on cell-days under a plume, smoke PM2.5 is total PM2.5 minus the cell’s normal non-smoke level (its seasonal “background”); on all other cell-days it is zero.
It is the research project’s main measure of the smoke that people breathe: one consistent method for every year, every county, and every day. It cannot tell you where the smoke came from (Canadian, western U.S., or agricultural fires), because it uses no fire locations and no winds. Between monitors it is an interpolation, so values in rural areas far from monitors are smooth guesses, and an HMS plume can sit high above clean surface air, so smoke PM2.5 on some plume days is only noise around zero.
| Provider | Jeffrey Pierce, Bonne Ford, Katelyn O’Dell, Jennifer McGinnis, and Emily Fischer, published on Dryad (version 8, 2026-08-06). Dryad lists Pierce, McGinnis, and Fischer at Colorado State University, Ford at the Cooperative Institute for Research in the Atmosphere (CIRA, a CSU institute), and O’Dell at the U.S. Department of Energy. Inputs: EPA Air Quality System (AQS) monitors and NOAA HMS smoke plumes |
| Coverage | 2006–2025, daily · a 309 × 189 grid over the contiguous U.S. that also covers nearby ocean, southern Canada, and northern Mexico. In the bundle: the raw grid for 2023 only (median background), and county files for all of 2006–2025 (the 3,108 contiguous-U.S. counties, DC included) |
| Raw unit | one grid cell (about 15 km wide) × day, in each of three daily arrays |
| Clean unit | one row = a county × day (2023 sample here; 2006–2025 in the instructor export) or a county × month (instructor export) |
| Licence and citation | CC0 1.0 (public-domain dedication: no restrictions, but cite the work). Data: Pierce, J., Ford, B., O’Dell, K., McGinnis, J., & Fischer, E. (2026). Spatially interpolated non-smoke and smoke PM2.5 concentrations for the US from 2006-2025 (Version 8) [Dataset]. Dryad. https://doi.org/10.5061/dryad.k0p2ngfhv. Method: O’Dell, K., Ford, B., Fischer, E. V., & Pierce, J. R. (2019). Contribution of wildland-fire smoke to US PM2.5 and its influence on recent trends. Environmental Science & Technology, 53(4), 1797–1804. https://doi.org/10.1021/acs.est.8b05430 |
| Documentation | Dryad dataset page and README (a copy of the README is in raw/csu/README.md); O’Dell et al. (2019); processing code (Zenodo); NOAA HMS fire and smoke product |
Raw files
The full product is 40 NetCDF files (20 years × two background definitions, mean and median), 514.6 to 516.0 MB each and 20.6 GB in all. The bundle ships one of them: 2023 with the median background. The county files for all years come from the instructor export (see the appendix).
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
csu/krigedPM25_2023_v2_statfix_medianbk.nc |
514.6 MB | NetCDF-4 (a file of labeled multi-dimensional arrays) | not rows but arrays: one value per grid cell × day for 309 × 189 cells × 365 days (21,316,365 cell-days) of 2023, median background |
csu/README.md |
0.005 MB | Markdown text | the provider’s README for Dryad version 8 (methods in brief, variable list) |
Raw variables
The table covers csu/krigedPM25_2023_v2_statfix_medianbk.nc. The other yearly files use the same grid and the same main variables (the research project checked all 40). The file has eight dimensions: lony (309), lonx (189), date (365 days), welony (310), welonx (189), nslony (309), nslonx (190), and nsites (2,228 monitor sites). nsites is the file’s unlimited (record) dimension; the others have a fixed length. Dimensions are listed in R’s order, as ncdf4 shows them; tools that use C order, such as ncdump, show them reversed, e.g., PM25(date, lonx, lony). Despite its name, lony runs roughly west to east and lonx south to north. Every variable is stored as a double except doy (integer). All except doy declare 1e30 as the missing-value code; no value in the 2023 file uses it. The file’s only global attribute, description, reads “Krigged PM2.5 concentrations from EPA AQS surface monitors for Jan-Dec 2023created: 2025-01-13” (sic). Each variable also carries units, long_name, and var_desc attributes with a short label (for example, HMS_Smoke has the var_desc “Binary HMS Smoke: 1 = smoke, 0 = no smoke”). Read them with ncatt_get(nc, "PM25"); the descriptions below agree with them.
| Variable | Dimensions | Units | Description |
|---|---|---|---|
doy |
date |
stored as “date”; really a day count | Day of year of each 24-hour average, counted from 0: 0 = January 1, 364 = December 31 |
lon |
lony, lonx |
degrees | Longitude of each cell’s center (negative = west of Greenwich), from −134.2 to −65.8. It changes along both dimensions because the grid is curvilinear (laid out on a projected map, not along lines of longitude and latitude) |
lat |
lony, lonx |
degrees | Latitude of each cell’s center, from 22.5 to 52.6 |
we_lon |
welony, welonx |
degrees | Longitude of the cell borders in the west–east direction: 310 points per row of 309 cells, one on each border between neighbors plus the two outer edges |
we_lat |
welony, welonx |
degrees | Latitude of the same west–east border points |
ns_lon |
nslony, nslonx |
degrees | Longitude of the cell borders in the north–south direction: 190 points per column of 189 cells |
ns_lat |
nslony, nslonx |
degrees | Latitude of the same north–south border points |
PM25 |
lony, lonx, date |
ug/m3 (µg/m³) |
24-hour average total PM2.5 concentration in micrograms per cubic meter of air (µg/m³), kriged from EPA AQS monitors. Kriging can undershoot below zero: 0.21% of 2023 cell-days are negative (minimum −26.9 µg/m³) |
Background_PM25 |
lony, lonx, date |
ug/m3 (µg/m³) |
Seasonal non-smoke background: the median (in this medianbk file; the mean in meanbk files) of PM25 over the cell’s days in the season with no HMS smoke polygon. Seasons are quarters: January–March, April–June, July–September, October–December. At least 15 non-smoke days are required; if a season has fewer, days just before and after it are added. One value per cell and quarter, repeated on every day (checked in 2023: no cell changes within a quarter) |
HMS_Smoke |
lony, lonx, date |
binary | 1 = an HMS smoke polygon (light, medium, or heavy density) lies over the cell that day; 0 = none. Whether the cell’s center or any part of the cell must be covered is not documented by the provider. A plume can be anywhere in the air column, not only at the surface. 33.8% of 2023 cell-days in the whole rectangle are 1 |
testing_sites_longitudes |
nsites |
degrees | Longitude of each EPA AQS monitor site used to test the kriging by leave-one-out cross-validation (LOOCV): each monitor is left out in turn, the map is re-estimated without it, and the prediction is compared with what the monitor measured. The README calls it testing_sites_longitude |
testing_sites_latitudes |
nsites |
degrees | Latitude of the same monitor sites (the README calls it testing_sites_latitude) |
r_squared |
nsites |
unitless | R² between LOOCV predictions and the site’s measurements (2023 median across sites: 0.88) |
mean_bias |
nsites |
ug m-3 (µg/m³) |
Mean bias of the LOOCV predictions at the site; the sign convention (predicted minus measured, or the reverse) is not documented by the provider |
mean_absolute_error |
nsites |
ug m-3 (µg/m³) |
Mean absolute error of the LOOCV predictions at the site (2023 median: 1.48 µg/m³) |
slope |
nsites |
unitless | Slope of a linear regression of LOOCV predictions and measurements at the site; which variable is on which side is not documented by the provider. Missing for 1 site in 2023 |
nobs |
nsites |
unitless | Number of daily monitor observations at the site available for LOOCV (1 to 365 in 2023) |
Cleaning steps
The script R/m07_csu.R makes these decisions:
- One year, a month at a time. The sample file holds 21 million cell-days in each of three arrays. The script reads one month at a time (about 1.8 million cell-days), so it needs about 1.5 to 1.7 GB of memory at most.
- Dates from
doy.doystarts at 0, so the date is January 1 plusdoy. - Smoke = HMS flag × (total − background). The file has no smoke variable; we compute it for every cell-day. Cell-days without a plume get exactly 0. Small negative values are kept, as the authors advise: they are noise around zero, and dropping or flooring them would push average smoke up.
- Population weights. People, not land, breathe smoke. Each of the 2020 block groups with residents goes to the grid cell whose center is nearest to its center of population, with distances measured in CONUS Albers (EPSG:5070). CONUS Albers is a flat map projection of the contiguous U.S. (“CONUS”) whose coordinates are in meters, so distances can be measured directly; module m00 explains CRS and EPSG codes. Block groups more than 25 km from every cell center are dropped; this only happens in Washington County, Maine, where the grid ends. A county’s weight on a cell is the share of the county’s people who live in it. The research project uses the same rule.
- County-day values are population-weighted means of the cell values: smoke, total PM2.5, background, and the HMS flag (which becomes the share of residents under a plume). The grid has no missing values, so no weights need to be adjusted for gaps.
- Rounding. PM2.5 values are rounded to 2 decimals and
hms_shareto 3. The check that smoke is 0 wheneverhms_shareis 0 runs on the unrounded values, because rounding can break it (see Checks and cautions). - A complete panel. The output has every county on every day of 2023 and no missing values.
The first chunk opens the file and lists its variables with their dimensions and units.
# The bundle ships one year of the CSU product: 2023, median background.
# nc_open() reads only the file's header (names, dimensions, units), so it is
# instant even though the file is about 500 MB.
csu_file <- file.path(raw_dir, "csu", "krigedPM25_2023_v2_statfix_medianbk.nc")
nc <- nc_open(csu_file)
# One row per variable: its dimensions, in the order R uses, and its units.
csu_vars <- tibble(
variable = names(nc$var),
dims = map_chr(nc$var, \(v) str_c(map_chr(v$dim, "name"), collapse = ", ")),
units = map_chr(nc$var, "units")
)
csu_varsNext, the grid. Each cell gets a number, and its center’s longitude and latitude come from the lon and lat matrices. The provider describes the cells as about 15 km wide; measured in CONUS Albers, neighboring centers are 16 km apart.
# The grid is curvilinear: cells are about 16 km apart on a projected map, so
# every cell has its own longitude and latitude, stored in two 309 x 189
# matrices. Careful with the names: the first dimension ("lony", 309 steps)
# runs roughly west to east, and the second ("lonx", 189 steps) south to north.
lon <- ncvar_get(nc, "lon")
lat <- ncvar_get(nc, "lat")
dim(lon)
# as.vector() reads a matrix column by column: cell 1 is lon[1, 1], cell 2 is
# lon[2, 1], and so on. The PM2.5 arrays are flattened the same way below, so
# cell numbers always line up.
cells <- tibble(cell = seq_along(lon), lon = as.vector(lon), lat = as.vector(lat))
# The rectangle covers more than the U.S.: ocean, southern Canada, and
# northern Mexico are in it too.
stopifnot(nrow(cells) == 309 * 189, !anyDuplicated(cells$cell), !anyNA(cells))
summarize(cells, across(c(lon, lat), list(min = min, max = max)))The dates come from doy, which starts at 0.
# doy ("day of year") starts at 0, not 1: day 0 is January 1. Adding it to
# January 1 gives the calendar date.
doy <- ncvar_get(nc, "doy")
dates <- as.Date("2023-01-01") + doy
stopifnot(length(dates) == 365, all(doy == 0:364),
max(dates) == as.Date("2023-12-31"))This function reads any run of consecutive days into a long tibble with one row per cell × day: cell, lon, lat, date, total PM2.5, background PM2.5, and the HMS flag. ncvar_get() returns a 3-D array, and as.vector() flattens it cell by cell within each day, which is why cell, lon, and lat repeat times and date repeats each.
# Read consecutive days for every cell into a tibble with one row per cell x
# day: the cell's number and center, the date, and the three daily values.
# `start` and `count` pick a slice of the 3-D array (lony, lonx, date): all
# cells (count -1 means "to the end") and only the days we ask for. One month
# is about 1.8 million rows; the whole year at once would be 21 million rows,
# more memory than we need. The function uses `cells` and `dates` from the
# chunks above.
read_csu_days <- function(nc, day_index) {
start <- c(1, 1, min(day_index))
count <- c(-1, -1, length(day_index))
get_var <- function(v) as.vector(ncvar_get(nc, v, start = start, count = count))
n_days <- length(day_index)
tibble(
cell = rep(cells$cell, times = n_days),
lon = rep(cells$lon, times = n_days),
lat = rep(cells$lat, times = n_days),
date = rep(dates[day_index], each = nrow(cells)),
total_pm25 = get_var("PM25"),
background_pm25 = get_var("Background_PM25"),
hms = get_var("HMS_Smoke")
)
}Before averaging to counties, look at one month of cell-days and compute smoke.
# Look at one month first: June 2023, when smoke from fires in Quebec reached
# the Northeast. Smoke PM2.5 is total PM2.5 minus the seasonal background, but
# only on cell-days that lie under an HMS smoke plume (hms = 1); on all other
# cell-days it is exactly 0.
june <- read_csu_days(nc, which(month(dates) == 6)) |>
mutate(smoke_pm25 = hms * (total_pm25 - background_pm25))
# The grid has no missing values, and hms is always 0 or 1.
stopifnot(nrow(june) == nrow(cells) * 30, !anyNA(june), all(june$hms %in% c(0, 1)))
# Under a plume, total PM2.5 can be below the background, which gives a small
# negative smoke value. The authors advise keeping these values: they are
# noise around zero, and dropping them would bias smoke upward.
june |>
filter(hms == 1) |>
summarize(cell_days = n(), mean_smoke = mean(smoke_pm25),
share_negative = mean(smoke_pm25 < 0), max_smoke = max(smoke_pm25))A map makes the grid concrete. It also shows where the grid ends: its eastern edge cuts through eastern Maine.
# Smoke PM2.5 on June 7, 2023, the day New York City's sky turned orange. Each
# square is one grid cell; the scale stops at 100 µg/m³ so that moderate
# smoke stays visible (darker cells can be far above 100).
states <- read_sf(file.path(clean_dir, "us_states_2020.gpkg")) |>
filter(contiguous)
june |>
filter(date == as.Date("2023-06-07")) |>
ggplot() +
geom_point(aes(lon, lat, color = smoke_pm25), shape = 15, size = 0.5) +
geom_sf(data = states, fill = NA, color = "grey30", linewidth = 0.2) +
scale_color_viridis_c(option = "inferno", direction = -1, limits = c(0, 100),
oob = scales::squish, name = "Smoke PM2.5\n(µg/m³)") +
coord_sf(xlim = c(-125, -66.5), ylim = c(24.5, 49.5)) +
labs(x = NULL, y = NULL)Now the population weights. This chunk finds the nearest grid cell for every populated block group and measures the distance.
# Population weights. People, not land, breathe the smoke, so a county's value
# should average the cells where its people live. Each block group (from
# module m00) goes to the grid cell whose center is nearest to the block
# group's center of population. Block groups with no residents get no weight.
bg_centers <- read_csv(
file.path(clean_dir, "census_bg_centers_2020.csv"),
col_types = cols(bg_geoid = col_character(), GEOID = col_character(),
pop_2020 = col_double(), lat = col_double(), lon = col_double())
) |>
filter(pop_2020 > 0)
# Distances need a projected CRS, one with coordinates in meters (see m00 for
# CRS and EPSG codes). We use CONUS Albers (EPSG:5070): a flat map of the
# contiguous U.S. ("CONUS") on which distances can be measured directly.
bg_points <- bg_centers |>
st_as_sf(coords = c("lon", "lat"), crs = 4326) |>
st_transform(5070)
cell_points <- cells |>
st_as_sf(coords = c("lon", "lat"), crs = 4326) |>
st_transform(5070)
nearest <- st_nearest_feature(bg_points, cell_points)
bg_cells <- bg_centers |>
mutate(
cell = cells$cell[nearest],
dist_km = as.numeric(st_distance(bg_points, cell_points[nearest, ],
by_element = TRUE)) / 1000
)
# Inside the grid, no point is more than about 11 km (half the diagonal of a
# cell) from its nearest center.
summary(bg_cells$dist_km)Block groups more than 25 km from a cell center are dropped, and the rest become county weights.
# The grid ends at about 67 degrees west, so the eastern edge of Washington
# County, Maine (GEOID 23029), lies outside it. A cell center more than 25 km
# away does not describe the air there, so those block groups are dropped
# (the research project uses the same 25 km rule). The county keeps the
# weight of its other block groups.
bg_cells |>
filter(dist_km > 25) |>
summarize(block_groups = n(), pop_dropped = sum(pop_2020), .by = GEOID)
# A county's weight on a cell = share of the county's people who live in it.
county_weights <- bg_cells |>
filter(dist_km <= 25) |>
summarize(pop = sum(pop_2020), .by = c(GEOID, cell)) |>
mutate(weight = pop / sum(pop), .by = GEOID)
# Grain: one row per county x cell. Every one of the 3,108 counties has
# weights, and its weights add up to 1.
weight_sums <- county_weights |> summarize(total = sum(weight), .by = GEOID)
stopifnot(
nrow(weight_sums) == 3108, all(abs(weight_sums$total - 1) < 1e-9),
!anyDuplicated(select(county_weights, GEOID, cell))
)
# Number of cells per county: from 1 (small counties) to dozens (big ones).
count(county_weights, GEOID, name = "cells") |> pull(cells) |> summary()The main step: for each month, compute smoke for every cell-day, join the weights, and take population-weighted means by county and day. It takes about a minute.
# Average the grid to counties one month at a time to keep memory low. The
# join is many-to-many on purpose: a cell appears once per day, and one cell
# can serve several counties. The county value is the population-weighted
# mean over the county's cells. Note that county smoke averages the cell
# smoke values; it is not hms_share x (total - background).
county_days_for_month <- function(m) {
read_csu_days(nc, which(month(dates) == m)) |>
mutate(smoke_pm25 = hms * (total_pm25 - background_pm25)) |>
inner_join(county_weights, by = "cell", relationship = "many-to-many") |>
summarize(
smoke_pm25 = weighted.mean(smoke_pm25, weight),
total_pm25 = weighted.mean(total_pm25, weight),
background_pm25 = weighted.mean(background_pm25, weight),
hms_share = weighted.mean(hms, weight),
.by = c(GEOID, date)
)
}
csu_county_day <- map(1:12, county_days_for_month) |> list_rbind()
nc_close(nc)Round, check the grain, and write the file.
# Read only the columns we need, all as character: FIPS codes keep their
# leading zeros only when read as text.
counties <- read_csv(file.path(clean_dir, "counties_2020.csv"),
col_types = cols_only(GEOID = col_character(),
county_name = col_character(),
state_abbr = col_character()))
csu_sample <- csu_county_day |>
transmute(
GEOID, date,
smoke_pm25 = round(smoke_pm25, 2),
total_pm25 = round(total_pm25, 2),
background_pm25 = round(background_pm25, 2),
hms_share = round(hms_share, 3)
) |>
arrange(GEOID, date)
# Grain: one row per county x day, 3,108 counties x 365 days, with no gaps and
# no missing values; hms_share is a share between 0 and 1. In 2023 the
# background is always positive (not in every year: see the cautions).
# Smoke is exactly 0 when no resident is under a plume. We check this on the
# unrounded values: after rounding, a tiny hms_share (below 0.0005) shows as 0
# while smoke can round to 0.01.
stopifnot(
nrow(csu_sample) == 3108 * 365,
!anyDuplicated(select(csu_sample, GEOID, date)),
setequal(csu_sample$GEOID, counties$GEOID),
setequal(csu_sample$date, dates),
!anyNA(csu_sample),
all(csu_county_day$smoke_pm25[csu_county_day$hms_share == 0] == 0),
all(csu_sample$background_pm25 > 0),
all(between(csu_sample$hms_share, 0, 1))
)
write_csv(csu_sample, file.path(clean_dir, "csu_sample_county_day_2023.csv.gz"), na = "")Finally, a first look at the result. The last table shows why a zero in smoke_pm25 is not the same as a day without a plume.
# The five county-days with the most smoke in 2023, with county names.
csu_sample |>
slice_max(smoke_pm25, n = 5) |>
left_join(select(counties, GEOID, county_name, state_abbr), by = "GEOID")
# How common are smoke days, and how often is smoke slightly negative?
csu_sample |>
summarize(
mean_smoke = mean(smoke_pm25),
# smoke day = more than half of residents under an HMS plume
# (hms_share > 0.5), as in the project
share_smoke_days = mean(hms_share > 0.5),
share_negative = mean(smoke_pm25 < 0),
min_smoke = min(smoke_pm25)
)
# smoke_pm25 = 0 does not always mean "no plume". On some plume days the
# value rounds to 0.00 for one of two reasons: (a) the plume covers the
# county, but its total PM2.5 is about equal to its background; or (b) only
# a tiny share of residents is under the plume, so averaging in the zeros
# of everyone else pulls the county value to about 0. To find days without
# a plume, use hms_share, not smoke_pm25 == 0.
csu_sample |>
filter(smoke_pm25 == 0) |>
count(plume = case_when(hms_share == 0 ~ "none (hms_share = 0)",
hms_share == 1 ~ "whole county",
.default = "part of the county"))Clean files
The first file is written by the code above. The other two come from the instructor export described in the appendix; they use the same method for every year from 2006 to 2025. All three use the median background.
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
csu_sample_county_day_2023.csv.gz |
1,134,420 | a county × day in 2023 (3,108 × 365), built here from the raw 2023 file | 7.08 MB (36.9 MB uncompressed) |
csu_smoke_county_day/csu_smoke_county_day_<year>.csv.gz, 20 files for 2006–2025 |
1,134,420 per year (1,137,528 in leap years); 22,703,940 in all | a county × day (instructor export) | 5.9–7.1 MB each (about 37 MB uncompressed); 124.8 MB in all |
csu_smoke_county_month_2006_2025.csv |
745,920 | a county × month, January 2006 to December 2025 (3,108 × 240; instructor export) | 41.07 MB |
Clean variables
csu_sample_county_day_2023.csv.gz and the yearly csu_smoke_county_day_<year>.csv.gz files share these columns. Rows are sorted by GEOID and date, and no cell is missing. Read a file with read_csv() directly; it unzips .gz files on the fly.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (state + county), 2020 boundaries; joins to counties_2020.csv |
date |
date | — | Day of the 24-hour average (from the raw doy) |
smoke_pm25 |
dbl | µg/m³ | Smoke PM2.5: population-weighted mean over the county’s cells of HMS_Smoke × (PM25 − Background_PM25). Exactly 0 when no resident lives under a plume (hms_share = 0): a true zero, not a missing value. The reverse does not hold: it can also be 0.00 on plume days (1,103 county-days in 2023). Because hms_share is rounded to 3 decimals, 7 county-days in 2006–2025 show hms_share = 0 with smoke_pm25 = 0.01. Can be negative; keep those values. 2 decimals |
total_pm25 |
dbl | µg/m³ | Total PM2.5: population-weighted mean of the kriged PM25, smoke and non-smoke together. Rarely negative, where kriging undershoots (516 county-days in 2023; 217 to 2,478 per year). 2 decimals |
background_pm25 |
dbl | µg/m³ | Non-smoke background: population-weighted mean of the seasonal median Background_PM25. Constant within each quarter, so it changes only on January 1, April 1, July 1, and October 1. Positive except in July–September 2016 in 11 counties of North Dakota and Minnesota (minimum −0.69) and July–September 2022 in Douglas County, Wisconsin (−0.35): kriged PM2.5 can be negative, and so can its median. 2 decimals |
hms_share |
dbl | share, 0 to 1 | Share of the county’s 2020 population living in grid cells under an HMS smoke plume that day (population-weighted mean of HMS_Smoke). 3 decimals, so a share below 0.0005 shows as 0 (31 to 70 county-days per year). The project calls a county-day a smoke day when hms_share > 0.5 |
csu_smoke_county_month_2006_2025.csv: one row per county × month, sorted by GEOID, year, and month, no missing cells. Monthly values were computed by the research project from unrounded daily values, then rounded. read_csv() reads year, month, and the counts as dbl and csu_release as a date.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code, 2020 boundaries |
year |
dbl | — | Calendar year, 2006 to 2025 |
month |
dbl | — | Calendar month, 1 (January) to 12 (December) |
smoke_pm25 |
dbl | µg/m³ | Monthly mean of daily smoke PM2.5 (the project’s monthly smoke measure, S). 2 decimals |
total_pm25_mean |
dbl | µg/m³ | Monthly mean of daily total PM2.5. 2 decimals |
smoke_max |
dbl | µg/m³ | Highest daily smoke PM2.5 in the month. 2 decimals |
hms_days |
dbl | days | Days with hms_share > 0.5 (most residents under a plume) |
days_gt5 |
dbl | days | Days with daily smoke PM2.5 > 5 µg/m³ |
days_gt10 |
dbl | days | Days with daily smoke PM2.5 > 10 µg/m³ |
days_gt15 |
dbl | days | Days with daily smoke PM2.5 > 15 µg/m³ |
days_gt35 |
dbl | days | Days with daily smoke PM2.5 > 35 µg/m³ (35 µg/m³ is the EPA 24-hour standard for total PM2.5; here only the smoke part is counted) |
n_days_obs |
dbl | days | Days in the month with a value; equals the number of days in the month in every row |
complete_month |
lgl | — | TRUE when every day of the month has a value; TRUE in every row |
csu_release |
date | — | Release: the date Dryad published the file behind the month: 2025-03-21 for 2006–2023, 2026-02-25 for 2024, 2026-08-06 for 2025. This is not the date CSU made the file: the NetCDF records its creation date in its description attribute (2025-01-13 for 2006–2023, 2025-10-22 for 2024, 2026-07-30 for 2025) |
Checks and cautions
- The sample matches the research project. We compared
csu_sample_county_day_2023.csv.gzwith the project’s population-weighted, median-background county-day values for 2023 (built by its script05g_csu.R). All 1,134,420 county-days are in both files. The correlation is 1.0000 for all four variables. Mean absolute differences are 0.0012 µg/m³ (smoke), 0.0025 µg/m³ (total), 0.0025 µg/m³ (background), and 0.000006 (hms_share), and the largest is 0.005, which is rounding. Every value equals the project’s value rounded the same way. The method is the same: nearest cell center in EPSG:5070, the 25 km cut, and population shares from the same 2020 block groups. The project also re-weights over non-missing cells, which changes nothing because the grid has no gaps. The sample is identical, cell by cell, tocsu_smoke_county_day_2023.csv.gzfrom the instructor export. - Negative smoke is normal; keep it. On a plume day, total PM2.5 can be below the background (for example, when the plume stays aloft and the air at the ground is cleaner than usual). In 2023, 19% of plume cell-days are negative (minimum −31.7 µg/m³), and after averaging to counties 6.8% of county-days are negative (minimum −18.18 µg/m³). The share of negative county-days runs from 1.4% (2006) to 7.9% (2024). Do not drop them or set them to zero: that would bias smoke upward.
- What zero means. When
hms_share= 0,smoke_pm25= 0. This is a true zero, not a missing value: no resident of the county lived under an HMS plume that day, as on 53.7% of 2023 county-days. The reverse does not hold.smoke_pm25can round to 0.00 on plume days: 1,103 county-days in 2023, for two reasons. (a) The plume covers the county, but its total PM2.5 is about equal to its background: 453 of the 1,103 havehms_share= 1 (for example, Coosa County, Alabama, on 2023-05-30, where total and background are both 6.92 µg/m³). (b) Only a tiny share of residents is under the plume, so the zeros of everyone else pull the county average to about 0: of the other 650, 72% havehms_sharebelow 0.05 (median 0.014). Every year from 2006 to 2025 has 354 to 1,219 such county-days. To find days without a plume, usehms_share, notsmoke_pm25 == 0. Rounding also works the other way: a county with a tiny share of residents under a plume (below 0.0005) showshms_share= 0, and on 7 such county-days in 2006–2025 (for example, San Diego County on 2007-11-01)smoke_pm25is 0.01. When only part of the county is under a plume (2.3% of 2023 county-days havehms_sharestrictly between 0 and 1),smoke_pm25averages the smoke of the covered cells with zeros for the rest. County smoke is therefore nothms_share× (total_pm25−background_pm25). - 2023 in numbers. Mean county-day smoke PM2.5 is 2.39 µg/m³, the highest of 2006–2025 (the yearly mean ranges from 0.11 in 2009 to 2.39 in 2023). 45% of county-days have
hms_share> 0.5. The largest county-day value is 237.70 µg/m³ (Spokane County, Washington, on 2023-08-19); on 2023-06-07, Lehigh County, Pennsylvania, reached 229 µg/m³. - Smoke from where? CSU smoke counts smoke from all fires: Canadian, western U.S., prescribed burns, and farm fires. It cannot separate them. The project links smoke to Canadian fires with fire detections and winds from other modules.
- Median or mean background. This bundle uses the median background, as the instructor chose. The provider recommends the mean background for most uses, and the research project’s current design uses the mean for its headline estimates and the median as a robustness check. Smoke is higher with the median background: across 2006–2025 the average county-month smoke is 0.60 µg/m³ with the median and 0.52 µg/m³ with the mean.
- Kriging smooths, and it comes from the monitors. In rural areas far from monitors, values are interpolated from distant monitors and will miss local smoke. In 2023, the provider’s leave-one-out test at 2,228 monitor sites had a median R² of 0.88 and a median mean absolute error of 1.48 µg/m³. The project compared county-day CSU total PM2.5 with the county’s EPA monitor readings on the 2,884,953 county-days with a monitor (2006–2025): the correlation is 0.967 (0.974 on smoke days, 0.982 in 2023). This is a check of the build, not an independent test, because CSU is made from those same monitors.
- Where the grid ends. The grid’s eastern edge runs through Washington County, Maine (GEOID 23029). 19 block groups there, with 17,089 of the county’s 31,095 residents (55%), are more than 25 km from every cell center and get no weight, so that county’s values describe its western part only. Elsewhere every populated block group is within 11.5 km of a cell center.
- What a day is. The provider does not say which clock defines a day. EPA’s daily monitor averages, which the kriging starts from, cover the local calendar day at each monitor. Keep this in mind when you match CSU days to data stamped in UTC (Coordinated Universal Time, the clock of most satellite products).
- Releases. Years 2006–2023 come from the CSU release of 2025-03-21, 2024 from the release of 2026-02-25, and 2025 from the release of 2026-08-06 (all in Dryad version 8). These are the dates Dryad published the files. CSU made the files earlier: the NetCDF
descriptionattribute gives 2025-01-13 for 2006–2023, 2025-10-22 for 2024, and 2026-07-30 for 2025. The 2006–2023 files were made in one batch; 2024 and 2025 were separate, later runs, and the provider does not say whether anything in the method changed between them. Be careful when you compare 2024 or 2025 with earlier years. A later release could also revise recent years. - Monthly versus daily files. Monthly values were computed from unrounded daily values. If you rebuild them from the rounded daily files, means differ by at most 0.006 µg/m³, and
hms_dayscan differ in rare cases (0.02% of county-months in our test of 2006, 2023, and 2025), whenhms_shareis within rounding of 0.5. - Memory and time.
R/m07_csu.Rruns in about 1 to 1.5 minutes, depending on how busy the computer is, with a peak memory use of about 1.5 to 1.7 GB. Reading the whole year at once instead of a month at a time would need several GB. If you run the script from the Terminal withRscript R/m07_csu.Rinstead of in RStudio, R saves the map to a file calledRplots.pdfin your project folder; you can delete it.
Childs et al. smoke PM2.5 (Stanford ECHO Lab)
This file estimates smoke PM2.5 for every county and day: the part of fine particulate matter (particles smaller than 2.5 micrometers, PM2.5) that comes from wildfire smoke, in micrograms per cubic meter (µg/m³). Marissa Childs, Marshall Burke, and colleagues at Stanford’s Environmental Change and Human Outcomes (ECHO) Lab built it. A 10 km grid cell has a smoke day when a smoke plume drawn by NOAA’s Hazard Mapping System (HMS) from satellite images touches it. When clouds may hide plumes, it also has one if most of its satellite aerosol readings are missing and many simulated air trajectories from fires (from NOAA’s HYSPLIT model) pass low overhead. At EPA air monitors, smoke PM2.5 on a smoke day is the PM2.5 above the monitor’s usual level on non-smoke days; a machine-learning model (gradient-boosted trees) trained on satellite, weather, and fire data predicts that amount for every smoke-day grid cell, and the authors average the cells to counties, weighting by population and area. The method described here is from the 2022 paper (v1.0); v2.0 beta extends it to 2023 and may differ in details.
For this project it is the main daily measure of smoke exposure in U.S. counties from 2006 to 2023, built the same way for every county, so you can count smoke days and compare smoke across places; compare years with care (see “Comparing years” under “Checks and cautions”). It cannot tell you where the smoke came from: Canadian and U.S. fires are pooled. It is a model estimate, not a measurement, it says nothing about non-smoke pollution, and it stops at the end of 2023.
| Provider | Stanford Environmental Change and Human Outcomes (ECHO) Lab (Childs et al.). Version 2.0 beta, file dated 2024-09-04, downloaded on 2026-09-27 from the lab’s Dropbox folder |
| Coverage | 1 January 2006 – 31 December 2023 · contiguous U.S., 3,108 counties (DC included) |
| Raw unit | one row = a county on a smoke day (non-smoke days are left out) |
| Clean unit | one row = a county-day, every day (daily files); a county-month (monthly file) |
| Licence and citation | The provider states no licence for the v2.0 beta files and calls them “preliminary and subject to change”; the v1.0 release on Harvard Dataverse is CC BY-SA 4.0. Cite both papers and name the version (v2.0 beta, file of 2024-09-04). For v2.0 beta, the ECHO Lab page names this source (in review): Childs, M., Martins, M., Wilson, A., Qiu, M., Heft-Neal, S., and Burke, M. “Growing wildfire-derived PM2.5 across the contiguous U.S. and implications for air quality regulation.” EarthArXiv preprint, doi:10.31223/X57H95 (version 1, 9 December 2024; version 2, 18 August 2025). For the method (v1.0): Childs, M. L., Li, J., Wen, J., Heft-Neal, S., Driscoll, A., Wang, S., Gould, C. F., Qiu, M., Burney, J., and Burke, M. (2022). “Daily Local-Level Estimates of Ambient Wildfire Smoke PM2.5 for the Contiguous US.” Environmental Science & Technology 56(19): 13607–13621, doi:10.1021/acs.est.2c02934 |
| Documentation | ECHO Lab wildfire smoke page; README__v2.0beta_20221104.txt in the raw folder; Childs et al. v2.0 preprint (EarthArXiv); Childs et al. (2022), Methods section “Defining Smoke Days”; code repository and README; county aggregation script; v1.0 on Harvard Dataverse |
Raw files
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
childs/v2.0beta/smokePM2pt5_predictions_daily_county_20060101-20231231__v2.0beta_20240904.csv |
120.5 MB | comma-separated text, ASCII, header row; GEOID and date in double quotes |
a county on a smoke day (3,317,195 rows, 3,108 counties) |
childs/v2.0beta/README__v2.0beta_20221104.txt |
0.003 MB | plain text | the provider’s notes on the county file (no data) |
The research project also holds version 1.0 (2006–2020, from Harvard Dataverse). It is not in the student bundle, because the two versions are different model runs and must not be mixed (see “Checks and cautions”).
Raw variables
The CSV file has three columns. The README file has no variables.
| Variable | Type | Description |
|---|---|---|
GEOID |
text in double quotes, 5 characters | County identifier (GEOID): 2-digit state FIPS code + 3-digit county FIPS code, e.g., 36051 = Livingston County, New York. The README says it matches the GEOID column of the 2019 TIGER/Line county shapefile for the contiguous U.S. |
date |
text in double quotes, 8 digits (YYYYMMDD) |
Day of the prediction, e.g., 20230607 = 7 June 2023. The README does not say whether days follow local time or UTC (Coordinated Universal Time, the clock that satellites and weather models use). The model is trained on EPA daily averages, which use local calendar days, and its smoke days come from HMS’s daily plume analysis. Treat date as a local calendar day. It may be off by one day from UTC-day products such as ERA5 or EMBER |
smokePM_pred |
decimal number | Predicted daily smoke PM2.5 in µg/m³ on a smoke day. A county-day is in the file if any 10 km cell overlapping the county has a smoke day. The county value is the average over all overlapping 10 km cells, weighted by population and area of overlap; cells without smoke count as 0 in that average. A value of 0 means the county-day was a smoke day but had no elevated PM2.5. Days that are not in the file are non-smoke days, and their smoke PM2.5 is 0 by construction |
Cleaning steps
The script R/m05_childs.R makes these decisions:
- Read the keys with the right types.
GEOIDis read as text so the leading zero of codes such as01001survives. The date is text like20060125; without a format, readr would read it as the number 20060125. - Check the county lists. The raw file has exactly the 3,108 counties of
counties_2020.csv: no extra county and no county without rows. The script still keeps only universe counties, so it would work if the lists differed. - Absence means zero. The raw file lists smoke days only. The README says a county-day that is not in the file is a non-smoke day with smoke PM2.5 = 0.
complete()adds those days and fills them with 0, andsmoke_dayrecords which rows came from the raw file. If you average only the raw rows, you average smoke days only and overstate smoke exposure (six times over for 2006–2023). - One year at a time. A complete year is 3,108 × 365 ≈ 1.1 million rows. All 18 years at once would be 20.4 million rows, so the script builds and writes one year per step.
- Monthly summaries from unrounded values. Monthly means include the zeros, so they divide by the number of days in the month. Day counts use strict thresholds (> 5, > 15, > 35 µg/m³). These echo the WHO annual guideline (5 µg/m³), the WHO 24-hour guideline (15 µg/m³), and the U.S. EPA 24-hour standard (35 µg/m³) for total PM2.5; here they apply to the smoke part alone.
- Round for storage. PM2.5 values are stored with 2 decimals. Each daily file is about 29 MB as plain text, so it is gzipped (
.csv.gz);read_csv()reads it directly.
The first chunk reads the raw file with explicit column types and gives the value column a clearer name.
# The provider's file has one row per county per SMOKE day only. GEOID is
# quoted text in the file ("01001"), but we still declare it as character so
# the leading zero can never be lost. The date is stored as text such as
# "20060125" (YYYYMMDD). Left to guess, readr would read it as the number
# 20060125, so we give the format and get a real Date instead.
childs_file <- file.path(
raw_dir, "childs", "v2.0beta",
"smokePM2pt5_predictions_daily_county_20060101-20231231__v2.0beta_20240904.csv"
)
childs_raw <- read_csv(
childs_file,
col_types = cols(
GEOID = col_character(),
date = col_date(format = "%Y%m%d"),
smokePM_pred = col_double()
)
) |>
rename(smoke_pm25 = smokePM_pred)
childs_rawBefore building on the file, check what the README promises: one row per county-day, dates inside 2006–2023, and no missing or negative values. The count by year shows that the file has many more smoke county-days after 2020 (see “Comparing years” under “Checks and cautions”).
# Check what the README promises before we build on it: one row per
# county-day, dates inside 2006-2023, no missing values (a date that failed
# to parse would show up as NA), and no negative smoke PM2.5.
stopifnot(
!anyNA(childs_raw),
all(str_detect(childs_raw$GEOID, "^[0-9]{5}$")),
min(childs_raw$date) >= ymd("2006-01-01"),
max(childs_raw$date) <= ymd("2023-12-31"),
min(childs_raw$smoke_pm25) >= 0,
nrow(distinct(childs_raw, GEOID, date)) == nrow(childs_raw)
)
# Smoke-day rows per year. The file gets much longer after 2020: many more
# county-days count as smoke days (2023 is the big Canadian smoke year).
childs_raw |>
count(year = year(date), name = "smoke_county_days")Next, compare the raw counties with the county universe in both directions. Both lists below are empty: the 3,108 counties match exactly.
# Our county universe is the 3,108 contiguous-U.S. counties plus DC in
# counties_2020.csv. Compare the two lists in both directions before joining.
counties <- read_csv(
file.path(clean_dir, "counties_2020.csv"),
col_types = cols(.default = col_character()),
col_select = c(GEOID, county_name, state_abbr)
)
raw_geoids <- childs_raw |> distinct(GEOID)
# In the raw file but not in our universe: these rows would be dropped.
raw_geoids |> anti_join(counties, by = "GEOID")
# In our universe but never in the raw file: these counties would get 0 smoke
# on every day, so we want to know if there are any.
counties |> anti_join(raw_geoids, by = "GEOID")
# Keep only smoke days of counties in the universe.
childs_smoke <- childs_raw |> semi_join(counties, by = "GEOID")
nrow(childs_smoke)This chunk shows why the missing days matter. In Livingston County, New York, 2023 had 162 smoke days. Their mean is 6.73 µg/m³, but the mean over all 365 days is 2.99 µg/m³. Over all counties and days of 2006–2023, the raw rows average 3.80 µg/m³ and the complete panel 0.62 µg/m³.
# KEY IDEA: a county-day that is missing from the raw file is NOT missing
# data. The README says those days had no smoke, so smoke PM2.5 = 0 there.
# A mean over the raw rows is a mean over smoke days only, and it overstates
# how much smoke people breathed over a month or a year.
# Example: Livingston County, NY (GEOID 36051, home of SUNY Geneseo), 2023.
livingston_2023 <- childs_smoke |>
filter(GEOID == "36051", year(date) == 2023)
tibble(
smoke_days = nrow(livingston_2023),
mean_over_raw_rows = mean(livingston_2023$smoke_pm25),
mean_over_all_365_days = sum(livingston_2023$smoke_pm25) / 365
)
# The same comparison for the whole file: 3,108 counties x 6,574 days.
all_dates <- seq(ymd("2006-01-01"), ymd("2023-12-31"), by = "day")
tibble(
mean_over_raw_rows = mean(childs_smoke$smoke_pm25),
mean_over_all_county_days = sum(childs_smoke$smoke_pm25) /
(nrow(counties) * length(all_dates))
)The next chunk defines a function that builds the complete panel for one year with complete().
# complete() adds every county-day that is missing from the raw file and
# fills it with 0 smoke PM2.5, as the README tells us to. smoke_day records
# whether the row came from the raw file (TRUE) or was added (FALSE).
# We work one year at a time: a year is 3,108 x 365 = 1.1 million rows,
# while all 18 years at once would be 20.4 million rows.
complete_year <- function(yr) {
year_dates <- seq(make_date(yr, 1, 1), make_date(yr, 12, 31), by = "day")
childs_smoke |>
filter(year(date) == yr) |>
mutate(smoke_day = TRUE) |>
complete(
GEOID = counties$GEOID,
date = year_dates,
fill = list(smoke_pm25 = 0, smoke_day = FALSE)
) |>
arrange(GEOID, date)
}A second function turns one year of the complete daily panel into county-month summaries.
# Monthly summaries of one year of the COMPLETE daily panel. The zeros are
# already in, so mean() divides by the number of days in the month, not by
# the number of smoke days. We use the unrounded daily values here.
summarize_months <- function(day_panel) {
day_panel |>
mutate(year = year(date), month = month(date)) |>
summarize(
smoke_mean = mean(smoke_pm25),
smoke_max = max(smoke_pm25),
smoke_days = sum(smoke_day),
days_gt5 = sum(smoke_pm25 > 5),
days_gt15 = sum(smoke_pm25 > 15),
days_gt35 = sum(smoke_pm25 > 35),
.by = c(GEOID, year, month)
)
}Try the function on 2023 and check it before running all years. In 2023, 47.8% of county-days were smoke days, and the mean smoke PM2.5 over all county-days was 2.45 µg/m³.
# Try the function on 2023, the year of the big Canadian fires.
day_2023 <- complete_year(2023)
day_2023
# Every county has all 365 days, the TRUE rows are exactly the raw rows,
# and every added row has 0 smoke.
stopifnot(
nrow(day_2023) == nrow(counties) * 365,
sum(day_2023$smoke_day) == sum(year(childs_smoke$date) == 2023),
all(day_2023$smoke_pm25[!day_2023$smoke_day] == 0)
)
# Share of county-days with smoke in 2023, and the mean over all county-days.
day_2023 |>
summarize(share_smoke_days = mean(smoke_day), mean_smoke_pm25 = mean(smoke_pm25))The loop builds, checks, and writes one daily file per year with map(), and keeps each year’s monthly summary. It takes a minute or two.
# Build, check, and write one file per year. Each file is about 29 MB as
# plain text, so we gzip it (.csv.gz); read_csv() reads it directly.
# The loop also returns each year's monthly summary for the next chunk.
day_dir <- file.path(clean_dir, "childs_smoke_county_day")
dir.create(day_dir, recursive = TRUE, showWarnings = FALSE)
childs_month <- map(2006:2023, \(yr) {
day_panel <- complete_year(yr)
# Grain: one row per county x day, for every county and every day.
stopifnot(
nrow(day_panel) == nrow(counties) * yday(make_date(yr, 12, 31)),
nrow(distinct(day_panel, GEOID, date)) == nrow(day_panel),
!anyNA(day_panel),
sum(day_panel$smoke_day) == sum(year(childs_smoke$date) == yr)
)
day_panel |>
mutate(smoke_pm25 = round(smoke_pm25, 2)) |>
write_csv(file.path(day_dir, str_glue("childs_smoke_county_day_{yr}.csv.gz")), na = "")
message("wrote ", yr)
summarize_months(day_panel)
}) |>
list_rbind()The last writing step rounds the monthly values, checks the grain and the logic of the day counts, and writes the monthly file.
# Round only now, after every summary was computed from unrounded values.
childs_month <- childs_month |>
mutate(across(c(smoke_mean, smoke_max), \(x) round(x, 2))) |>
arrange(GEOID, year, month)
# Grain: one row per county x month, 3,108 counties x 216 months, no gaps.
# Every raw smoke day is counted exactly once, and the day counts nest:
# days > 35 <= days > 15 <= days > 5 <= smoke days <= days in the month.
stopifnot(
nrow(childs_month) == nrow(counties) * 18 * 12,
nrow(distinct(childs_month, GEOID, year, month)) == nrow(childs_month),
!anyNA(childs_month),
sum(childs_month$smoke_days) == nrow(childs_smoke),
all(childs_month$smoke_mean <= childs_month$smoke_max),
all(childs_month$days_gt35 <= childs_month$days_gt15),
all(childs_month$days_gt15 <= childs_month$days_gt5),
all(childs_month$days_gt5 <= childs_month$smoke_days),
all(childs_month$smoke_days <=
days_in_month(make_date(childs_month$year, childs_month$month)))
)
write_csv(childs_month, file.path(clean_dir, "childs_smoke_county_month_2006_2023.csv"), na = "")Finally, this chunk shows how to read the daily files back: pick the years you need and give the column types.
# How to read the daily files later: list them, keep the years you need,
# and give col_types so GEOID stays text and smoke_day stays TRUE/FALSE.
# All 18 years together are 20.4 million rows, so read only what you need.
day_files <- list.files(day_dir, pattern = "\\.csv\\.gz$", full.names = TRUE)
childs_day_2021_2023 <- day_files |>
str_subset("_202[1-3]\\.csv\\.gz$") |>
read_csv(col_types = cols(
GEOID = col_character(),
date = col_date(),
smoke_pm25 = col_double(),
smoke_day = col_logical()
))
stopifnot(
length(day_files) == 18,
nrow(childs_day_2021_2023) == nrow(counties) * (365 + 365 + 365),
sum(childs_day_2021_2023$smoke_day) == sum(year(childs_smoke$date) >= 2021)
)Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
childs_smoke_county_day/childs_smoke_county_day_<year>.csv.gz (18 files, 2006–2023) |
1,134,420 per year (1,137,528 in the leap years 2008, 2012, 2016, 2020); 20,431,992 in total | a county-day | 3.0–4.3 MB each (about 29 MB unzipped); 61.0 MB in total |
childs_smoke_county_month_2006_2023.csv |
671,328 (3,108 counties × 216 months) | a county-month | 19.5 MB |
Clean variables
All 18 daily files share these columns. Rows are sorted by GEOID and date. Every county has every day of the year, and no cell is missing.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (state + county). The provider aggregated to 2019 TIGER/Line counties, and their codes are identical to the 3,108 GEOIDs in counties_2020.csv |
date |
date | — | Day of the prediction (YYYY-MM-DD), from the raw date. Treat it as a local calendar day; it may be off by one day from UTC-day products |
smoke_pm25 |
dbl | µg/m³ | Daily smoke PM2.5, 2 decimals. On a smoke day it is the raw smokePM_pred; on every other day it is 0, as the README instructs. It can be 0.00 on a smoke day |
smoke_day |
lgl | — | TRUE if the county-day is in the raw file (a smoke day for the provider); FALSE if the row was added by complete(). A county-day is in the raw file if any 10 km cell overlapping the county has a smoke day, so TRUE means smoke somewhere in the county, not everywhere in it. Cells without smoke count as 0 in the population-and-area weighted mean behind smoke_pm25 |
childs_smoke_county_month_2006_2023.csv: one row per county-month, sorted by GEOID, year, and month. Every county has all 216 months, and no cell is missing. All values come from the unrounded daily values.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (state + county). The provider aggregated to 2019 TIGER/Line counties, and their codes are identical to the 3,108 GEOIDs in counties_2020.csv |
year |
dbl | — | Calendar year, 2006–2023 |
month |
dbl | — | Calendar month, 1–12 |
smoke_mean |
dbl | µg/m³ | Mean daily smoke PM2.5 over all days of the month, zeros on non-smoke days included (the sum over smoke days divided by the number of days in the month), 2 decimals |
smoke_max |
dbl | µg/m³ | Highest daily smoke PM2.5 in the month, 2 decimals; 0 if the month has no smoke day |
smoke_days |
dbl | days | Number of smoke days (county-days in the raw file), 0–31 |
days_gt5 |
dbl | days | Number of days with smoke PM2.5 above 5 µg/m³ |
days_gt15 |
dbl | days | Number of days with smoke PM2.5 above 15 µg/m³ |
days_gt35 |
dbl | days | Number of days with smoke PM2.5 above 35 µg/m³ |
Checks and cautions
- Complete panel. The daily files hold 20,431,992 county-days (3,108 counties × 6,574 days), with no duplicate county-day and no missing cell. 3,317,195 of them (16.2%) are smoke days; all other rows have
smoke_pm25= 0. The monthly file has 671,328 rows: 3,108 counties × 216 months. 63.9% of county-months have at least one smoke day. - Matches the project build. The smoke-day rows are the same 3,317,195 county-days as in the research project’s v2.0 beta county-day file, key for key, and the values differ only by rounding (at most 0.005 µg/m³). All 671,328 county-months match the project’s monthly file:
smoke_meanandsmoke_maxagree within 0.005 µg/m³ (rounding), andsmoke_days,days_gt5,days_gt15, anddays_gt35are identical. - County lists match. The raw file has 3,108 GEOIDs, and they are exactly the 3,108 GEOIDs of
counties_2020.csv. No raw row was dropped, and no county is all zeros because it was missing from the raw file. - Absence means zero. The daily mean over all county-days of 2006–2023 is 0.62 µg/m³; the mean of the raw rows alone is 3.80 µg/m³, six times higher. In Livingston County, New York, smoke PM2.5 peaked at 92.58 µg/m³ on 7 June 2023, during the Canadian smoke episode.
- Use
smoke_dayto count smoke days. 137,690 smoke-day rows showsmoke_pm25= 0.00. Of these, 14,796 are exact zeros in the raw file (a smoke day without elevated PM2.5 at the ground, as the README puts it) and 122,894 are below 0.005 µg/m³ and round to 0. Partial coverage also produces near-zero values: if smoke touches only a few of the 10 km cells in a county, the other cells count as 0 in the county average. Countingsmoke_pm25 > 0would miss these rows. - Smoke somewhere in the county.
smoke_dayisTRUEwhen at least one 10 km cell overlapping the county is a smoke day. Big counties have more cells, so they are smoke days a little more often. Within the same state, the largest quarter of counties (by land area) are smoke days on 17.0% of days and the smallest quarter on 15.6%. - Rebuilding monthly values from the daily files. The monthly file uses unrounded daily values. If you rebuild it from the 2-decimal daily files,
smoke_daysandsmoke_maxcome out the same, butdays_gt5is lower in 931 county-months (932 days in all),days_gt15in 66, anddays_gt35in 5, because a value such as 5.004 is stored as 5.00.smoke_meandiffers by 0.01 µg/m³ in 8,059 county-months (1.2%). - Days with no smoke row anywhere. On 99 of the 6,574 days, no county has a row, so every county gets 0. 64 of these days fall in December or January, when smoke is rare. On 54 of them, the project’s copy of the NOAA HMS smoke archive has no plume polygons at all, so a 0 on those days may mean “no satellite smoke analysis” rather than clean air. The converse also happens: on 7 other days the HMS archive has no plumes but the file still has smoke rows (for example 2,714 of 3,108 counties on 12 May 2023). The model’s second route (fire trajectories when clouds hide plumes) can produce such rows, so HMS gaps do not map one-to-one to zeros. The README does not say how v2.0 beta handles days without HMS data. The provider’s v1.1 code documents its rule for the monitor-level data: dates with missing smoke data count as non-smoke days (an alternative version,
filled_smokePM, fills them from 1–2 days before and after). - Comparing years. The share of county-days classified as smoke days is between 6.5% and 18.5% in every year from 2006 to 2020 (16.0% in 2020), then 28.9% in 2021, 38.4% in 2022, and 47.8% in 2023. Part of this is real smoke, but the smoke-day screen depends on how HMS analysts draw plumes, which changes over time. The jump does not follow the number of plumes: analysts drew about 124 plume polygons per day in 2020 and about 55 in 2023. Also, 2021–2023 exist only in this beta version, so there is no second version to check them against. Compare
smoke_pm25levels or threshold days (days_gt15,days_gt35) rather than rawsmoke_dayswhen you compare years. - Beta version and an older README. The provider calls v2.0 “preliminary and subject to change.” The README in the folder is dated 2022-11-04 and describes the v1.0 county file (2006–2020,
.rdsand.csv, 2019 county shapefile), not this 2024-09-04 file. Its rule for missing days is the one the script applies. - Version 1.0 is not shipped. The research project also has v1.0 (2006–2020). Over 2006–2020 the two versions’ monthly means correlate at 0.984, but v1.0 averages 0.434 µg/m³ and v2.0 beta 0.394 µg/m³, and v1.0 lists 2,304,227 smoke county-days against 2,011,678 in v2.0 beta. Never mix versions in one variable.
- Not source-attributed. Smoke from all fires is pooled. The file cannot separate Canadian smoke from U.S. smoke; for that you need fire locations, smoke plumes, and winds from other modules.
- A model estimate. The values are predictions from a model trained on EPA monitors, not measurements. Smoke days come mainly from HMS plumes, which analysts draw from satellite images, and a plume seen from space can sit high above the ground. Outside the West, HMS plumes agree poorly with smoke at the ground (Liu et al. 2024, International Journal of Wildland Fire), so the smoke-day screen is less reliable in the eastern counties that Canadian smoke often reaches.
- Extremes. The highest daily value is 430.80 µg/m³ (Mono County, California, 17 September 2020). The highest monthly mean is 200.18 µg/m³ (Trinity County, California, August 2021).
- Reading the files.
read_csv()guessesGEOIDas text andsmoke_dayas logical, but givecol_typesanyway, as in chunkm05-read-back. All 18 daily files together are 20.4 million rows; read only the years you need. - Memory and time. The script runs in about 2 minutes, with a peak memory use of about 1.5 GB.
EPA EMBER model runs for 2023
EMBER (Expedited Modeling of Burn Events Results) is a set of air-quality model runs that the U.S. Environmental Protection Agency (EPA) made for the 2023 fire season. EPA ran its Community Multiscale Air Quality model (CMAQ), a computer simulation of how emissions travel, mix, and react in the air, three times over the same 36 km grid: once with all emissions, once with no fire emissions, and once with no Canadian wildfire emissions. Each run gives, for every grid cell and every day from 11 April to 29 September 2023, the 24-hour average PM2.5 (fine particles smaller than 2.5 micrometers across, the main pollutant in wildfire smoke) and the daily maximum 8-hour average ozone (MDA8) near the ground.
Because the three runs differ only in their fires, subtracting one from another gives a model estimate of how much PM2.5 came from all fires, and from Canadian fires alone, in each county on each day. No monitor can split PM2.5 by source this way. But EMBER is a model, not a measurement: its numbers are only as good as its emission estimates and its physics, its fire emissions were built from fire perimeters matched to satellite fire detections (so it is not independent of the satellite fire data used elsewhere in this course), and it covers one season only.
| Provider | U.S. Environmental Protection Agency (EPA); data set by Simon et al., archived on Zenodo (record 13737754) |
| Coverage | 11 April – 29 September 2023 (172 days) · contiguous U.S., southern Canada, and most of Mexico on EPA’s “36US3” grid (172 × 148 cells of 36 km × 36 km) |
| Raw unit | one grid cell = 36 km × 36 km, one value per cell and day, in each of three model runs |
| Clean unit | one row = one county × one modeled day |
| Licence and citation | Data: CC BY 4.0. Cite the data, Simon, H., Beidler, J., Baker, K., et al. (2024), Expedited Modeling of Burn Events Results (EMBER) Data Files, Zenodo, https://doi.org/10.5281/zenodo.13737754, and the article, Simon, H., Beidler, J., Baker, K.R., et al. (2025), “Expedited modeling of burn events results (EMBER): A screening-level dataset of 2023 ozone fire impacts in the US,” Data in Brief 58, 111208, https://doi.org/10.1016/j.dib.2024.111208 (the Zenodo description misspells it “Expediated”). The article itself is CC BY-NC. |
| Documentation | Zenodo record 13737754 (includes the data dictionary shipped here) · Simon et al. 2025, Data in Brief · EPA EMBER page · I/O API file format |
Raw files
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
ember/2023/HR2DAY_CMAQv54_cb6r5_ae7_aq.36US3.35.basecase2023_2023_O3_PM25_11Apr2023to29Sep2023.nc |
35.04 MB | NetCDF-3 (64-bit offset) in I/O API layout | one grid cell × day; base run (all emissions) |
ember/2023/HR2DAY_CMAQv54_cb6r5_ae7_aq.36US3.35.basecase2023_nofires_2023_O3_PM25_11Arp2023to29Sep2023.nc |
35.04 MB | NetCDF-3 (64-bit offset) in I/O API layout | one grid cell × day; no-fires run (no U.S. or Canadian fire emissions) |
ember/2023/HR2DAY_CMAQv54_cb6r5_ae7_aq.36US3.35.basecase2023_noCAfires_2023_O3_PM25_11Apr2023to29Sep2023.nc |
35.04 MB | NetCDF-3 (64-bit offset) in I/O API layout | one grid cell × day; no-Canadian-fires run (U.S. fires kept) |
ember/2023/EMBER_datadictionary.docx |
0.02 MB | Word document | — (the provider’s data dictionary for every file in the Zenodo record) |
ember/2023/zenodo_record_13737754.json |
0.01 MB | JSON | — (Zenodo’s metadata record for the data set, saved 27 September 2026) |
The file names are the provider’s. “11Arp” in the no-fires name is a typo in the original; the file covers the same days as the other two. All three NetCDF files match the MD5 checksums published on Zenodo. (An MD5 checksum is a short fingerprint computed from a file’s bytes; matching fingerprints mean the files are identical.)
Not shipped: the rest of the Zenodo record (fire and other emission inputs, EMBER.EMIS.tar.gz, 25.4 GB; figures, EMBER_figures_table.zip, 0.66 GB; model scripts, EMBER_inputs_and_scripts.zip, 6.4 MB). EPA has also posted EMBER runs for 2021, 2022, 2024, and 2025 (CMAQ 5.5, 1 April – 30 September, about 168 MB per file, on EPA’s public epa-ember Amazon S3 bucket; see the EPA page). Their third run removes Canadian and Mexican fires together, so it cannot isolate Canada. The research project keeps them only as a consistency check, and they are not in this bundle. (In the 2024 and 2025 files the PM2.5 variable is named ATOTIJ, not ATOTIJ_AVG.)
Raw variables
I/O API is the file layout of EPA’s air-quality models: a NetCDF file with fixed dimension names and with the map grid stored in global attributes. The three .nc files share one layout; only the values differ. Their dimensions are TSTEP (172 days, the “unlimited” time dimension), DATE-TIME (2), LAY (1 model layer), VAR (2), ROW (148), and COL (172). That list is the order in which ncdf4 lists the dimensions (what chunk m08-look prints). In the table below, each variable’s dimensions are given in the order of the array that ncvar_get() returns, fastest-changing first (COL for the two data variables). Variable descriptions follow the data dictionary and Simon et al. (2025).
| Variable | Dimensions | Units | Description |
|---|---|---|---|
TFLAG |
DATE-TIME × VAR × TSTEP (2 × 2 × 172); integer |
<YYYYDDD,HHMMSS> |
Time-step flag, one pair per variable and day. Element 1 is the date as YYYYDDD, the year and the day of the year (2023101 = 11 April 2023). Element 2 is the start time as HHMMSS (always 0). |
O3_MDA8 |
COL × ROW × LAY × TSTEP (172 × 148 × 1 × 172); float |
ppbV (parts per billion by volume) | Maximum daily 8-hour average ozone in the model’s bottom layer, which the article says was “calculated in accordance with Appendix U to 40 CFR Part 50” (the regulatory MDA8 rule). Variable attribute var_desc = “Max-8-hour”. |
ATOTIJ_AVG |
COL × ROW × LAY × TSTEP (172 × 148 × 1 × 172); float |
ug/m3 (µg/m³) | 24-hour average fine particulate matter (PM2.5) in the model’s bottom layer: the sum of all particle species in the model’s two fine-particle size groups, the Aitken mode (“I”, the smallest particles) and the accumulation mode (“J”). Variable attribute var_desc = “Daily-average”. |
Global attributes: in I/O API files the map grid and time axis live in global attributes, not in latitude or longitude variables. The values below are from the base file; the other two files are identical except for CTIME and WTIME (the clock time each file was written). Definitions follow the I/O API documentation.
| Attribute | Value | Description |
|---|---|---|
IOAPI_VERSION |
ioapi-3.2: $Id: init3.F90 247 2023-03-22 … |
Version of the I/O API library that wrote the file |
EXEC_ID |
???????????????? |
Execution ID of the writing program (not set) |
FTYPE |
1 | File type; 1 = gridded data (GRDDED3) |
CDATE, CTIME |
2024191, 211903 | File creation date (YYYYDDD: 9 July 2024) and time (HHMMSS) |
WDATE, WTIME |
2024191, 211903 | Date and time of the last write |
SDATE, STIME |
2023101, 0 | Date (11 April 2023) and time (00:00:00) of the first time step |
TSTEP |
240000 | Length of a time step as HHMMSS: 24 hours |
NTHIK |
1 | Boundary thickness in cells; used only by boundary files, not by gridded files like these |
NCOLS, NROWS, NLAYS |
172, 148, 1 | Number of grid columns, rows, and layers |
NVARS |
2 | Number of data variables (O3_MDA8, ATOTIJ_AVG; TFLAG is not counted) |
GDTYP |
2 | Map projection type; 2 = Lambert conformal conic (a map projection that unrolls a cone touching the globe at two latitudes; it keeps shapes nearly true across mid-latitude regions such as North America) |
P_ALP, P_BET |
33, 45 | The two latitudes (degrees north) that define the projection cone (standard parallels) |
P_GAM |
−97 | Central meridian (degrees east) |
XCENT, YCENT |
−97, 40 | Longitude and latitude of the point where projected x = 0 and y = 0 |
XORIG, YORIG |
−2,952,000, −2,772,000 | x and y (meters) of the lower-left corner of the cell in column 1, row 1 |
XCELL, YCELL |
36,000, 36,000 | Cell width and height (meters) |
VGTYP |
−9999 | Vertical coordinate type; −9999 means “missing” |
VGTOP |
0.999 | Model top for sigma coordinates (sigma is the model’s height coordinate that follows the terrain: 1 at the ground, falling toward 0 with height); with VGTYP missing, this value is not documented by the provider |
VGLVLS |
1, 0.9975 | Vertical coordinate values of the layer edges: the single layer here is the model’s bottom layer (sigma 1.0 to 0.9975, a thin slice of air at the ground) |
GDNAM |
36US3 |
Grid name: EPA’s 36 km continental U.S. grid |
UPNAM |
M3XTRACT |
Name of the last program that wrote the file (an I/O API tool that extracts subsets) |
VAR-LIST |
O3_MDA8 ATOTIJ_AVG |
Names of the data variables, 16 characters each |
FILEDESC |
“Concentration file output / Averaged over the synchronization time steps / Timestamp represents beginning computed date/time / Layer mapping (CGRID to AGRID): Layer 1 to 1” | Free-text file description written by CMAQ (padded with blanks) |
HISTORY |
(empty) | Update history (empty) |
zenodo_record_13737754.json is Zenodo’s metadata record for the data set. It is documentation, not data. Its top-level fields:
| Field | Type | Description |
|---|---|---|
id, recid |
number, string | Zenodo record number (13737754) |
doi, doi_url |
string | DOI of this version: 10.5281/zenodo.13737754 |
conceptrecid, conceptdoi |
string | Record number (13737753) and DOI that cover all versions of the data set |
created, modified, updated |
string (ISO date-time, UTC) | When the record was created (2024-09-10) and last changed (2025-01-23) |
title |
string | Data set title |
metadata |
object | Descriptive metadata: title, publication_date (2024-09-09), description (HTML), access_right, creators and contributors (names, affiliations, ORCID iDs), keywords, references (the Data in Brief article), language, resource_type, journal, license (cc-by-4.0), relations (versions) |
files |
array | One entry per file in the record: key (file name), size (bytes), checksum (MD5), id, links (download URL) |
links |
object | Web and API addresses of the record |
revision |
number | Revision number of the record (16) |
stats |
object | View and download counts on the day the record was saved |
status, state, submitted |
string, string, boolean | Publication status: “published”, “done”, true |
owners |
array | Zenodo user ID of the record’s owner |
swh |
object | Software Heritage archive identifiers (empty) |
The .docx data dictionary describes each file in the Zenodo record in prose; it has no variables beyond the two listed above.
Cleaning steps
The code makes these decisions:
- Read the arrays as they are.
ncdf4returns each variable as an array in the order [column, row, day]. Column 1 is the westernmost column and row 1 the southernmost row, so the array never needs to be flipped as long as we index cells by (column, row). - Rebuild the model’s map from the file. The PROJ string uses the projection numbers stored in the global attributes. It also treats the Earth as a sphere with a radius of 6,370 km, which is CMAQ’s convention and is not stored in the file. The Census coordinates are on the NAD83 datum; treating them as WGS84 (EPSG:4326) moves them by about a meter, far less than a 36 km cell.
- Weight by population, not by area. A county’s value is the average of its cells, weighted by where its people live in 2020. Each block group’s center of population (from module m00) is placed in the grid cell that contains it, so a cell over a lake or an empty desert gets no weight. A 36 km cell is much larger than a block group, so assigning each block group to one whole cell loses little.
- Average first, then subtract. County averages are taken for each run, and fire PM2.5 is the difference of the county averages. Because a weighted mean is linear, this is the same as averaging the cell differences.
- Define the Canadian share only when there is fire smoke. The share is a ratio of county averages, capped to [0, 1], and set to missing when modeled fire PM2.5, before rounding, is below 0.01 µg/m³.
- Round only at the end, and keep every county and every modeled day (a complete panel).
- Keep extreme values, but show them. The file keeps every modeled value, including 135 county-days with more than 100 µg/m³ of fire PM2.5 that came mostly from nearby U.S. fires, which monitors often do not confirm. The last two chunks show where these values are and how to summarize the file with population weights.
Name the three runs. The named vector lets later code say ember_paths[["no_fires"]] instead of repeating a long file name.
# EMBER ran one air-quality model three times for 11 April - 29 September
# 2023. The runs differ only in which fire emissions they include, so the
# difference between two runs is the pollution that the removed fires caused.
# base : all emissions, fires included
# no_fires : no fire emissions at all (no U.S. and no Canadian fires)
# no_canada_fires : no Canadian wildfire emissions (U.S. fires still in)
# The file names are the provider's own. The "11Arp" in the no-fires file
# name is a typo in the original name; we keep it so the file matches Zenodo.
ember_files <- c(
base = "HR2DAY_CMAQv54_cb6r5_ae7_aq.36US3.35.basecase2023_2023_O3_PM25_11Apr2023to29Sep2023.nc",
no_fires = "HR2DAY_CMAQv54_cb6r5_ae7_aq.36US3.35.basecase2023_nofires_2023_O3_PM25_11Arp2023to29Sep2023.nc",
no_canada_fires = "HR2DAY_CMAQv54_cb6r5_ae7_aq.36US3.35.basecase2023_noCAfires_2023_O3_PM25_11Apr2023to29Sep2023.nc"
)
ember_paths <- set_names(file.path(raw_dir, "ember", "2023", ember_files), names(ember_files))
stopifnot(all(file.exists(ember_paths)))Open the base run and print its variables, dimensions, and grid attributes. These are the numbers that tell us where each cell is.
# Open the base run and look inside. The three files have the same layout:
# three variables (TFLAG, O3_MDA8, ATOTIJ_AVG) on 172 columns x 148 rows of
# grid cells, one model layer, and 172 daily time steps.
nc_base <- nc_open(ember_paths[["base"]])
names(nc_base$var)
map_int(nc_base$dim, "len")
# IOAPI files store the map grid in global attributes instead of latitude and
# longitude variables. GDTYP = 2 means a Lambert conformal conic projection.
grid <- ncatt_get(nc_base, 0)
grid[c("GDTYP", "P_ALP", "P_BET", "P_GAM", "XCENT", "YCENT",
"XORIG", "YORIG", "XCELL", "YCELL", "NCOLS", "NROWS")] |>
as_tibble()Turn TFLAG into real dates. make_date(year) is 1 January, so adding the day of the year minus one gives the date.
# TFLAG stores the start of each time step as two integers: the date as
# YYYYDDD (year and day of the year) and the time as HHMMSS. Its dimensions
# are [DATE-TIME, VAR, TSTEP], with one copy per variable; the first is enough.
tflag <- ncvar_get(nc_base, "TFLAG")
nc_close(nc_base)
yyyyddd <- tflag[1, 1, ]
ember_dates <- make_date(yyyyddd %/% 1000) + (yyyyddd %% 1000) - 1
# One time step per day, all at 00:00:00, 11 April to 29 September 2023.
stopifnot(
length(ember_dates) == 172, all(tflag[2, 1, ] == 0), all(diff(ember_dates) == 1),
min(ember_dates) == as.Date("2023-04-11"), max(ember_dates) == as.Date("2023-09-29")
)A small function reads one variable from one file and checks that the file uses the same grid and days as the base run.
# Read one daily variable from one run. ncvar_get() returns an array with the
# dimensions [COL, ROW, TSTEP]; it drops the single model layer (LAY) for us.
# Before reading, check that the run uses the same grid and start date as the
# base run, so cell (col, row) on day t means the same place and day in all.
read_ember <- function(path, var) {
nc <- nc_open(path)
on.exit(nc_close(nc))
same_grid <- map_lgl(
c("XORIG", "YORIG", "XCELL", "YCELL", "NCOLS", "NROWS", "SDATE", "TSTEP"),
\(a) identical(ncatt_get(nc, 0, a)$value, grid[[a]])
)
x <- ncvar_get(nc, var)
stopifnot(all(same_grid), !anyNA(x),
identical(dim(x), c(grid$NCOLS, grid$NROWS, length(ember_dates))))
x
}Build the cell-by-day table: 4,378,432 rows, one for each of the 25,456 cells on each of the 172 days, with the three PM2.5 runs and the base ozone side by side. The order of expand_grid() must match the order of the array.
# Turn the arrays into one long table with one row per grid cell and day.
# as.vector() reads an array with its first dimension changing fastest: COL,
# then ROW, then TSTEP. expand_grid() changes its LAST column fastest, so
# listing date, row, col in that order lines the rows up with the values.
cell_day <- expand_grid(
date = ember_dates,
row = seq_len(grid$NROWS),
col = seq_len(grid$NCOLS)
) |>
mutate(
pm25_base = as.vector(read_ember(ember_paths[["base"]], "ATOTIJ_AVG")),
pm25_no_fires = as.vector(read_ember(ember_paths[["no_fires"]], "ATOTIJ_AVG")),
pm25_no_canada_fires = as.vector(read_ember(ember_paths[["no_canada_fires"]], "ATOTIJ_AVG")),
o3_mda8_base_ppb = as.vector(read_ember(ember_paths[["base"]], "O3_MDA8"))
)
# 172 columns x 148 rows x 172 days = 4,378,432 cell-days. Concentrations
# cannot be negative, so the smallest value of each column must be >= 0.
cell_min <- cell_day |>
summarize(across(pm25_base:o3_mda8_base_ppb, min))
stopifnot(nrow(cell_day) == 172 * 148 * 172, all(cell_min >= 0))
# R prints scientific notation: 1.742e+04 means 17,420 ug/m3, in a cell over
# the Quebec fires. Few such extreme cells hold U.S. residents, so county
# averages are much lower (see m08-hotspots).
summary(select(cell_day, pm25_base:o3_mda8_base_ppb))The printed summary describes grid cells, not counties, so it includes cells far from any U.S. resident. The largest pm25_base (17,420 µg/m³) is a cell over the fires in northern Quebec on 22 June 2023; in cells where U.S. residents live, the maximum is about 2,320 µg/m³ (on the Oregon–California coast, 27 August), and population weighting brings the largest county value down to 855 µg/m³ (chunk m08-hotspots). The ozone minimum (0.35 ppb) is also a cell in Canada, in northern British Columbia on 23 September, so no county uses it.
Write the grid’s map projection as a PROJ string, a one-line text description of a map projection that sf understands. The grid uses a Lambert conformal conic projection: picture a paper cone set over the globe so that it touches it along two latitudes (33° N and 45° N here) and then unrolled flat.
# To find the grid cell that a place falls in, we need the grid's projection.
# IOAPI's Lambert conformal conic grid has two standard parallels (P_ALP and
# P_BET), a central meridian (P_GAM), and an origin where x = 0 and y = 0
# (XCENT, YCENT). Here P_GAM equals XCENT, so the origin sits on the central
# meridian and no false easting is needed. CMAQ treats the Earth as a sphere
# with a radius of 6,370 km; that number is a CMAQ rule, not stored in the file.
stopifnot(grid$GDTYP == 2, grid$P_GAM == grid$XCENT)
ember_crs <- str_glue(
"+proj=lcc +lat_1={grid$P_ALP} +lat_2={grid$P_BET} +lat_0={grid$YCENT} ",
"+lon_0={grid$P_GAM} +x_0=0 +y_0=0 +R=6370000 +units=m +no_defs"
)
ember_crsProject the block-group centers to the grid and compute each one’s column and row. floor((x - XORIG) / XCELL) + 1 counts how many whole 36 km cells lie between the grid’s western edge and the point.
# Population weights start from the 2020 block-group centers of population
# (module m00). Block groups with no residents are dropped: they would get a
# weight of zero anyway. Each center is projected to the grid's x/y meters.
bg_centers <- read_csv(
file.path(clean_dir, "census_bg_centers_2020.csv"),
col_types = cols(bg_geoid = "c", GEOID = "c", .default = "d")
) |>
filter(pop_2020 > 0)
bg_xy <- bg_centers |>
st_as_sf(coords = c("lon", "lat"), crs = 4326) |>
st_transform(st_crs(ember_crs)) |>
st_coordinates()
# Count whole cells from the grid's lower-left corner (XORIG, YORIG). Column 1
# is the westernmost column and row 1 the southernmost row, the same order as
# the NetCDF arrays, so no flipping is needed.
bg_cells <- bg_centers |>
mutate(
col = as.integer(floor((bg_xy[, "X"] - grid$XORIG) / grid$XCELL)) + 1L,
row = as.integer(floor((bg_xy[, "Y"] - grid$YORIG) / grid$YCELL)) + 1L
)
# Every populated block group must land inside the 172 x 148 grid.
stopifnot(
all(between(bg_cells$col, 1, grid$NCOLS)),
all(between(bg_cells$row, 1, grid$NROWS))
)Add up population by county and cell, and turn it into weights that sum to 1 in each county. A county touches between 1 and 22 cells (median 3).
# A county's weight on a cell is the share of the county's 2020 population
# whose block-group centers fall in that cell. The weights add up to 1 in
# each county. Lakes and empty land get no weight because nobody lives there.
county_cell_weights <- bg_cells |>
summarize(pop = sum(pop_2020), .by = c(GEOID, col, row)) |>
mutate(weight = pop / sum(pop), .by = GEOID)
counties <- read_csv(file.path(clean_dir, "counties_2020.csv"),
col_types = cols_only(GEOID = "c"))
# Every one of the 3,108 counties has weights, and they add up to 1.
weight_check <- county_cell_weights |>
summarize(total = sum(weight), n_cells = n(), .by = GEOID)
stopifnot(
nrow(counties) == 3108,
setequal(weight_check$GEOID, counties$GEOID),
all(abs(weight_check$total - 1) < 1e-9),
!anyDuplicated(select(county_cell_weights, GEOID, col, row))
)
# How many cells does a county touch? Most touch only a few.
summary(weight_check$n_cells)Join the weights to the cell-day table and take the weighted mean for each county and day. This step is the slowest (a few seconds) and uses the most memory, about 1.5 GB for the whole script.
# Join each county's weights to the daily values of its cells, then take the
# weighted mean, sum(weight * value), for each county and day. The join is
# many-to-many: a cell can serve several counties, and each county-cell pair
# matches all 172 days.
county_day <- county_cell_weights |>
select(GEOID, col, row, weight) |>
inner_join(cell_day, by = c("col", "row"), relationship = "many-to-many") |>
summarize(
across(pm25_base:o3_mda8_base_ppb, \(x) sum(weight * x)),
.by = c(GEOID, date)
)Compute fire PM2.5, Canadian-fire PM2.5, and the Canadian share of fire PM2.5.
# Fire PM2.5 is the PM2.5 that disappears when fires are switched off:
# pm25_fire = base - no fires (U.S. and Canadian fires)
# pm25_canada_fire = base - no Canadian fires (Canadian wildfires only)
# Model chemistry is not perfectly additive, so either difference can be a
# little below zero. The Canadian share of fire PM2.5 is left undefined (NA)
# when fire PM2.5 is below 0.01 ug/m3: a ratio of two tiny numbers is noise.
# Otherwise the share is capped to the range 0 to 1. These values are not
# rounded yet, so a share can be NA where the rounded file shows 0.01.
county_day <- county_day |>
mutate(
pm25_fire = pm25_base - pm25_no_fires,
pm25_canada_fire = pm25_base - pm25_no_canada_fires,
canada_share_of_fire = if_else(
pm25_fire >= 0.01,
pmin(pmax(pm25_canada_fire / pm25_fire, 0), 1),
NA_real_
)
)Round, order the columns, check the grain, and write the file.
# Round for storage: PM2.5 to 0.01 ug/m3, the share to 0.001, and ozone to
# 0.1 ppb. Differences and the share were computed from unrounded values.
# A tiny negative difference rounds to -0; adding 0 turns it into a plain 0,
# so the file never shows "-0".
dir.create(clean_dir, recursive = TRUE, showWarnings = FALSE)
ember_county_day <- county_day |>
mutate(
across(starts_with("pm25"), \(x) round(x, 2) + 0),
canada_share_of_fire = round(canada_share_of_fire, 3),
o3_mda8_base_ppb = round(o3_mda8_base_ppb, 1)
) |>
select(GEOID, date, pm25_base, pm25_no_fires, pm25_no_canada_fires,
pm25_fire, pm25_canada_fire, canada_share_of_fire, o3_mda8_base_ppb) |>
arrange(GEOID, date)
# Grain: one row per county and modeled day, 3,108 x 172 = 534,576 rows,
# with no duplicated keys. The only missing values are undefined shares.
stopifnot(
nrow(ember_county_day) == 3108 * 172,
!anyDuplicated(select(ember_county_day, GEOID, date)),
setequal(ember_county_day$GEOID, counties$GEOID),
setequal(ember_county_day$date, ember_dates),
all(complete.cases(select(ember_county_day, -canada_share_of_fire))),
all(ember_county_day$pm25_base >= 0), all(ember_county_day$o3_mda8_base_ppb > 0),
all(between(ember_county_day$canada_share_of_fire, 0, 1), na.rm = TRUE)
)
write_csv(ember_county_day, file.path(clean_dir, "ember_county_day_2023.csv"), na = "")Two quick summaries. June has by far the most Canadian-fire PM2.5 (5.36 µg/m³ on average across counties), and the largest single values are in north-central Indiana on 28 June 2023.
# A first look. By month: the average across counties (not weighted by
# population) of fire and Canadian-fire PM2.5, and how often the share is NA.
ember_county_day |>
mutate(month = month(date, label = TRUE)) |>
summarize(
fire = mean(pm25_fire), canada_fire = mean(pm25_canada_fire),
share_na = mean(is.na(canada_share_of_fire)),
.by = month
)
# The five county-days with the most modeled Canadian-fire PM2.5.
ember_county_day |>
slice_max(pm25_canada_fire, n = 5) |>
select(GEOID, date, pm25_base, pm25_canada_fire, canada_share_of_fire)The highest values in the file come from U.S. fires, not Canadian smoke. This chunk prints the five largest pm25_base values (all with a Canadian share of 0.055 or less) and counts the county-days with more than 100 µg/m³ of fire PM2.5: 363 are mostly Canadian smoke (May to July, 338 of them in June) and 135 are mostly from U.S. fires.
# The very highest values in the file are not Canadian smoke. They are
# county-days in or next to a grid cell that holds a large U.S. wildfire or
# prescribed burn, so the Canadian share is near 0. Monitors in the same
# county often read far less on those days (see "Checks and cautions"), so
# look at these rows before you average pm25_base or pm25_fire.
ember_county_day |>
slice_max(pm25_base, n = 5) |>
select(GEOID, date, pm25_base, pm25_fire, canada_share_of_fire)
# County-days with more than 100 ug/m3 of fire PM2.5, split by whether most
# of that fire PM2.5 came from Canadian fires (share of 0.5 or more).
ember_county_day |>
filter(pm25_fire > 100) |>
count(mostly_canada = canada_share_of_fire >= 0.5)Population-weighted averages describe what the average resident breathed. This chunk reproduces the season averages, the averages for each group of states in the east_split column of counties_2020.csv, and the peak days quoted under “Checks and cautions”.
# What did the average resident breathe? Weight each county by its 2020
# population (pop_2020 in counties_2020.csv). The east_split column of the
# same file puts each state in one of five groups: Great Lakes, Upper
# Midwest, Mid-Atlantic, New England, or Other (all remaining states).
county_pop <- read_csv(file.path(clean_dir, "counties_2020.csv"),
col_types = cols_only(GEOID = "c", east_split = "c",
pop_2020 = "d"))
ember_pop <- left_join(ember_county_day, county_pop, by = "GEOID")
stopifnot(nrow(ember_pop) == nrow(ember_county_day), !anyNA(ember_pop$pop_2020))
# Season averages, population-weighted: whole country, then each group.
ember_pop |>
summarize(across(c(pm25_base, pm25_fire, pm25_canada_fire),
\(x) weighted.mean(x, pop_2020)))
ember_pop |>
summarize(across(c(pm25_base, pm25_fire, pm25_canada_fire),
\(x) weighted.mean(x, pop_2020)),
.by = east_split) |>
arrange(desc(pm25_canada_fire))
# The three days with the most Canadian-fire PM2.5 for the average resident.
ember_pop |>
summarize(pm25_canada_fire = weighted.mean(pm25_canada_fire, pop_2020),
.by = date) |>
slice_max(pm25_canada_fire, n = 3)Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
ember_county_day_2023.csv |
534,576 | one county × one modeled day (3,108 counties × 172 days, 11 April – 29 September 2023) | 27.5 MB |
Clean variables
ember_county_day_2023.csv: sorted by GEOID and date. All values are population-weighted county averages of EMBER grid cells. Ranges are over all rows.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (2020 Census counties; joins to counties_2020.csv) |
date |
date | — | Modeled day, 2023-04-11 to 2023-09-29 (see the time caution below) |
pm25_base |
dbl | µg/m³ | 24-hour average PM2.5 in the base run, all emissions (ATOTIJ_AVG); 0.01 to 855 (median 4.4). Values above about 300 come from cells next to large U.S. fires and are often not seen by monitors: see “Spikes next to U.S. fires” below |
pm25_no_fires |
dbl | µg/m³ | Same, in the run with no fire emissions; 0.01 to 47.1 |
pm25_no_canada_fires |
dbl | µg/m³ | Same, in the run with no Canadian wildfire emissions; 0.01 to 854 |
pm25_fire |
dbl | µg/m³ | Fire PM2.5 from U.S. and Canadian fires: pm25_base − pm25_no_fires, computed before rounding; −0.60 to 851 (median 0.77). The same U.S.-fire spikes drive its top values |
pm25_canada_fire |
dbl | µg/m³ | Canadian-fire PM2.5: pm25_base − pm25_no_canada_fires, computed before rounding; −0.72 to 220 |
canada_share_of_fire |
dbl | share (0 to 1) | pm25_canada_fire / pm25_fire, capped to [0, 1], computed before rounding; NA (undefined) when the unrounded fire PM2.5 is below 0.01 µg/m³ (16,668 rows, 3.1%; 5,110 of them show pm25_fire = 0.01 after rounding) |
o3_mda8_base_ppb |
dbl | ppb | Maximum daily 8-hour average ozone in the base run (O3_MDA8); 3.5 to 109.4. Values below 15 ppb (10 county-days, mostly in Oregon and Washington on 31 August 2023) look like model artifacts: see the ozone caution below |
Checks and cautions
- Agreement with the project build.
R/check_m08.Rcompares every row with the research project’s population-weighted file (ember2023_county_day.parquet). All 534,576 county-days match: the largest difference is 0.005 µg/m³ for each PM2.5 column, 0.0005 for the share, and 0.05 ppb for ozone, which is exactly the rounding. The share is missing on the same 16,668 county-days in both files. - Raw copies. The three
.ncfiles and the data dictionary are byte-identical to the project’s copies and match the MD5 checksums published on Zenodo. - EMBER is a model, not a measurement. On the 92,661 county-days with a PM2.5 monitor (616 counties; EPA’s AQS monitor data, columns
pm25_meanando3_mda8_ppbofaqs_county_day_2006_2025.csv.gzfrom module m03), EMBER’s base PM2.5 averages 7.18 µg/m³ against 9.72 µg/m³ measured; the correlation is 0.70, and the typical (median) model value is two thirds of the monitor value. On the worst Canadian smoke days it is often too low. On 7 June 2023 it gives 81.5 µg/m³ for the Bronx (36005) and 88.4 for Queens (36081), where monitors averaged 177 and 203. On 17 May 2023, in the 9 North Dakota counties with a monitor, it averages 39 µg/m³ against 191 measured, about 5 times too low. It can also come close: on 28 June, Marion County, Indiana (18097, Indianapolis), the highest monitored Indiana county that day, measured 170 µg/m³ against 190 in the model; the model’s Indiana peak of 224 µg/m³ is in north-central counties that have no monitor. For ozone (128,233 monitored county-days), the model averages 50.0 ppb against 46.0 ppb measured (correlation 0.73). The authors call EMBER a “screening-level” data set. - Spikes next to U.S. fires. The highest values in the file are not Canadian smoke. All 25 county-days with
pm25_baseabove 300 µg/m³ (13 counties) have a Canadian share below 0.1: they lie in or next to grid cells that hold a large U.S. wildfire or prescribed burn. Satellite fire detections agree: for 24 of the 25, the MODIS file from module m01 (firms_modis_detections_2003_2024.csv.gz) has at least 10 U.S. detections within about 100 km of the county’s center on the same or the previous day (Ocean County, New Jersey, on 12 April has 7). The maximum, 855 µg/m³ in Del Norte County, California (06015) on 24 August 2023, has no monitor. Where there is a monitor, it often reads far less: Atlantic County, New Jersey (34001) on 1 June, 489 µg/m³ modeled against 6.6 measured; Ocean County, New Jersey (34029) on 12 April, 304 against 16.2; Franklin County, Idaho (16041) on 18 September, 218 against 4.5; Lane County, Oregon (41039) on 15 August, 484 against 81.8. Sometimes the monitor agrees (Spokane County, Washington,53063, on 19 August: 242 against 240). Over the 35 monitored county-days with more than 100 µg/m³ of modeled fire PM2.5 that came mostly from U.S. fires (share below 0.5), the model’s median is 149 µg/m³ against a monitor median of 28; 20 of the 35 monitors read below 35. Over the 93 monitored county-days where that much fire PM2.5 was mostly Canadian, model and monitors agree (medians 135 and 132). So when you summarizepm25_base,pm25_no_canada_fires, orpm25_fireacross counties or days, use medians, trim the top values, or flag county-days with very high fire PM2.5 and a low Canadian share (chunkm08-hotspots).pm25_canada_fireis much less affected: its maximum is 220 µg/m³, on the Canadian smoke day of 28 June. - Very low ozone on a few days. Modeled MDA8 ozone is below 15 ppb on only 10 county-days, 6 of them in Oregon and Washington on 31 August 2023: Multnomah County, Oregon (
41051) 3.5 ppb and Clark County, Washington (53011) 4.2 ppb, where monitors read 19 and 26 ppb. The days before and after look normal. Treat these values as model artifacts. - What the runs remove. The no-Canadian-fires run drops the Canadian fire emissions sector, which holds all Canadian fires inside the grid (the provider calls them Canadian wildfires); U.S. wildfires and prescribed burns stay in. The no-fires run drops all U.S. and Canadian wild and prescribed fire emissions. Mexican fires and agricultural burning are in none of the runs.
- Fires outside the grid. The grid reaches about 55° N at its corners and about 62° N at its center (longitude −97°), so, in the authors’ words, it covers “most of the Canadian wildfires” of 2023, but not all. Smoke that blows in across the edge of the grid comes from the same boundary data in all three runs, so it cancels out in the differences and is counted as “no-fire” PM2.5.
- Shared inputs with satellite fire data. EMBER’s fire emissions come from Canada’s burned-area perimeters (National Burned Area Composite, NBAC) and U.S. fire perimeters (WFIGS), matched to NOAA Hazard Mapping System (HMS) satellite fire detections and run through the U.S. Forest Service BlueSky emissions system. So EMBER fire PM2.5 is not independent of the satellite fire and smoke data in other modules; agreement between them is partly built in. The 2023 fire emissions were preliminary, and the non-fire emissions are typical 2018 days, not actual 2023 days.
- Negative values are real model output. Chemistry is not additive, so removing fires can slightly raise PM2.5 in some places. In the file,
pm25_fireis below 0 on 2,249 county-days (minimum −0.60 µg/m³) andpm25_canada_fireon 6,331 (minimum −0.72). Before rounding, 4,306 and 29,461 values were below 0; the tiny ones (between −0.005 and 0) round to 0. Do not take logs without handling them. - The share. Missing (NA) means “undefined: less than 0.01 µg/m³ of fire PM2.5”, not “not observed”. It is most common in April (9.1% of county-days) and May (6.3%). The cap changed 30,071 county-days: 23,011 had a negative Canadian part (share set to 0) and 7,060 had a Canadian part larger than total fire PM2.5 (share set to 1). After rounding to 0.001, more rows show the end values: 27,534 rows show 0 and 12,253 show 1.
- Rounding. The differences and the share were computed before rounding. So
pm25_firecan differ by 0.01 frompm25_base − pm25_no_firesrecomputed from the rounded columns (133,370 rows; 127,516 rows forpm25_canada_fire), and 5,110 rows showpm25_fire= 0.01 with a missing share because the unrounded value was just below 0.01. - What a “day” is. Each value belongs to a model day. The article says MDA8 ozone follows the regulatory rule (Appendix U), which uses local standard time; the provider does not say whether the 24-hour PM2.5 averages use local days or UTC days (UTC is Coordinated Universal Time, 5 to 8 hours ahead of U.S. local standard time, or 4 to 7 hours ahead during daylight saving time). The model itself ran in UTC, starting each day at 00:00 UTC (run script in the Zenodo record). The daily files were made with CMAQ’s
hr2daytool (the “HR2DAY” in the file names), where one setting (USELOCAL, default “no”) switches every variable in a run to local time. Ozone and PM2.5 sit in one file, so they probably came from one run of the tool. The 2021 EMBER files, written directly byhr2day(theirUPNAMattribute is “hr2day”), hold ozone and PM2.5 variables together, and the 2023 files look like extracts of such a file (UPNAM= “M3XTRACT”). If so, the PM2.5 days are local-standard-time days as well. This is our inference, not a provider statement. When you compare with monitor data, which use local days, expect possible offsets of a few hours. - Coarse grid. A 36 km cell is larger than many counties. Only 18 counties have values identical to another county’s on every day: they take all their population from the same single cell. Sixteen of them are in Virginia, most of them small independent cities such as Alexandria and Manassas; the other two are Union County, New Jersey, and Staten Island (Richmond County, New York). Many more neighboring counties have nearly identical values, and city-scale differences are invisible.
- Big picture. Averaged over the season and weighted by population, modeled PM2.5 is 7.40 µg/m³, fire PM2.5 2.55 µg/m³, and Canadian-fire PM2.5 1.87 µg/m³. The national peak is 7 June 2023 (21.7 µg/m³ of Canadian-fire PM2.5, population-weighted). By the
east_splitgroups of states incounties_2020.csv(also weighted bypop_2020), season Canadian-fire PM2.5 is 4.17 µg/m³ in the Great Lakes group, 3.41 in Upper Midwest, 3.23 in Mid-Atlantic, 1.59 in New England, and 0.94 in “Other” (all remaining states). Chunkm08-pop-weightedreproduces these numbers. - Running time. The script takes 15 to 40 seconds, depending on the computer and how busy it is, and needs about 1.5 GB of memory (peak), most of it in the
m08-county-daystep. On a laptop with 4 GB of memory, close other programs before you run it.
EPA AQS air-quality monitors
The U.S. Environmental Protection Agency (EPA) collects the measurements of ground-level air-quality monitors in its Air Quality System (AQS). State, local, and tribal air agencies run the monitors and report to EPA, and EPA publishes “pre-generated” daily summary files on its AirData website. We use two pollutants: fine particulate matter (PM2.5, particles smaller than 2.5 micrometers), measured in micrograms per cubic meter (µg/m³), and ozone, measured in parts per million (ppm). For PM2.5 we use parameter code 88101, the FRM/FEM monitors: federal reference methods and federal equivalent methods, the instruments EPA accepts for checking whether an area meets its air-quality standards.
For this project, AQS is the measured truth on the ground. It shows how bad the air actually was at a monitor on a given day, for example when Canadian smoke covered New York and Pennsylvania in June 2023, and it can be used to check model-based smoke estimates such as the Childs et al. smoke PM2.5 (model estimates of the part of PM2.5 that comes from wildfire smoke; see the section “Childs et al. smoke PM2.5 (Stanford ECHO Lab)”). It cannot tell you where the pollution came from: a monitor measures all PM2.5 and all ozone, from smoke, traffic, and industry alike. It also covers only places with monitors: 1,080 of the 3,108 counties have at least one site, and many monitors do not report every day.
| Provider | U.S. Environmental Protection Agency, Air Quality System (AQS), through the AirData pre-generated daily summary files. File version generated by EPA on 2026-06-25; downloaded on 2026-09-27 |
| Coverage | 1 January 2006 – 31 December 2025 · raw files: all U.S. states and territories plus a few sites in Mexico and Canada; clean files: the contiguous U.S. and DC (2,773 sites in 1,080 counties) |
| Raw unit | one row = one monitor (site × pollutant × POC) on one local day, for one sample duration, one pollutant standard, and one event type |
| Clean unit | one row = a site (site list); a site-day (PM2.5 and ozone files); a county-day with at least one reading; a county-month with at least one reading |
| Licence and citation | Public domain: EPA states that AQS ambient monitoring data “are public domain” and may be used without a request. EPA’s suggested citation: US Environmental Protection Agency. Air Quality System Data Mart [internet database] available via https://www.epa.gov/outdoor-air-quality-data. Accessed September 27, 2026. Also name the file version (generated 2026-06-25) |
| Documentation | AirData file formats; AirData pre-generated files; PM2.5 data advisory on T640 monitors (2024); AirData frequent questions |
Raw files
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
aqs/daily_88101_<year>__asof20260625.zip (20 files, 2006–2025) |
1.6–9.9 MB each; 104.2 MB in total | zip archive with one comma-separated text file (daily_88101_<year>.csv, 52–322 MB unzipped), header row, text fields in double quotes |
a PM2.5 monitor-day for one sample duration and event type (139,281 rows in 2006 to 863,466 in 2022; 9,203,911 in total) |
aqs/daily_44201_<year>__asof20260625.zip (20 files, 2006–2025) |
4.4–4.9 MB each; 92.9 MB in total | zip archive with one comma-separated text file (daily_44201_<year>.csv, 123–140 MB unzipped), header row, text fields in double quotes |
an ozone monitor-day for one event type (355,145 to 404,185 rows per year; 7,691,814 in total) |
aqs/file_list__dl20260927.csv |
0.09 MB | comma-separated text, header row | one file on EPA’s AirData download page (2,049 rows), with its row count, size, and date |
The names keep EPA’s file names (daily_88101_2023.zip) and add __asof<date>, the date EPA generated the file, and __dl<date>, the date the project downloaded it. readr reads the CSV inside each zip directly, so you never need to unzip. Unzipped, the PM2.5 files alone would take 3.5 GB (sizes in this section are decimal: 1 MB = 1,000,000 bytes, as Finder on a Mac shows them). The number of PM2.5 rows grew sixfold from 2006 to 2022. Continuous monitors joined the network after 2008, and each one writes two rows per day (1 HOUR and 24-HR BLK AVG); a Teledyne T640 with EPA’s corrected data adds a second POC, so up to four rows per day. The clean PM2.5 site-days grew only about 2.3 times (125,266 in 2006, 283,157 in 2022). The number of ozone rows hardly changed:
| Year | PM2.5 rows | Ozone rows |
|---|---|---|
| 2006 | 139,281 | 355,145 |
| 2007 | 145,631 | 362,148 |
| 2008 | 147,108 | 364,607 |
| 2009 | 191,009 | 370,320 |
| 2010 | 230,028 | 378,545 |
| 2011 | 251,767 | 383,872 |
| 2012 | 275,331 | 386,438 |
| 2013 | 317,591 | 389,281 |
| 2014 | 349,157 | 388,524 |
| 2015 | 373,478 | 386,361 |
| 2016 | 408,270 | 392,841 |
| 2017 | 459,536 | 404,185 |
| 2018 | 556,441 | 402,908 |
| 2019 | 654,337 | 392,961 |
| 2020 | 704,092 | 395,536 |
| 2021 | 790,468 | 394,384 |
| 2022 | 863,466 | 387,156 |
| 2023 | 847,162 | 388,607 |
| 2024 | 740,924 | 386,972 |
| 2025 | 758,834 | 381,023 |
Raw variables
All 40 daily files (PM2.5 and ozone) have the same 29 columns. Descriptions follow EPA’s AirData file-format page; value ranges and codes are those found in the 40 files. In the file, text and dates are in double quotes and numbers are not.
| Variable | Type | Description |
|---|---|---|
State Code |
text, 2 characters | FIPS code (Federal Information Processing Standards: the official numeric codes for U.S. states and counties) of the state where the monitor is, with its leading zero ("01" = Alabama). Besides the 50 states and DC, the files contain 02 Alaska, 15 Hawaii, 72 Puerto Rico, 78 Virgin Islands, 80 (“Country Of Mexico”: sites in Baja California, Chihuahua, and Sonora), and CC (“Canada”, sites in Saskatchewan, 2006 only) |
County Code |
text, 3 characters | FIPS code of the county where the monitor is. State Code + County Code is the 5-character county GEOID. Connecticut sites carry the 8 county codes of the 2020 Census (09001–09015) |
Site Num |
text, 4 characters | Number of the site within its county. State Code + County Code + Site Num is the 9-character site ID (360810124 = Queens College, New York) |
Parameter Code |
text, 5 digits | AQS code of what is measured: 88101 = PM2.5 by FRM/FEM monitors; 44201 = ozone. One value per file |
POC |
integer | Parameter Occurrence Code: tells apart several instruments or data streams that measure the same pollutant at the same site. EPA notes that the numbering is “somewhat arbitrary”. 1–33 in the PM2.5 files, 1–9 in the ozone files |
Latitude |
decimal number | Site latitude in decimal degrees north of the equator |
Longitude |
decimal number | Site longitude in decimal degrees east of the prime meridian (negative = west) |
Datum |
text | Geodetic datum of the coordinates: NAD83 or WGS84 (they differ by about a meter) |
Parameter Name |
text | Name of the parameter: PM2.5 - Local Conditions or Ozone. “Local conditions” means the air volume is taken at the site’s actual temperature and pressure |
Sample Duration |
text | How long air passes through the instrument before a value is measured. PM2.5: 24 HOUR (a filter collects particles for 24 hours and is weighed: one value per day), 1 HOUR (a continuous monitor; the row summarizes the day’s hourly values), 24-HR BLK AVG (a 24-hour block average that EPA calculates from the hourly values). Ozone: 8-HR RUN AVG BEGIN HOUR (8-hour running averages, each labeled by the hour it begins) |
Pollutant Standard |
text | The air-quality standard whose rules EPA used to build the day’s summary. PM2.5: PM25 24-hour 2012 (blank on 1 HOUR rows). Ozone: Ozone 8-hour 2015 on every row |
Date Local |
date text (YYYY-MM-DD) |
Calendar day of the summary: midnight to midnight in local standard time at the monitor (no daylight saving time) |
Units of Measure |
text | Micrograms/cubic meter (LC) for PM2.5 (µg/m³ at local conditions); Parts per million for ozone |
Event Type |
text | Whether data measured during exceptional events are in the day’s summary. EPA defines exceptional events as unusual or natural events, such as wildfires or high winds, that air agencies cannot reasonably control. None = no data were flagged; Included = flagged data are included; Excluded = flagged data are removed. A flagged day can have both an Included and an Excluded row. EPA’s page also lists “Concurred Events Excluded”, which does not occur in these files |
Observation Count |
integer | Number of observations behind the day’s values. PM2.5 1 HOUR rows: 1–24 hourly values; 24 HOUR rows: 1 (2 in a few rows); 24-HR BLK AVG rows: 1. Ozone: number of 8-hour averages, 17 on 96% of rows (1–34) |
Observation Percent |
decimal number | Observations as a percentage of those scheduled for the day (4–200; above 100 in 101 rows). 24-HR BLK AVG rows always show 100 |
Arithmetic Mean |
decimal number | Average value for the day, in Units of Measure. For ozone rows it is the average of the day’s 8-hour averages. PM2.5: −9.75 to 834 µg/m³ |
1st Max Value |
decimal number | Highest value of the day. For ozone rows this is the maximum daily 8-hour average (MDA8), in ppm. For PM2.5 24-hour rows it equals Arithmetic Mean |
1st Max Hour |
integer | Hour (0–23, local standard time) when the highest value occurred. For ozone rows, the hour the highest 8-hour period begins (7–23 in these files). 0 on PM2.5 24-hour rows, except 2,843 24 HOUR rows |
AQI |
integer | Air Quality Index for the day for this pollutant (0 = good; higher is worse). Blank on PM2.5 1 HOUR rows and in one ozone row |
Method Code |
text, 3 digits | Code of the sampling and analysis method. 32 codes in the PM2.5 files and 21 in the ozone files; blank in 241 ozone rows. Examples: 145 a filter sampler (R & P 2025); 170 and 209 Met One BAM monitors; 236/238 Teledyne T640/T640X as first reported; 736/738 the same T640/T640X data as corrected by EPA in 2024; 636/638 T640/T640X with network data alignment |
Method Name |
text | Short description of the method’s equipment and procedure |
Local Site Name |
text | Name the operating agency gives the site; blank on 164,582 PM2.5 rows and 177,909 ozone rows |
Address |
text | Approximate street address of the site |
State Name |
text | Name of the state (or Country Of Mexico, Canada) |
County Name |
text | Name of the county |
City Name |
text | City whose legal limits contain the site, or Not in a city |
CBSA Name |
text | Core-based statistical area (metropolitan or micropolitan area) containing the site; blank outside CBSAs |
Date of Last Change |
date text (YYYY-MM-DD) |
Date any number in the row was last updated in AQS (2023-02-05 to 2026-06-25 in these files) |
The file list file_list__dl20260927.csv has four columns. EPA’s download page describes its contents.
| Variable | Type | Description |
|---|---|---|
Filename |
text | Name of a file on the AirData download page, as EPA names it (daily_88101_2023.zip, without our __asof tag) |
Rows |
integer | Number of data rows in that file. The script checks every read against it |
Size |
integer | Size of the zipped file in kilobytes (KB), as shown on the download page |
Created |
date (YYYY-MM-DD) |
Date EPA generated the file (“As of” on the download page); 2026-06-25 for all 2,049 files |
Cleaning steps
The script R/m03_aqs.R makes these decisions:
- Read the zips directly, with types. readr reads the CSV inside each zip. Codes stay text so leading zeros survive, and every read is checked against EPA’s row count.
- One year at a time. The raw PM2.5 files hold 9.2 million rows (3.5 GB unzipped). Each year is read, reduced to site-days, and dropped before the next.
- (a) Event Type: keep
None+Included. This is the “events included” series: what the monitors measured, smoke days included.Excludedrows are a second copy of some flagged days with the flagged data removed, so keeping them would count those days twice. EPA writes anExcludedrow only for some flags: in 2023 there are 207,372IncludedPM2.5 rows but only 279Excludedrows. - (b) One value per instrument and day. The same PM2.5 instrument-day can appear once per sample duration, and
24 HOURand24-HR BLK AVGrows also carry a pollutant standard while1 HOURrows do not. Older (and maybe future) AirData files repeat each 24-hour row once per PM2.5 standard. This vintage has a single standard (PM25 24-hour 2012), so no row is repeated; astopifnot()check stops the script if a second standard appears, and then you would filter to one standard first. The script keeps one row per instrument-day: a24 HOURfilter sample first, then the24-HR BLK AVG, then the1 HOURmean. A day built from hourly values needs at least 75% of the hours (18 of 24), as in the project build. EPA writes24-HR BLK AVGrows only for days that meet its own completeness rule, and theirObservation Percentis always 100, so in practice our 75% filter acts only on the1 HOURfallback. - Average the POCs. The site-day value is the mean over the site’s POCs;
n_pocsays how many were averaged. - Ozone: the 2015 standard’s MDA8. Keep
Ozone 8-hour 2015rows, take1st Max Value(the maximum daily 8-hour average), and multiply by 1,000 to go from ppm to parts per billion (ppb). - County from the AQS code. A site’s county (GEOID) is its AQS state + county code when that code is one of the 3,108 counties in
counties_2020.csv. Otherwise the script uses the county that contains the site’s coordinates (point-in-polygon). Sites outside the contiguous U.S. are dropped (76 sites). - Keep negative PM2.5 values and flag them. AQS accepts small negative daily means as instrument noise near zero. Dropping them or setting them to 0 would push averages up.
- Round, then aggregate. Site-day values are rounded to 2 decimals. The county-day value is the simple mean over the county’s sites that reported that day, and the county-month file summarizes the county-day file.
- Sparse files. A county-day or county-month exists only if some monitor reported. No row means no reading, not zero.
- Write big files one year at a time, and free memory early. The three
.csv.gzfiles are written year by year into the same file, so the rows are sorted by year first. Objects that later steps do not need are removed withrm()as soon as possible. This keeps the script’s memory use near 2 GB.
The first chunk lists the zips and checks that each pollutant has exactly one file for every year from 2006 to 2025.
# AirData has one zip per pollutant and year. Parameter code 88101 is PM2.5
# measured by FRM/FEM monitors (federal reference or equivalent methods: the
# instruments EPA accepts for checking its air-quality standards); 44201 is
# ozone. "asof" in the name is the date EPA generated the file.
aqs_dir <- file.path(raw_dir, "aqs")
pm25_files <- list.files(aqs_dir, pattern = "^daily_88101_\\d{4}__asof\\d{8}\\.zip$",
full.names = TRUE)
o3_files <- list.files(aqs_dir, pattern = "^daily_44201_\\d{4}__asof\\d{8}\\.zip$",
full.names = TRUE)
# The year is the 4 digits after the parameter code. list.files() sorts the
# names, so the files come in year order.
file_year <- \(f) as.integer(str_match(basename(f), "^daily_\\d{5}_(\\d{4})")[, 2])
stopifnot(
identical(file_year(pm25_files), 2006:2025),
identical(file_year(o3_files), 2006:2025)
)EPA’s file list gives the number of data rows in each AirData file. We keep the rows for the daily PM2.5 and ozone files.
# EPA's index of every AirData file, saved on the day the project downloaded
# the zips. Its Rows column is the number of data rows in each file, so we
# can check that every zip is read completely.
file_list <- read_csv(
file.path(aqs_dir, "file_list__dl20260927.csv"),
col_types = cols(Filename = "c", Rows = "i", Size = "d", Created = "D")
)
expected_rows <- file_list |>
filter(str_detect(Filename, "^daily_(88101|44201)_")) |>
select(Filename, Rows)One function reads any of the 40 zips. It reads 15 of the 29 columns with explicit types, checks the row count against EPA’s list, and gives the columns short snake_case names.
# The daily files have 29 columns; we read the 15 we need. Codes stay text so
# that leading zeros survive ("01" is Alabama). Date Local is the calendar
# day at the monitor in local standard time (no daylight saving time).
aqs_cols <- cols_only(
`State Code` = "c", `County Code` = "c", `Site Num` = "c", POC = "i",
Latitude = "d", Longitude = "d", `Local Site Name` = "c",
`Sample Duration` = "c", `Pollutant Standard` = "c", `Date Local` = "D",
`Event Type` = "c", `Observation Percent` = "d",
`Arithmetic Mean` = "d", `1st Max Value` = "d", `Method Code` = "c"
)
read_aqs <- function(file) {
# readr reads the one CSV inside a .zip directly; no need to unzip.
aqs <- read_csv(file, col_types = aqs_cols, progress = FALSE)
# A broken download can lose rows without an error; compare with EPA's count.
zip_name <- str_remove(basename(file), "__asof\\d{8}")
stopifnot(identical(nrow(aqs), expected_rows$Rows[expected_rows$Filename == zip_name]))
aqs |>
transmute(
site_id = str_c(`State Code`, `County Code`, `Site Num`), # 2 + 3 + 4 characters
aqs_geoid = str_c(`State Code`, `County Code`), # county code used by AQS
poc = POC, latitude = Latitude, longitude = Longitude,
local_site_name = `Local Site Name`,
duration = `Sample Duration`, standard = `Pollutant Standard`,
date = `Date Local`, event_type = `Event Type`, obs_pct = `Observation Percent`,
daily_mean = `Arithmetic Mean`, daily_max = `1st Max Value`,
method = `Method Code`
)
}Before cleaning all twenty years, look at 2023. The counts show the three Event Type values and the three PM2.5 sample durations. The last table shows every row for one site on one smoky day: five rows for three POCs.
# Look at one year before cleaning all twenty. In 2023, smoke from Canadian
# fires covered much of the eastern U.S.
pm25_2023 <- read_aqs(pm25_files[file_year(pm25_files) == 2023])
count(pm25_2023, event_type)
count(pm25_2023, duration, standard)
# All rows for one site on one smoky day: Queens College, New York City,
# June 6, 2023. POC (parameter occurrence code) numbers the PM2.5 data
# streams at the site. POC 1 is a filter sampler (method 145). POCs 4 and 24
# are one Teledyne T640 instrument reported twice: as first measured (method
# 236) and as corrected by EPA in 2024 (method 736).
pm25_2023 |>
filter(site_id == "360810124", date == as.Date("2023-06-06")) |>
select(poc, method, duration, standard, event_type, obs_pct, daily_mean)This function turns one year of raw PM2.5 rows into one value per site and day. It applies teaching points (a) and (b) above, and stopifnot() confirms that no instrument has two rows for the same day and duration once Excluded rows are gone.
# Preference order when one instrument reports the same day more than once:
# a 24-hour filter sample, then EPA's 24-hour average of the hourly readings
# ("24-HR BLK AVG"), then the plain mean of the hourly readings ("1 HOUR").
duration_rank <- c("24 HOUR" = 1, "24-HR BLK AVG" = 2, "1 HOUR" = 3)
pm25_site_day <- function(aqs) {
pm25_rows <- aqs |>
# (a) Event Type: "None" = no exceptional event was flagged; "Included" =
# flagged data (for example, wildfire smoke) kept in the day's value;
# "Excluded" = the same day with the flagged data removed. None + Included
# keeps smoky days as they were measured.
filter(event_type %in% c("None", "Included")) |>
# A day built from hourly readings needs at least 75% of them (18 of 24).
# EPA writes 24-HR BLK AVG rows only for days that meet its own
# completeness rule, so they always show 100 here: in practice this filter
# acts only on the 1 HOUR fallback. 24-hour samples are one reading, so
# they are kept as they are.
filter(duration == "24 HOUR" |
(duration %in% names(duration_rank) & obs_pct >= 75))
# Within None + Included, an instrument has at most one row per day and
# sample duration, so the next step never has to choose between events.
# Older (and maybe future) AirData files repeat each 24-hour row once per
# PM2.5 standard. This vintage has a single standard ("PM25 24-hour 2012"),
# so no row is repeated. If a second standard appears, this check stops the
# script: then filter to one value of `standard` first.
stopifnot(nrow(distinct(pm25_rows, site_id, poc, date, duration)) == nrow(pm25_rows))
pm25_rows |>
# (b) Sort so the preferred duration comes first, then keep the first row
# of each instrument-day.
mutate(rank = duration_rank[duration]) |>
arrange(site_id, poc, date, rank) |>
distinct(site_id, poc, date, .keep_all = TRUE) |>
# Average the site's POCs: one row per site and day. Like the project
# build, we keep every method code, so a T640 with original and corrected
# data counts twice (see "Checks and cautions" in the guide).
summarize(pm25 = mean(daily_mean), n_poc = n(), .by = c(site_id, date))
}Apply the rules to 2023 and look at the Queens College day again. The count of n_poc shows that most site-days have one or two POCs.
# Try the rules on 2023. The Queens College day becomes one row: the mean of
# its three POCs, (58.6 + 101 + 99.5) / 3 = 86.4 µg/m³.
pm25_days_2023 <- pm25_site_day(pm25_2023)
filter(pm25_days_2023, site_id == "360810124", date == as.Date("2023-06-06"))
count(pm25_days_2023, n_poc)
rm(pm25_2023) # free memory before reading all yearsNow run every year. map() returns, for each year, the year’s site list, its Event Type counts, and its site-days; list_rbind() stacks each piece across years.
# Run the same steps on every year. Each raw file is read, used, and dropped,
# so only one year of raw rows is in memory at a time (2023 alone has 847,162).
pm25_by_year <- map(pm25_files, \(f) {
aqs <- read_aqs(f)
list(
sites = distinct(aqs, site_id, aqs_geoid, latitude, longitude, local_site_name),
events = count(aqs, year = year(date), event_type),
days = pm25_site_day(aqs)
)
})
pm25_days_all <- map(pm25_by_year, "days") |> list_rbind()
pm25_sites <- map(pm25_by_year, "sites") |> list_rbind()
pm25_events <- map(pm25_by_year, "events") |> list_rbind()
rm(pm25_by_year)
# Raw rows by Event Type and year. "Included" rows rise sharply in 2018-2023.
# Most of that rise is the original T640 data (method codes 236 and 238),
# which carries an event flag on almost every day, not extra smoke flags.
pm25_events |>
pivot_wider(names_from = event_type, values_from = n, values_fill = 0) |>
print(n = 20)Ozone needs no duration ranking, because each instrument has a single 8-hour-standard row per day. The function keeps those rows and converts ppm to ppb.
# Ozone rows follow the 2015 8-hour standard. For those rows, "1st Max Value"
# is the day's highest 8-hour running average (MDA8: maximum daily 8-hour
# average), in parts per million (ppm). 1 ppm = 1,000 parts per billion (ppb).
# Every ozone row in these files uses this standard; filtering on it makes
# the choice explicit and protects us if EPA adds rows for other standards.
o3_site_day <- function(aqs) {
o3_rows <- aqs |>
filter(event_type %in% c("None", "Included"),
standard == "Ozone 8-hour 2015")
# One row per instrument and day, so no reading is counted twice.
stopifnot(nrow(distinct(o3_rows, site_id, poc, date)) == nrow(o3_rows))
o3_rows |>
summarize(o3_mda8_ppb = 1000 * mean(daily_max), n_poc = n(),
.by = c(site_id, date))
}The same loop for the ozone files.
# Same pattern as for PM2.5: read one year, keep its site list, Event Type
# counts, and site-days, then drop the raw rows.
o3_by_year <- map(o3_files, \(f) {
aqs <- read_aqs(f)
list(
sites = distinct(aqs, site_id, aqs_geoid, latitude, longitude, local_site_name),
events = count(aqs, year = year(date), event_type),
days = o3_site_day(aqs)
)
})
o3_days_all <- map(o3_by_year, "days") |> list_rbind()
o3_sites <- map(o3_by_year, "sites") |> list_rbind()
o3_events <- map(o3_by_year, "events") |> list_rbind()
rm(o3_by_year)
o3_events |>
pivot_wider(names_from = event_type, values_from = n, values_fill = 0) |>
print(n = 20)Give each site a county. The printed tables show how many sites kept their AQS county code, which sites lie outside the contiguous U.S., and which sites have coordinates in a different county than their code.
# One row per site. A site has the same coordinates and name in every year
# and in both pollutants' files, so distinct() leaves one row per site_id.
site_info <- bind_rows(pm25_sites, o3_sites) |> distinct()
stopifnot(!anyDuplicated(site_info$site_id))
county_ids <- read_csv(file.path(clean_dir, "counties_2020.csv"),
col_types = cols(GEOID = "c"), col_select = GEOID)$GEOID
county_shapes <- read_sf(file.path(clean_dir, "counties_2020.gpkg")) |>
select(geoid_pip = GEOID)
# Point-in-polygon: which 2020 county contains each site? Latitude and
# longitude are in NAD83 or WGS84 (see Datum), which differ by about a meter,
# so we treat both as WGS84 (EPSG:4326).
sites_geo <- site_info |>
st_as_sf(coords = c("longitude", "latitude"), crs = 4326, remove = FALSE) |>
st_join(county_shapes, join = st_within) |>
st_drop_geometry() |>
# Use the AQS county code when it is a 2020 county; otherwise use the county
# that contains the site. These files still give Connecticut sites the 8
# 2020 county codes, so the fallback would matter only if EPA switched them
# to the 2022 planning-region codes (09110-09190). Sites outside the
# contiguous U.S. fall in no county and get NA.
mutate(GEOID = if_else(aqs_geoid %in% county_ids, aqs_geoid, geoid_pip))
stopifnot(nrow(sites_geo) == nrow(site_info)) # no site landed in two counties
# Sites by rule: code kept, or no county (outside the contiguous U.S.)
count(sites_geo, code_is_county = aqs_geoid %in% county_ids, has_GEOID = !is.na(GEOID))
# Sites with no GEOID, by AQS state code (02 AK, 15 HI, 72 PR, 78 VI,
# 80 Mexico, CC Canada)
sites_geo |> filter(is.na(GEOID)) |> count(state_code = str_sub(site_id, 1, 2))
# Sites whose coordinates fall in a different county than their code says
sites_geo |>
filter(!is.na(GEOID), !is.na(geoid_pip), geoid_pip != GEOID) |>
select(site_id, local_site_name, GEOID, geoid_pip)
# The county shapes and the per-pollutant site lists are no longer needed.
# rm() deletes them and gc() hands their memory back right away.
rm(county_shapes, site_info, pm25_sites, o3_sites)
invisible(gc())Define a helper that writes a big table one year at a time, then attach the county, round, flag negative values, check the grain, and write the PM2.5 site-day file.
# A helper that writes a big table one year at a time. Sorting millions of
# rows and writing them as one block both need a full extra copy in memory
# (write_csv() builds the text before compressing it): about 1 GB more for
# these files. One year at a time needs only a small fraction of that. Each
# year's rows are sorted by `key` (site_id or GEOID) and date, then added to
# the end of the same .csv.gz file (append = TRUE; the header is written only
# for the first year). read_csv() reads the result as one table, sorted by
# year, then key, then date.
write_csv_by_year <- function(data, key, path) {
years <- year(data$date)
for (y in sort(unique(years))) {
data[years == y, ] |>
arrange(.data[[key]], date) |>
write_csv(path, na = "", append = y != min(years))
}
}
# Keep sites in the contiguous U.S. and attach their county. Averages are
# rounded to 2 decimals; every later file is built from these rounded values.
site_geoid <- sites_geo |> filter(!is.na(GEOID)) |> select(site_id, GEOID)
pm25_site_day_out <- pm25_days_all |>
inner_join(site_geoid, by = "site_id") |>
mutate(pm25 = round(pm25, 2)) |>
# Negative daily means are instrument noise near zero. AQS accepts them,
# and dropping them or setting them to 0 would bias averages upward, so we
# keep them and flag them.
transmute(site_id, GEOID, date, pm25, n_poc, pm25_negative = pm25 < 0)
rm(pm25_days_all) # the unrounded copy is no longer needed; free the memory
invisible(gc())
# Grain: one row per site and day; no missing values. n_distinct() counts
# the distinct site-day pairs without building a second table.
stopifnot(
n_distinct(pm25_site_day_out$site_id, pm25_site_day_out$date) ==
nrow(pm25_site_day_out),
!anyNA(pm25_site_day_out),
all(year(pm25_site_day_out$date) %in% 2006:2025),
all(between(pm25_site_day_out$pm25, -10, 1000)),
all(pm25_site_day_out$n_poc >= 1)
)
write_csv_by_year(pm25_site_day_out, "site_id",
file.path(clean_dir, "aqs_pm25_site_day_2006_2025.csv.gz"))The same steps for ozone.
o3_site_day_out <- o3_days_all |>
inner_join(site_geoid, by = "site_id") |>
transmute(site_id, GEOID, date, o3_mda8_ppb = round(o3_mda8_ppb, 2), n_poc)
rm(o3_days_all)
invisible(gc())
# Grain: one row per site and day; no missing values; ozone in a plausible
# range (a reading slightly below zero is instrument noise, as for PM2.5).
stopifnot(
n_distinct(o3_site_day_out$site_id, o3_site_day_out$date) ==
nrow(o3_site_day_out),
!anyNA(o3_site_day_out),
all(year(o3_site_day_out$date) %in% 2006:2025),
all(between(o3_site_day_out$o3_mda8_ppb, -5, 250)),
all(o3_site_day_out$n_poc >= 1)
)
write_csv_by_year(o3_site_day_out, "site_id",
file.path(clean_dir, "aqs_ozone_site_day_2006_2025.csv.gz"))The site list is built from the clean site-day files, so every site in it has at least one clean day.
# First and last day with a clean value, for each pollutant.
pm25_span <- pm25_site_day_out |>
summarize(pm25_first = min(date), pm25_last = max(date), .by = site_id)
o3_span <- o3_site_day_out |>
summarize(o3_first = min(date), o3_last = max(date), .by = site_id)
# inner_join() keeps only sites with at least one clean day of either
# pollutant: a site whose every row was dropped above has nothing to offer.
aqs_sites <- sites_geo |>
filter(!is.na(GEOID)) |>
inner_join(full_join(pm25_span, o3_span, by = "site_id"), by = "site_id") |>
transmute(
site_id, latitude, longitude, GEOID, local_site_name,
measures_pm25 = !is.na(pm25_first),
measures_o3 = !is.na(o3_first),
first_date = pmin(pm25_first, o3_first, na.rm = TRUE),
last_date = pmax(pm25_last, o3_last, na.rm = TRUE)
) |>
arrange(site_id)
# Grain: one row per site; every site is in a county of the frame.
stopifnot(
!anyDuplicated(aqs_sites$site_id),
all(aqs_sites$GEOID %in% county_ids),
all(aqs_sites$measures_pm25 | aqs_sites$measures_o3),
all(aqs_sites$first_date <= aqs_sites$last_date),
all(between(aqs_sites$latitude, 24, 50)), all(between(aqs_sites$longitude, -125, -66))
)
write_csv(aqs_sites, file.path(clean_dir, "aqs_sites.csv"), na = "")
count(aqs_sites, measures_pm25, measures_o3)Average the sites in each county and day, one year at a time, and write the county-day file. After that, the two site-day tables are removed from memory.
# County value = simple mean across the county's sites that reported that
# day. Counties with no monitor that day have no row. We build one year at a
# time: summarize() over all 5.4 million county-days at once needs about
# 2.5 GB of extra memory; year by year, the whole step stays under 1 GB.
# between() on dates is much faster than computing year(date) for millions
# of rows.
county_day_one_year <- function(y) {
first_day <- make_date(y, 1, 1)
last_day <- make_date(y, 12, 31)
pm25_county_day <- pm25_site_day_out |>
filter(between(date, first_day, last_day)) |>
summarize(pm25_mean = round(mean(pm25), 2), n_sites_pm25 = n(),
.by = c(GEOID, date))
o3_county_day <- o3_site_day_out |>
filter(between(date, first_day, last_day)) |>
summarize(o3_mda8_ppb = round(mean(o3_mda8_ppb), 2), n_sites_o3 = n(),
.by = c(GEOID, date))
# full_join() keeps a county-day if either pollutant was measured; the
# other pollutant's columns are then NA (no monitor reading, not zero).
full_join(pm25_county_day, o3_county_day, by = c("GEOID", "date"))
}
aqs_county_day <- map(2006:2025, county_day_one_year) |>
list_rbind()
# Grain: one row per county and day with at least one monitor reading.
stopifnot(
n_distinct(aqs_county_day$GEOID, aqs_county_day$date) == nrow(aqs_county_day),
all(aqs_county_day$GEOID %in% county_ids),
all(!is.na(aqs_county_day$pm25_mean) | !is.na(aqs_county_day$o3_mda8_ppb)),
sum(aqs_county_day$n_sites_pm25, na.rm = TRUE) == nrow(pm25_site_day_out),
sum(aqs_county_day$n_sites_o3, na.rm = TRUE) == nrow(o3_site_day_out)
)
write_csv_by_year(aqs_county_day, "GEOID",
file.path(clean_dir, "aqs_county_day_2006_2025.csv.gz"))
# The site-day tables are saved; the monthly step needs only aqs_county_day.
rm(pm25_site_day_out, o3_site_day_out)
invisible(gc())Finally, summarize the county-day file by month: average levels, the number of days with a reading, and the number of days above the levels of the national standards.
# Monthly summaries of the county-day values. 35.4 µg/m³ and 70 ppb are the
# levels of the 24-hour PM2.5 standard (35, after rounding) and the 8-hour
# ozone standard. A daily value above them is not by itself a violation:
# the standards apply to 3-year statistics.
aqs_county_month <- aqs_county_day |>
rename(pm25_day = pm25_mean, o3_day = o3_mda8_ppb) |>
# floor_date() moves each date to the first day of its month. It is one
# cheap step, where year() and month() would each convert 5.4 million dates.
mutate(month_start = floor_date(date, "month")) |>
summarize(
pm25_mean = round(mean(pm25_day, na.rm = TRUE), 2),
pm25_days = sum(!is.na(pm25_day)),
pm25_days_gt35 = sum(pm25_day > 35.4, na.rm = TRUE),
o3_mda8_mean_ppb = round(mean(o3_day, na.rm = TRUE), 2),
o3_days = sum(!is.na(o3_day)),
o3_days_gt70 = sum(o3_day > 70, na.rm = TRUE),
.by = c(GEOID, month_start)
) |>
mutate(
year = year(month_start), month = month(month_start), .after = GEOID,
# mean() of no values gives NaN; store "no reading" as NA instead.
pm25_mean = if_else(pm25_days > 0, pm25_mean, NA),
o3_mda8_mean_ppb = if_else(o3_days > 0, o3_mda8_mean_ppb, NA)
) |>
select(-month_start) |>
arrange(GEOID, year, month)
# Grain: one row per county and month with at least one monitor reading.
stopifnot(
nrow(distinct(aqs_county_month, GEOID, year, month)) == nrow(aqs_county_month),
all(aqs_county_month$pm25_days + aqs_county_month$o3_days > 0),
all(aqs_county_month$pm25_days <= 31), all(aqs_county_month$o3_days <= 31),
all(aqs_county_month$pm25_days_gt35 <= aqs_county_month$pm25_days),
all(aqs_county_month$o3_days_gt70 <= aqs_county_month$o3_days)
)
write_csv(aqs_county_month,
file.path(clean_dir, "aqs_county_month_2006_2025.csv"), na = "")Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
aqs_sites.csv |
2,773 | a site in the contiguous U.S. or DC with at least one clean PM2.5 or ozone day | 0.24 MB |
aqs_pm25_site_day_2006_2025.csv.gz |
4,214,339 | a site-day with a PM2.5 value | 19.1 MB (165.3 MB unzipped) |
aqs_ozone_site_day_2006_2025.csv.gz |
7,497,771 | a site-day with an ozone MDA8 value | 27.9 MB (240.0 MB unzipped) |
aqs_county_day_2006_2025.csv.gz |
5,448,481 | a county-day with at least one PM2.5 or ozone reading | 26.3 MB (142.7 MB unzipped) |
aqs_county_month_2006_2025.csv |
202,737 | a county-month with at least one PM2.5 or ozone reading | 6.3 MB |
The network changes over time. The table counts sites, counties, and site-days in the two site-day files:
| Year | PM2.5 sites | PM2.5 counties | PM2.5 site-days | Ozone sites | Ozone counties | Ozone site-days |
|---|---|---|---|---|---|---|
| 2006 | 976 | 603 | 125,266 | 1,249 | 751 | 349,167 |
| 2007 | 944 | 595 | 133,786 | 1,275 | 764 | 355,700 |
| 2008 | 923 | 585 | 129,382 | 1,279 | 768 | 356,642 |
| 2009 | 929 | 589 | 142,389 | 1,289 | 779 | 363,366 |
| 2010 | 923 | 594 | 153,108 | 1,316 | 793 | 371,794 |
| 2011 | 839 | 547 | 148,927 | 1,314 | 797 | 378,040 |
| 2012 | 846 | 544 | 156,607 | 1,318 | 788 | 381,019 |
| 2013 | 877 | 567 | 172,528 | 1,302 | 786 | 381,543 |
| 2014 | 930 | 593 | 183,738 | 1,297 | 789 | 381,778 |
| 2015 | 946 | 611 | 195,375 | 1,285 | 783 | 378,528 |
| 2016 | 942 | 604 | 206,775 | 1,275 | 782 | 382,055 |
| 2017 | 958 | 604 | 220,012 | 1,274 | 783 | 390,471 |
| 2018 | 973 | 618 | 237,360 | 1,269 | 783 | 390,036 |
| 2019 | 964 | 616 | 250,854 | 1,250 | 774 | 380,996 |
| 2020 | 943 | 607 | 255,992 | 1,243 | 764 | 383,592 |
| 2021 | 967 | 610 | 270,948 | 1,239 | 763 | 382,237 |
| 2022 | 967 | 613 | 283,157 | 1,232 | 766 | 374,493 |
| 2023 | 988 | 623 | 298,456 | 1,220 | 759 | 375,119 |
| 2024 | 1,008 | 630 | 320,922 | 1,234 | 763 | 373,327 |
| 2025 | 1,016 | 630 | 328,757 | 1,202 | 750 | 367,868 |
Clean variables
aqs_sites.csv: one row per site, sorted by site_id. No cell is missing except local_site_name (blank for 203 sites).
| Variable | Type | Units | Description |
|---|---|---|---|
site_id |
chr | — | 9-character AQS site ID: state (2) + county (3) + site number (4), from the raw State Code, County Code, and Site Num |
latitude |
dbl | degrees | Site latitude (raw Latitude, NAD83 or WGS84) |
longitude |
dbl | degrees | Site longitude (raw Longitude; negative = west) |
GEOID |
chr | — | 5-character county FIPS code (state + county), 2020 boundaries; matches counties_2020.csv. The AQS county code for all 2,773 sites (see “Checks and cautions”) |
local_site_name |
chr | — | Site name from the raw Local Site Name |
measures_pm25 |
lgl | — | TRUE if the site has at least one day in the PM2.5 site-day file |
measures_o3 |
lgl | — | TRUE if the site has at least one day in the ozone site-day file |
first_date |
date | — | First day with a clean PM2.5 or ozone value |
last_date |
date | — | Last day with a clean PM2.5 or ozone value |
aqs_pm25_site_day_2006_2025.csv.gz: one row per site and day with a PM2.5 value, sorted by year, then site_id, then date. No cell is missing. Days without a valid reading have no row.
| Variable | Type | Units | Description |
|---|---|---|---|
site_id |
chr | — | 9-character AQS site ID; links to aqs_sites.csv |
GEOID |
chr | — | 5-character county FIPS code of the site (2020 boundaries) |
date |
date | — | Local calendar day (raw Date Local, local standard time) |
pm25 |
dbl | µg/m³ | Daily PM2.5, 2 decimals: the mean over the site’s POCs of each POC’s chosen daily value (24 HOUR, else 24-HR BLK AVG, else 1 HOUR with at least 75% of hours), using None and Included rows. Can be negative (down to −9.70) |
n_poc |
dbl | count | Number of POCs averaged into pm25 (1–6). A T640 with both original and corrected data counts as 2 (see “A T640 can count twice”) |
pm25_negative |
lgl | — | TRUE if pm25 is below 0 |
aqs_ozone_site_day_2006_2025.csv.gz: one row per site and day with an ozone value, sorted by year, then site_id, then date. No cell is missing. Many sites report only in the warm months (ozone season).
| Variable | Type | Units | Description |
|---|---|---|---|
site_id |
chr | — | 9-character AQS site ID; links to aqs_sites.csv |
GEOID |
chr | — | 5-character county FIPS code of the site (2020 boundaries) |
date |
date | — | Local calendar day (raw Date Local, local standard time) |
o3_mda8_ppb |
dbl | ppb | Maximum daily 8-hour average ozone (MDA8): raw 1st Max Value of the Ozone 8-hour 2015 rows × 1,000, averaged over POCs, 2 decimals |
n_poc |
dbl | count | Number of POCs averaged (1, 2, or 4) |
aqs_county_day_2006_2025.csv.gz: one row per county and day on which at least one monitor in the county reported PM2.5 or ozone, sorted by year, then GEOID, then date. Use arrange(GEOID, date) after reading if you want each county’s days together. A county-day with no row had no reading at all.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (2020 boundaries); matches counties_2020.csv |
date |
date | — | Local calendar day |
pm25_mean |
dbl | µg/m³ | Simple mean of pm25 over the county’s sites that reported that day, 2 decimals. Blank (NA) if no PM2.5 site reported; this happens on 2,563,528 county-days with ozone only |
n_sites_pm25 |
dbl | count | Number of sites in pm25_mean; blank if none |
o3_mda8_ppb |
dbl | ppb | Simple mean of o3_mda8_ppb over the county’s sites that reported that day, 2 decimals. Blank (NA) if no ozone site reported |
n_sites_o3 |
dbl | count | Number of sites in o3_mda8_ppb; blank if none |
aqs_county_month_2006_2025.csv: one row per county and month with at least one county-day, sorted by GEOID, year, and month. All values come from the county-day file.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (2020 boundaries) |
year |
dbl | — | Calendar year, 2006–2025 |
month |
dbl | — | Calendar month, 1–12 |
pm25_mean |
dbl | µg/m³ | Mean of pm25_mean over the county-days with a PM2.5 reading, 2 decimals. Blank (NA) if pm25_days is 0 |
pm25_days |
dbl | days | Number of days with a PM2.5 reading (0–31) |
pm25_days_gt35 |
dbl | days | Days with pm25_mean above 35.4 µg/m³, the highest value that still rounds to the 24-hour standard of 35 µg/m³ |
o3_mda8_mean_ppb |
dbl | ppb | Mean of o3_mda8_ppb over the county-days with an ozone reading, 2 decimals. Blank (NA) if o3_days is 0 |
o3_days |
dbl | days | Number of days with an ozone reading (0–31) |
o3_days_gt70 |
dbl | days | Days with o3_mda8_ppb above 70 ppb, the level of the 2015 ozone standard |
Checks and cautions
- Matches the project build.
R/check_m03.Rcompares every clean file with the research project’s files (series “events included”). The site list has the same 2,773 sites, with the same GEOIDs and coordinates. The PM2.5 site-day file has the same 4,214,339 site-days;pm25differs by at most 0.005 µg/m³ (rounding), andn_poc,GEOID, andpm25_negativeare identical. The county-day file has the same 5,448,481 county-days and the same site counts; means differ by at most 0.0092 µg/m³ and 0.005 ppb. The county-month file has the same 202,737 county-months; all day counts agree exceptpm25_days_gt35, which is one day lower in 9 county-months, because we compare the rounded county-day values with 35.4. The project build does not store an ozone site-day file, but its county-day ozone values match ours. - Raw files are complete. All 40 zips are byte-identical to the project’s copies, and every read has exactly the number of rows EPA lists.
- Sparse coverage. Only 1,080 of 3,108 counties have a site (PM2.5: 804, ozone: 897). On a typical day, 452 counties have a PM2.5 reading and 725 an ozone reading (medians over days). Many filter samplers run only every third or sixth day, and many ozone monitors run only in the warm months: 10.1% of ozone site-days fall in July but 6.0% in January. A county with no row on a day was not measured; do not treat it as clean air.
- “Included” is mostly T640 data in 2018–2023. EPA flags exceptional events, such as wildfire smoke, but the big rise of
IncludedPM2.5 rows after 2017 is not smoke. In the raw 2018–2023 PM2.5 files (all rows, including those outside the contiguous U.S.), 99.6% of the original T640 rows (method codes 236 and 238) areIncluded, against 3.3% of other monitors’ rows (83,558 of 2,549,528) and 7.0% of the corrected T640 rows (736 and 738). So theIncludedcount per year tells you little about smoke. - Events excluded instead? The project also builds an “events excluded” series from the
None+Excludedrows. It has 4,053,533 site-days: 160,806 of our site-days have no value in it, and on another 457,388 the value differs by more than 0.005 µg/m³. Most of these differences fall in 2018–2023 and come from the T640 flags described above, not from smoke. In June 2023, 9,864 of 24,028 site-days differ (mean 18.09 µg/m³ with events included, 17.40 with events excluded) and 1,404 are missing from the excluded series. To build that series yourself, change the filter inpm25_site_day()toevent_type %in% c("None", "Excluded"). Be aware of what it does: mostIncludedrows have noExcludedtwin (EPA writes anExcludedrow only for some flags), so this filter drops those days instead of giving them a smoke-free value. That is why 160,806 site-days are missing from that series. - A T640 can count twice. EPA corrected the Teledyne T640 and T640X PM2.5 data in 2024 and kept both versions under the same parameter code, as separate POCs. Its data advisory recommends using the corrected data (736, 738) and excluding the originals (236, 238). Like the project build, our files keep both, so on 450,979 site-days (10.7%) one T640 enters the site mean twice, once original and once corrected. This happens mostly in 2017–2023: on 39.7% of site-days in 2022. At Queens College on 6 June 2023,
pm25= 86.37 µg/m³ is the mean of a filter (58.6), the original T640 (101.0), and the corrected T640 (99.5); following the advisory gives (58.6 + 99.5) / 2 = 79.05. Over all 450,979 site-days, dropping the originals lowers the site mean by 0.53 µg/m³ on average (median −0.50, or −8.1%; 90% of changes between −0.90 and −0.20). Another 6,125 site-days have an original T640 POC with no corrected twin. To follow the advisory, addfilter(!method %in% c("236", "238"))before the duration ranking inpm25_site_day(). - Which PM2.5 row is used. Of the kept instrument-days, 36.5% use a
24 HOURfilter sample and 63.5% use EPA’s24-HR BLK AVG. The script cannot check the completeness of the24-HR BLK AVGvalues itself: theirObservation Percentis always 100, because EPA writes them only for days that meet its completeness rule. The1 HOURfallback is never used: every instrument-day whose hourly values pass the 75% rule also has a higher-ranked row. The rule still matters. It removes 64,552NoneorIncluded1 HOURrows with fewer than 18 hourly values. Only 76 of them have a higher-ranked row for the same instrument-day, so without the rule 44,288 extra site-days in the contiguous U.S. would enter through the fallback, each built from an incomplete day. In 2006–2007 the files have no continuous monitors, only filter samples. - Negative values. 14,491 PM2.5 site-days (0.34%) are below 0, the lowest at −9.70 µg/m³; they are kept and flagged with
pm25_negative. 5 ozone site-days are below 0 (lowest −3.00 ppb). Two county-day cells and one county-month cell are written as-0(a mean that rounds to zero from below);read_csv()reads them as 0. - Ozone completeness. For the 2015 standard, EPA counts an MDA8 day as complete when at least 13 of its 17 8-hour periods are available. 162,531 of the kept ozone rows (2.1%) have fewer; 1,345 of those are above 70 ppb. Like the project build, we keep them.
- County assignment. All 2,773 sites keep their AQS county code. These files give Connecticut sites the 2020 county codes, so no site in the contiguous U.S. needs point-in-polygon. For 8 sites the coordinates fall in a different county than the code, for example a DC school (
110010025) that plots in Charles County, Maryland; we trust the code, as the project does (its planar test flags 6 of these 8). 76 sites are outside the contiguous U.S.: Alaska 18, Hawaii 22, Puerto Rico 17, Virgin Islands 3, Mexico 14, Canada 2. - June 2023. County-days above 35.4 µg/m³ peak in June 2023 (862 of the year’s 1,747). On 7 June 2023 the five highest county means were all in Pennsylvania: Northampton (
42095, 258.00 µg/m³), Lehigh (42077, 242.80), Lackawanna (42069, 216.75), Berks (42011, 211.85), and Montgomery (42091, 208.65). Philadelphia (42101, 207.71, mean of 6 sites) and Queens County, New York (36081, 202.60) came next. - Extremes. The highest PM2.5 site-day is 833.80 µg/m³ (Lynn Water Treatment Plant, Essex County, Massachusetts, 10 November 2024). Lee Vining in Mono County, California, recorded 616–823 µg/m³ on 14–17 September 2020. The highest ozone MDA8 is 183 ppb.
- Local standard time.
dateis the local standard day at the monitor. Products built on UTC (Coordinated Universal Time, the clock at Greenwich without daylight saving) can be off by one day around midnight; keep this in mind when you join AQS with satellite or model data. - Reading the files.
read_csv()guessesGEOIDandsite_idas text and the counts as numbers, but givecol_typesanyway, withGEOIDandsite_idas character. The county files have long runs of blank cells, and a column that is blank in the first 1,000 rows would be guessed wrongly. - Memory and time. The script took 4–6.5 minutes in our runs, most of it reading the 40 zips and building the county-day file. Its peak memory, as Activity Monitor counts it, was about 2.1 GB. The operating system’s maximum resident-set count was about 2.75 GB, because it also counts parts of the unzipped CSV files that readr keeps on disk and maps into memory. Writing the big files one year at a time and removing objects early with
rm()brought the peak down from about 2.85 GB. On an 8 GB laptop, close other memory-heavy programs (browsers with many tabs, other R sessions) before you run it.
NOAA nClimGrid-Daily county weather
nClimGrid-Daily gives the daily maximum temperature, minimum temperature, and precipitation of every county in the contiguous United States. NOAA’s National Centers for Environmental Information (NCEI) builds it from daily reports of thousands of weather stations (the Global Historical Climatology Network–Daily, GHCNd). NCEI interpolates the reports (estimates values between the stations) onto a grid of cells about 4 km wide, scales the daily values to match its monthly nClimGrid product, and then averages the grid cells inside each county.
In this project, weather is a control variable: heat and rain move with smoke and also matter on their own, so a smoke effect estimated without weather can pick up a weather effect. The data tell you how warm and wet a county was on each day. They say nothing about wind, humidity, or smoke, and a single county-day value is an estimate with real error.
| Provider | NOAA National Centers for Environmental Information (NCEI), nClimGrid-Daily version 1.0.0, county area averages |
| Coverage | 2006–2025 (the provider covers 1951 to the present) · 3,107 county series covering the contiguous U.S. and DC; the clean files cover all 3,108 counties of counties_2020.csv |
| Raw unit | one row = one county in one month for one variable (tmax, tmin, or prcp), with 31 day columns |
| Clean unit | one row = one county on one day (daily files), or one county in one month (monthly file) |
| Licence and citation | U.S. Government work, public domain. Cite Durre, I., M. F. Squires, R. S. Vose, A. Arguez, W. S. Gross, J. R. Rennie, and C. J. Schreck (2022), “NOAA’s nClimGrid-Daily Version 1 – Daily gridded temperature and precipitation for the Contiguous United States since 1951,” NOAA NCEI, doi:10.25921/c4gt-r169, and Durre, I., A. Arguez, C. J. Schreck III, M. F. Squires, and R. S. Vose (2022), “Daily high-resolution temperature and precipitation fields for the Contiguous United States from 1951 to present,” Journal of Atmospheric and Oceanic Technology, doi:10.1175/JTECH-D-22-0024.1 |
| Documentation | nClimGrid-Daily product page; README of the data folder and user guide (copies in wildfire_data/raw/nclimgrid/doc/); data folder |
Raw files
File (in wildfire_data/raw/) |
Size | Format | One row is |
|---|---|---|---|
nclimgrid/cty/<year>/tmax-<YYYYMM>-cty-scaled.csv (240 files: one per month, 2006–2025, in one folder per year) |
1.09 MB each; 262.4 MB in all | comma-separated text, ASCII, no header row; 37 columns, values padded to fixed width | one county in one month: daily maximum temperature (3,107 rows per file) |
nclimgrid/cty/<year>/tmin-<YYYYMM>-cty-scaled.csv (240 files) |
1.09 MB each; 262.4 MB in all | same | one county in one month: daily minimum temperature |
nclimgrid/cty/<year>/prcp-<YYYYMM>-cty-scaled.csv (240 files) |
1.09 MB each; 262.4 MB in all | same | one county in one month: daily precipitation |
nclimgrid/doc/us-state-codes_ncei-to-fips.csv |
0.001 MB | comma-separated text with a header row | one state: its NCEI state number and its FIPS code, the federal state number (48 rows) |
nclimgrid/doc/nclimgrid-daily_v1-0-0_readme-web.txt |
0.007 MB | plain text | provider README: file names, region types, and the CSV layout |
nclimgrid/doc/nclimgrid-daily_v1-0-0_user-guide.pdf |
0.12 MB | provider user guide: method, accuracy, references | |
nclimgrid/doc/nclimgrid-daily_status-web.txt |
0.002 MB | plain text | provider release notes for version 1.0.0 |
In the file names, scaled means final values that NCEI has scaled to match its monthly product. The newest months appear first as prelim (preliminary) files; every file shipped here is scaled. NCEI also publishes tavg files and other region types (states, climate divisions, census tracts); the bundle has only the county files for tmax, tmin, and prcp.
Raw variables
The 720 county files (tmax-, tmin-, and prcp-<YYYYMM>-cty-scaled.csv) share this layout, described in the provider’s README and user guide; the files have no header row, so the names in parentheses are the ones the script gives the columns.
| Variable | Type | Description |
|---|---|---|
Column 1 (region_type) |
text, 3 characters | Type of region; always cty (county) in these files |
Column 2 (region_code) |
text, 5 digits | NCEI region code: the 2-digit NCEI state number, then the 3-digit county FIPS code. It is not a FIPS county code: Monroe County, New York, is 30055 here (NCEI’s New York is 30) but 36055 in Census files. DC is 18511, filed under NCEI’s Maryland (18). The provider documents the state part; we confirmed the county part by matching all 3,106 codes other than DC to Census counties (DC’s 18511 is recoded to 11001 by hand) |
Column 3 (region_name) |
text | State postal abbreviation, a colon, and the county name, e.g., NY: Monroe County, VA: Rockbridge County, DC: District of Columbia |
Column 4 (year) |
integer, 4 digits | Year |
Column 5 (month) |
integer, 2 digits (01–12) |
Month |
Column 6 (element) |
text, 4 characters | Variable in the file: TMAX daily maximum temperature (°C), TMIN daily minimum temperature (°C), PRCP daily precipitation (mm) |
Columns 7–37 (day_1 … day_31) |
number with 2 decimals, fixed width: 9 characters between the commas (a space, then the provider’s %8.2f format, e.g., 30.37); read_csv() trims the spaces |
Value on day 1, 2, …, 31 of the month: the average over the county of the grid-cell values, in °C (TMAX, TMIN) or mm (PRCP). -999.99 marks a day that does not exist, such as February 30 or June 31. There are no other missing values in 2006–2025 |
The crosswalk us-state-codes_ncei-to-fips.csv translates NCEI state numbers into FIPS state codes.
| Variable | Type | Description |
|---|---|---|
state_name |
text | State name without spaces, e.g., NewYork. The names of Illinois and Indiana are swapped: the row Indiana,IL,11,17 is Illinois. The codes in each row are correct |
postal_code |
text, 2 letters | U.S. Postal Service state abbreviation |
NCEI_code |
text, 2 digits | NCEI state number, 01–48: the 48 contiguous states in alphabetical order. NCEI uses it in station identifiers and in the region codes above |
FIPS_code |
text, 2 digits | FIPS state code (Federal Information Processing Series: the federal number of the state) |
The README, status file, and user guide are text documents with no variables.
Cleaning steps
The script R/m04_nclimgrid.R makes these decisions:
- Name the columns ourselves. The raw files have no header row.
region_codeis read as text, because codes such as01001start with a zero. - Drop days that do not exist. Each row has 31 day columns, whatever the month’s length.
-999.99is read as missing, and day columns past the end of the month are dropped. A missing value on a real day stops the script (there are none in 2006–2025). - Reshape twice.
pivot_longer()turns the 31 day columns into rows (one row per county per day), andpivot_wider()then puts tmax, tmin, and prcp side by side. - Translate NCEI codes into GEOIDs. The GEOID is the 5-character county FIPS code used by the Census Bureau and by every other clean file. We take the state from the postal abbreviation, the first two characters of
region_name(looked up inus_states_2020.csv), and the county from the last three digits ofregion_code. DC becomes11001. NCEI’s own crosswalk gives the same states, which the script checks. - Fill the one missing county. NCEI publishes no series for Lexington city, Virginia (
51678), and does not say why; size alone does not explain it, because the even smaller Falls Church city (5.3 km²) has one. Lexington is an independent city of 6.5 km² surrounded by Rockbridge County (51163). As in the research project, Lexington gets Rockbridge’s values, and the TRUE/FALSE columnweather_imputedmarks these copied rows. (The project stores the donor’s GEOID instead; we use TRUE/FALSE because a column that is blank on almost every row makesread_csv()guess its type wrong.) Every clean panel is then complete. - Average temperature.
tavg_c= (tmax_c+tmin_c) / 2, the definition NCEI uses for its own average temperature. - One year at a time. The script builds, checks, and writes one year of county-days (about 1.1 million rows) before it reads the next, so it never holds all 22.7 million county-days in memory at once.
- Monthly summaries. Means and totals of the daily values, and counts of hot days (tmax ≥ 32 °C), frost days (tmin ≤ 0 °C), and days in four precipitation bins. The thresholds and bins are the research project’s.
The first chunk sets the folders and reads the county frame from module m00.
# The raw files sit in one folder per year, 36 files per year
# (3 variables x 12 months). The daily output gets its own folder with one
# file per year: all 20 years in one CSV would be about 1 GB.
ncg_dir <- file.path(raw_dir, "nclimgrid", "cty")
day_dir <- file.path(clean_dir, "nclimgrid_county_day")
dir.create(day_dir, recursive = TRUE, showWarnings = FALSE)
ncg_years <- 2006:2025
# The county frame from module m00: 3,108 counties (48 states + DC).
counties <- read_csv(file.path(clean_dir, "counties_2020.csv"),
col_types = cols(.default = col_character()),
col_select = c(GEOID, county_name, state_abbr))
stopifnot(nrow(counties) == 3108)Next we read one raw file, June 2023 maximum temperature, with our own column names and types, and look at its wide layout.
# The files have no header row. Each row is one county in one month:
# 6 ID columns, then 31 day columns, whatever the length of the month.
# We name the columns ourselves. region_code stays text so that its
# leading zero survives ("01001", not 1001).
ncg_names <- c("region_type", "region_code", "region_name", "year", "month",
"element", str_c("day_", 1:31))
ncg_types <- cols(
region_type = col_character(), region_code = col_character(),
region_name = col_character(), year = col_integer(), month = col_integer(),
element = col_character(), .default = col_double()
)
# Daily maximum temperature in June 2023, the month Canadian smoke turned
# New York City's sky orange. -999.99 is NCEI's code for a day that does
# not exist (June 31), so we read it as NA.
tmax_jun23 <- read_csv(
file.path(ncg_dir, "2023", "tmax-202306-cty-scaled.csv"),
col_names = ncg_names, col_types = ncg_types, na = "-999.99"
)
tmax_jun23 |>
select(region_code:element, day_1, day_2, day_30, day_31) |>
head(3)This is the step to understand well: pivot_longer() stacks the 31 day columns into two columns, day (which column the value came from) and tmax_c (the value). The file grows from 3,107 rows to 96,317 rows, and dropping June 31 leaves 93,210.
# This file is "wide": the days are spread across 31 columns. pivot_longer()
# stacks them into rows, one row per county per day. names_prefix drops
# "day_" from the column names, and names_transform turns "7" into 7L.
tmax_jun23_long <- tmax_jun23 |>
pivot_longer(
cols = starts_with("day_"),
names_to = "day", names_prefix = "day_",
names_transform = list(day = as.integer),
values_to = "tmax_c"
)
nrow(tmax_jun23_long) # 3,107 counties x 31 day columns = 96,317 rows
# Keep the days that exist (June has 30), then build a real Date.
tmax_jun23_long <- tmax_jun23_long |>
filter(day <= days_in_month(make_date(year, month, 1L))) |>
mutate(date = make_date(year, month, day))
stopifnot(nrow(tmax_jun23_long) == 3107 * 30, !anyNA(tmax_jun23_long$tmax_c))
tmax_jun23_long |>
select(region_code, region_name, date, tmax_c) |>
head(3)The county codes need a lookup table. We build it from the same file and check it against the county frame.
# region_code is NOT a FIPS code. Its first two digits are NCEI's own state
# number (01 Alabama ... 48 Wyoming, alphabetical, without Alaska and Hawaii);
# its last three digits are the county's FIPS code. The postal abbreviation,
# the first two characters of region_name ("AL: Autauga"), is easier to
# translate: the m00 state table maps it to the 2-digit FIPS state code.
states <- read_csv(file.path(clean_dir, "us_states_2020.csv"),
col_types = cols(.default = col_character()),
col_select = c(STATEFP, state_abbr))
# Every raw file lists the same 3,107 counties in the same order, so one
# file is enough to build the lookup table.
ncg_counties <- tmax_jun23 |>
distinct(region_code, region_name) |>
mutate(state_abbr = str_sub(region_name, 1, 2)) |>
left_join(states, by = "state_abbr") |>
mutate(
GEOID = str_c(STATEFP, str_sub(region_code, 3, 5)),
# DC is filed under county number 511; its Census code is 11001.
GEOID = if_else(GEOID == "11511", "11001", GEOID)
)
stopifnot(
nrow(ncg_counties) == 3107, !anyNA(ncg_counties$GEOID),
!anyDuplicated(ncg_counties$GEOID),
all(ncg_counties$GEOID %in% counties$GEOID)
)
# The one frame county with no nClimGrid series: Lexington city, VA.
counties |> filter(!GEOID %in% ncg_counties$GEOID)As a second check, we translate the codes with NCEI’s own crosswalk and list every disagreement, including any code the crosswalk lacks. Only DC disagrees.
# NCEI also ships a crosswalk from its state numbers to FIPS state codes.
# Using it on the first two digits of region_code gives the same state as
# the postal abbreviation for every county but one: DC, which NCEI numbers
# as county 511 of Maryland (NCEI 18 = FIPS 24). The file's state_name
# column swaps Illinois and Indiana, but its codes are correct.
ncei_states <- read_csv(
file.path(raw_dir, "nclimgrid", "doc", "us-state-codes_ncei-to-fips.csv"),
col_types = cols(.default = col_character())
)
# A code missing from the crosswalk gives FIPS_code = NA after the join.
# FIPS_code != STATEFP is then NA, and filter() silently drops NA rows,
# so we keep them on purpose with is.na(): they are disagreements too.
disagree <- ncg_counties |>
mutate(NCEI_code = str_sub(region_code, 1, 2)) |>
left_join(select(ncei_states, NCEI_code, FIPS_code), by = "NCEI_code") |>
filter(is.na(FIPS_code) | FIPS_code != STATEFP)
disagree |> select(region_code, region_name, STATEFP, FIPS_code)
stopifnot(identical(disagree$region_code, "18511"))Now we wrap the read-and-pivot steps in a function that works for any raw file. It returns a long table with one row per county, day, and variable.
# The same read-and-pivot steps as above, for any of the 720 files.
# element is lowercased ("TMAX" -> "tmax") so it can become a column name.
read_ncg_file <- function(path) {
out <- read_csv(path, col_names = ncg_names, col_types = ncg_types,
na = "-999.99") |>
pivot_longer(
cols = starts_with("day_"),
names_to = "day", names_prefix = "day_",
names_transform = list(day = as.integer),
values_to = "value"
) |>
filter(day <= days_in_month(make_date(year, month, 1L))) |>
transmute(region_code, element = str_to_lower(element),
date = make_date(year, month, day), value)
# -999.99 should only fill days that a month does not have. NA on a real
# day would be a gap in the data, so stop and look. Every county code
# must also be in the lookup table built from June 2023.
stopifnot(!anyNA(out$value), all(out$region_code %in% ncg_counties$region_code))
out
}One month is three files. After stacking them, pivot_wider() spreads the element column back out into tmax, tmin, and prcp columns.
# One month is three files: tmax, tmin, and prcp. Stack them, then put the
# three variables side by side with pivot_wider(), the reverse of
# pivot_longer(): the values of element become the new column names.
# Result: one row per county per day. (%02d pads the month: 6 -> "06".)
read_ncg_month <- function(yr, mo) {
files <- file.path(ncg_dir, yr, sprintf("%s-%d%02d-cty-scaled.csv",
c("tmax", "tmin", "prcp"), yr, mo))
stopifnot(all(file.exists(files)))
map(files, read_ncg_file) |>
list_rbind() |>
pivot_wider(names_from = element, values_from = value)
}
read_ncg_month(2023, 6) |> head(3)A year is twelve months. This function also adds GEOIDs, fills Lexington city, computes tavg_c, and checks that the panel is complete. We call the data frame ncg_day, not day, because day is already the name of a column made by pivot_longer() and of a lubridate function.
# One year: read 12 months and swap NCEI codes for GEOIDs. NCEI publishes
# no series for Lexington city, VA (51678, 6.5 km2), and the provider does
# not say why (even smaller Falls Church city, 51610, has one). Like the
# research project, we copy the values of Rockbridge County (51163), which
# surrounds it. weather_imputed is TRUE on these copied rows and FALSE on
# every other row. It is never blank, so read_csv() always reads it as
# logical, even without col_types.
build_ncg_year <- function(yr) {
ncg_day <- map(1:12, \(mo) read_ncg_month(yr, mo)) |>
list_rbind() |>
left_join(select(ncg_counties, region_code, GEOID), by = "region_code") |>
transmute(GEOID, date, tmax_c = tmax, tmin_c = tmin, prcp_mm = prcp,
weather_imputed = FALSE)
lexington <- ncg_day |>
filter(GEOID == "51163") |>
mutate(GEOID = "51678", weather_imputed = TRUE)
ncg_day <- bind_rows(ncg_day, lexington) |>
# (tmax + tmin) / 2 has at most 3 decimals, so keep all 3.
mutate(tavg_c = round((tmax_c + tmin_c) / 2, 3), .after = tmin_c) |>
arrange(GEOID, date)
# Complete panel: every frame county on every day of the year, once.
n_dates <- as.integer(make_date(yr, 12L, 31L) - make_date(yr, 1L, 1L)) + 1L
stopifnot(
nrow(ncg_day) == 3108 * n_dates,
setequal(ncg_day$GEOID, counties$GEOID),
nrow(distinct(ncg_day, GEOID, date)) == nrow(ncg_day),
!anyNA(ncg_day),
sum(ncg_day$weather_imputed) == n_dates,
all(ncg_day$tmin_c <= ncg_day$tmax_c), all(ncg_day$prcp_mm >= 0)
)
ncg_day
}Before running twenty years, try one. The output shows Monroe County, New York (Rochester), around the smoke days of June 6–8, 2023: no rain and highs near 20 °C.
# Try one year before running all twenty. 2023 has 365 days, so the
# result should have 3,108 x 365 = 1,134,420 rows.
day_2023 <- build_ncg_year(2023)
glimpse(day_2023)
# Monroe County, NY (Rochester) during the June 2023 smoke days.
day_2023 |>
filter(GEOID == "36055", between(date, ymd("2023-06-05"), ymd("2023-06-09")))
day_2023 |> count(weather_imputed)The monthly summary uses summarise() with .by, so it needs no group_by(). sum() of a TRUE/FALSE condition counts the days that meet it.
# Monthly summaries of one year of county-days. The thresholds follow the
# research project: hot day = tmax >= 32 C (about 90 F), frost day =
# tmin <= 0 C, and four precipitation bins (dry, light, moderate, heavy).
summarise_ncg_month <- function(ncg_day) {
ncg_day |>
mutate(year = as.integer(year(date)), month = as.integer(month(date))) |>
summarise(
tmax_mean_c = round(mean(tmax_c), 2),
tmin_mean_c = round(mean(tmin_c), 2),
tavg_mean_c = round(mean(tavg_c), 2),
prcp_total_mm = round(sum(prcp_mm), 2),
days_tmax_ge32 = sum(tmax_c >= 32),
days_tmin_le0 = sum(tmin_c <= 0),
days_prcp_0 = sum(prcp_mm == 0),
days_prcp_0_5 = sum(prcp_mm > 0 & prcp_mm <= 5),
days_prcp_5_15 = sum(prcp_mm > 5 & prcp_mm <= 15),
days_prcp_gt15 = sum(prcp_mm > 15),
n_days = n(),
weather_imputed = any(weather_imputed),
.by = c(GEOID, year, month)
)
}
summarise_ncg_month(day_2023) |> filter(GEOID == "36055", month %in% 6:7)The loop runs all twenty years and writes one compressed CSV per year. The whole script takes about 3 to 7 minutes and 1.0 to 1.6 GB of memory on a shared 16 GB Mac.
# Build, check, and write each year, then keep only its monthly summary.
# Each new year replaces the last one in `ncg_day`, so the 22.7 million
# county-days are never in memory all at once. This takes a few minutes.
# Grain of each file: one row per county x day (GEOID + date unique),
# 3,108 counties x 365 or 366 days.
# day_2023 was a trial run and the loop builds 2023 again, so free its
# memory first (if you skipped the trial, there is nothing to free).
if (exists("day_2023")) rm(day_2023)
ncg_month_list <- vector("list", length(ncg_years))
for (i in seq_along(ncg_years)) {
yr <- ncg_years[i]
ncg_day <- build_ncg_year(yr)
stopifnot(nrow(ncg_day) == 3108 * if_else(leap_year(yr), 366, 365))
write_csv(ncg_day, file.path(day_dir, str_glue("nclimgrid_county_day_{yr}.csv.gz")),
na = "")
ncg_month_list[[i]] <- summarise_ncg_month(ncg_day)
message(yr, ": ", nrow(ncg_day), " county-days written")
}
ncg_month <- list_rbind(ncg_month_list) |> arrange(GEOID, year, month)The monthly file is checked and written once all years are done.
# Grain: one row per county x month, 3,108 counties x 240 months = 745,920
# rows, with GEOID + year + month unique. Each month's day counts must add
# up to the number of days in the month.
stopifnot(
nrow(ncg_month) == 3108 * 240,
nrow(distinct(ncg_month, GEOID, year, month)) == nrow(ncg_month),
!anyNA(ncg_month),
all(ncg_month$n_days == days_in_month(make_date(ncg_month$year, ncg_month$month, 1L))),
with(ncg_month, all(days_prcp_0 + days_prcp_0_5 + days_prcp_5_15 + days_prcp_gt15 == n_days)),
all(ncg_month$tmin_mean_c <= ncg_month$tmax_mean_c),
all(between(ncg_month$days_tmax_ge32, 0, ncg_month$n_days)),
sum(ncg_month$weather_imputed) == 240
)
write_csv(ncg_month, file.path(clean_dir, "nclimgrid_county_month_2006_2025.csv"),
na = "")Finally, read the files back the way later modules will: with plain read_csv() and no col_types. The checks confirm that every column gets the right type.
# Read the clean files the way later modules will, with plain read_csv().
# Every file is sorted by GEOID and starts with "01001", so read_csv()
# sees the leading zero and keeps GEOID as text. weather_imputed is
# TRUE/FALSE on every row, so it is read as logical. Giving
# col_types = cols(GEOID = col_character()) is still a good habit.
day_check <- read_csv(file.path(day_dir, "nclimgrid_county_day_2023.csv.gz"),
show_col_types = FALSE)
month_check <- read_csv(file.path(clean_dir, "nclimgrid_county_month_2006_2025.csv"),
show_col_types = FALSE)
stopifnot(
is.character(day_check$GEOID), is.Date(day_check$date),
is.logical(day_check$weather_imputed), nrow(problems(day_check)) == 0,
nrow(day_check) == 3108 * 365, sum(day_check$weather_imputed) == 365,
is.character(month_check$GEOID), is.logical(month_check$weather_imputed),
nrow(problems(month_check)) == 0,
nrow(month_check) == 3108 * 240, sum(month_check$weather_imputed) == 240
)Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
nclimgrid_county_day/nclimgrid_county_day_<year>.csv.gz (20 files, 2006–2025) |
1,134,420 per year (1,137,528 in the leap years 2008, 2012, 2016, 2020, 2024); 22,703,940 in all | one county on one day | 12.4–12.6 MB each; 250.3 MB in all |
nclimgrid_county_month_2006_2025.csv |
745,920 | one county in one month (3,108 counties × 240 months) | 44.0 MB |
Clean variables
The 20 daily files share these columns. Rows are sorted by GEOID and date. No cell is missing. The types are the ones plain read_csv() gives.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (2-digit state + 3-digit county), as in counties_2020.csv |
date |
date | — | Day, YYYY-MM-DD, as dated by NCEI. Dates are local dates at the stations, not UTC (Coordinated Universal Time) dates, and a day is not a strict midnight-to-midnight day (see the cautions) |
tmax_c |
dbl | °C | Daily maximum temperature, county average of grid cells (2 decimals) |
tmin_c |
dbl | °C | Daily minimum temperature, county average of grid cells (2 decimals) |
tavg_c |
dbl | °C | (tmax_c + tmin_c) / 2, kept at 3 decimals so it is exact |
prcp_mm |
dbl | mm | Daily precipitation, county average of grid cells (2 decimals). 0 means the county average rounds to 0.00 mm (below 0.005 mm): usually a dry day across the county, but a few grid cells may have had a trace |
weather_imputed |
lgl | — | TRUE on the rows of Lexington city, Virginia (51678): its values are copied from Rockbridge County (51163), which surrounds it. FALSE on every other row, where the values are the county’s own series |
nclimgrid_county_month_2006_2025.csv: one row per county and month, sorted by GEOID, year, and month. No cell is missing. The types are the ones plain read_csv() gives.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code |
year |
dbl | — | Year, 2006–2025 |
month |
dbl | — | Month, 1–12 |
tmax_mean_c |
dbl | °C | Mean of the month’s daily tmax_c (2 decimals) |
tmin_mean_c |
dbl | °C | Mean of the month’s daily tmin_c (2 decimals) |
tavg_mean_c |
dbl | °C | Mean of the month’s daily tavg_c (2 decimals) |
prcp_total_mm |
dbl | mm | Total precipitation in the month: the sum of daily prcp_mm (2 decimals) |
days_tmax_ge32 |
dbl | days | Hot days: days with tmax_c ≥ 32 °C (89.6 °F) |
days_tmin_le0 |
dbl | days | Frost days: days with tmin_c ≤ 0 °C |
days_prcp_0 |
dbl | days | Dry days: prcp_mm = 0, a county average that rounds to 0.00 mm (a few grid cells may still have had a trace) |
days_prcp_0_5 |
dbl | days | Days with 0 < prcp_mm ≤ 5 |
days_prcp_5_15 |
dbl | days | Days with 5 < prcp_mm ≤ 15 |
days_prcp_gt15 |
dbl | days | Days with prcp_mm > 15 |
n_days |
dbl | days | Days in the month (28–31). Every day is present, so the four precipitation bins add up to n_days |
weather_imputed |
lgl | — | TRUE on the 240 rows of Lexington city (51678), whose values are copied from Rockbridge County (51163); FALSE otherwise, as in the daily files |
Checks and cautions
- Raw copies are exact. All 724 shipped files (720 county CSVs and 4 documents) are byte-identical (same MD5 checksum) to the research project’s downloads of 2026-09-27. Each county file has 3,107 rows and 37 columns, and all 720 files list the same counties in the same order. In 2006–2025,
-999.99appears only on days that do not exist, and every file is a finalscaledfile. - Daily files match the research project. For all 20 years, the daily files have the same 22,703,940 county-days as the project’s
nclimgrid_county_daypanel.tmax_c,tmin_c, andprcp_mmare identical (largest difference 0),tavg_cagrees to within 10⁻¹⁴ °C, andweather_imputedisTRUEon exactly the rows where the project records a donor. - Monthly file matches the research project. All 745,920 rows match the project’s
nclimgrid_county_month.parquetfor 2006–2025. Hot-day, frost-day, precipitation-bin, andn_dayscounts are identical; monthly means differ by at most 0.005 °C (our rounding to 2 decimals), and precipitation totals are identical. As a further check, recomputing the 2023 monthly totals and hot-day counts from the 2023 daily file gives the same values. - The panels are complete. Every county in
counties_2020.csvhas a row for every day and every month of 2006–2025, and no cell is empty. Because no county-day or county-month is left out, there are no absent rows to interpret. - Lexington city is a copy. Its 7,305 daily rows and 240 monthly rows are Rockbridge County’s values. Keep them if you need a complete panel; drop rows with
weather_imputed == TRUEif an analysis should use only real county series. Rockbridge is 1,545 km² and Lexington 6.5 km², so the copy describes the surrounding countryside more than the town. - Never join
region_codetoGEOIDdirectly. 1,591 of the 3,107 NCEI codes are also valid GEOIDs, but only Alabama’s 67 are the right county, because Alabama is the one state whose NCEI number (01) equals its FIPS code. For example,30055is Monroe County, New York, in nClimGrid and McCone County, Montana, in Census files. A direct join would give 1,524 counties another county’s weather without any error message. - County values are area averages. Each value is the mean of the grid cells inside the county, weighted by the cosine of latitude (a grid cell covers less ground farther north). It is not weighted by population, so in large western counties it mostly describes empty land, not the towns where people live.
- Single days are uncertain. NCEI’s tests for 1991–2020 found that half of the stations had an average interpolation error of 1 °C or less for tmax and tmin, and a median error of 2 mm or less for precipitation. Errors are largest in the mountains of the West and on days with heavy rain, and the smoothing gives precipitation a slight dry bias. NCEI discourages relying on single-day values for single places; averages over months or over many counties are more reliable.
- What a “day” is. NCEI builds each day by interpolating station reports taken in the morning or at midnight, so a day is not a strict midnight-to-midnight local day. The provider lists differences in observation time between neighboring stations as one source of error. Dates are local dates at the stations, not UTC dates. Data dated in UTC, such as the ERA5 days of module m09, cover different hours, so keep this in mind when you line up weather and smoke on specific days.
- Smoke can change the weather. Thick smoke blocks sunlight and can cool afternoon temperatures, so on heavy smoke days
tmax_cis partly a result of the smoke. Same-daytmax_ccan therefore be a poor control in a model of smoke effects. - Thresholds are the project’s choices. A hot day is
tmax_c≥ 32 °C (about 90 °F) and a frost day istmin_c≤ 0 °C. 32.8% of county-months have at least one hot day and 51.7% at least one frost day. You can compute other thresholds (for example, 35 °C) from the daily files with the samesum()pattern. - Reading the files. Plain
read_csv()with nocol_typesreads every column correctly:GEOIDas text (each file starts with01001, so the leading zero is seen),dateas a date,weather_imputedas logical, and every number, includingyear,month, and the day counts, as dbl. Chunkm04-read-backchecks this. Addingcol_types = cols(GEOID = col_character())is still a good habit when you read only part of a file or filter it first. - More monthly measures in the project. The research project’s monthly weather file also counts days in 5 °C bins of daily mean temperature (below −10 °C, −10 to −5 °C, …, 30 °C and above) and keeps the within-month standard deviation of
tavgand the month’s highesttmax. They are not in this file; you can compute them from the daily files withcut()andsummarise(). - Years not in the bundle. NCEI covers 1951 to the present. The research project also uses 1991–2005 and January–March 2026; those files are not in the student bundle.
- Memory and time. The whole script runs in about 3 to 7 minutes and uses 1.0 to 1.6 GB of memory on a shared 16 GB Mac (our latest run: 296 seconds, 1.4 GB), because it works on one year of county-days at a time.
ERA5 winds and surface weather
ERA5 is a reanalysis: a reconstruction of the weather for every hour since 1940, on a global grid of cells 0.25° of latitude and longitude wide (about 28 km north–south and 18–25 km east–west over the contiguous U.S.). The European Centre for Medium-Range Weather Forecasts (ECMWF) makes it for the Copernicus Climate Change Service (C3S). It feeds millions of observations from satellites, weather balloons, aircraft, ships, and weather stations into a weather-forecast model, so every cell has a value for every hour, even where nobody measured. The research project downloaded daily means of the hourly values over North America and averaged the surface weather and the height of the 500 hPa pressure level to counties, giving more weight to cells where more people live.
In this project ERA5 tells you which way and how hard the wind blew over a county each day (for example, the northerly winds over New York during the Quebec smoke of June 2023), how deep the layer of air that mixes near the ground was (a shallow layer keeps smoke close to the ground), how much sunlight reached the ground (smoke dims it), and the temperature and humidity that any smoke analysis has to account for. It does not measure smoke. A county’s daily-mean surface wind is also not the path the smoke took: smoke travels for days, mostly above the surface. ERA5 values are model estimates for grid cells, not readings at a weather station, and its days are UTC days, not local days.
| Provider | European Centre for Medium-Range Weather Forecasts (ECMWF) for the Copernicus Climate Change Service (C3S); distributed by the Copernicus Climate Data Store (CDS). County averages: the research project (script scripts/R/02_winds.R), exported for this course by the instructor |
| Coverage | Raw (research project only): daily, January 2003 to March 2026 for surface fields and January 2003 to January 2026 for the 850/700 hPa winds; January 1991 to March 2026 for the 500 hPa geopotential · three rectangles over North America (see “Raw files”). Clean: daily and monthly, 2006–2025 · the 3,108 contiguous-U.S. counties (DC included) |
| Raw unit | one grid cell (0.25° × 0.25°) × UTC day (× pressure level for the B1 winds) |
| Clean unit | one row = a county × UTC day (instructor export, one file per year) or a county × month (built in this section) |
| Licence and citation | CC BY 4.0 (free to use and share if you credit the source). Cite the reanalysis: Hersbach, H., Bell, B., Berrisford, P., et al. (2020). The ERA5 global reanalysis. Quarterly Journal of the Royal Meteorological Society, 146(730), 1999–2049. https://doi.org/10.1002/qj.3803. Cite the datasets from the Copernicus Climate Change Service (C3S) Climate Data Store (CDS): ERA5 post-processed daily statistics on pressure levels (https://doi.org/10.24381/cds.50314f4c) and on single levels (https://doi.org/10.24381/cds.4991cf48), and ERA5 hourly data on pressure levels (https://doi.org/10.24381/cds.bd0915c6) and on single levels (https://doi.org/10.24381/cds.adbb2d47) |
| Documentation | ERA5 data documentation; CDS pages for the daily statistics on single levels and on pressure levels; ECMWF parameter database (definitions of u, v, z, ssrd, blh, t2m, d2m, u10, v10); Hersbach et al. (2020) |
Raw files
No raw ERA5 file ships in the bundle. The research project’s 979 monthly files take 17.1 GB, and downloading them requires a free Copernicus Climate Data Store account. They are listed here so that you know what the clean files are made from. The project requested three “bundles”, one NetCDF file per bundle and month (NetCDF-4 is a file format for labeled multi-dimensional arrays):
- B1 holds the winds at the 850 and 700 hPa pressure levels, about 1.5 km and 3 km above sea level, over 14–84° N and 180–50° W (most of North America and its oceans, where the smoke starts and travels).
- B2 holds the geopotential at 500 hPa, about 5.5 km up, over 20–75° N and 145–50° W.
- B3 holds six surface fields over 24–50° N and 125–66° W (the contiguous U.S.).
Every value is the mean of the 24 hourly values of one UTC day. UTC (Coordinated Universal Time) is the clock time at Greenwich, England. In New York a UTC day runs from 8 pm the evening before to 8 pm in summer (7 pm to 7 pm in winter); in California it runs from 5 pm to 5 pm in summer.
The files came by two routes with the same result. 67 of them came from the CDS “daily statistics” service as delivered: the B1 and B3 files for July 2023 (zip archives holding one NetCDF per variable) and 65 B2 files (January 1991 to April 1995, all of 2023, and April 2024). That service’s queue was too slow for the rest, so the project downloaded the other 912 months as hourly data and computed the daily means itself with the service’s rule (the same 24 hours and the same single-precision arithmetic). On the months it checked, the two routes gave identical values except on one day per month, where some cells differ by one or two of the smallest steps a single-precision number can resolve (at most 0.0008 gpm for the 500 hPa height and 0.00024 m for boundary-layer height).
File (in the research project’s data/raw/era5/; not shipped) |
Size | Format | One row is |
|---|---|---|---|
B1_pl_uv/era5_daymean_B1_<YYYY-MM>.nc, 277 monthly files, 2003-01 to 2026-01 (2023-07 is era5_daymean_B1_2023-07.zip) |
39.5–53.2 MB each; 12.1 GB in all | NetCDF-4 (the zip holds one NetCDF per variable) | not rows but arrays: one value per grid cell × pressure level × day, 521 × 281 cells × 2 levels × 28–31 days |
B2_pl_z500/era5_daymean_B2_<YYYY-MM>.nc, 423 monthly files, 1991-01 to 2026-03 |
4.3–4.9 MB each; 1.97 GB in all | NetCDF-4 | arrays: one value per grid cell × day, 381 × 221 cells × 28–31 days |
B3_sl/era5_daymean_B3_<YYYY-MM>.nc, 279 monthly files, 2003-01 to 2026-03 (2023-07 is era5_daymean_B3_2023-07.zip) |
10.2–11.4 MB each; 3.07 GB in all | NetCDF-4 (the zip holds one NetCDF per variable) | arrays: one value per grid cell × day for each of 6 fields, 237 × 105 cells × 28–31 days |
_manifest_era5.csv |
1.05 MB | CSV | one file record (1,025 rows: the 979 files in use, status downloaded, plus 46 earlier files marked superseded): CDS request, download date, size, checksums, licence |
README_era5.md |
0.05 MB | Markdown text | the project’s download log (routes, requests, checks) |
Raw variables
The tables describe the files as ncdf4 shows them, with dimensions in R’s order (tools that use C order, such as ncdump, list them reversed). Both routes give the same variable names, dimensions, units, and grids. They differ in two small ways. The CDS files store the number coordinate as a 64-bit integer and define the valid_time dimension first, while the project’s files store number as a 32-bit integer (each variable’s own dimension order is the same in both). And the attributes differ: the CDS files carry the provider’s GRIB_ metadata, and the files the project computed carry provenance attributes (hourly_window, cell_methods, era5_route, CDS job ids, and checksums). Every data variable is stored as a single-precision float (float32) with NaN as the missing-value code; no value is missing in the files checked (July 2024 for all three bundles, plus June and July 2023). Quoted names and definitions come from the ECMWF parameter database. The example ranges are from July 2024.
The first table covers the B1 files B1_pl_uv/era5_daymean_B1_<YYYY-MM>.nc.
| Variable | Dimensions | Units | Description |
|---|---|---|---|
longitude |
longitude |
degrees_east |
Longitude of each cell center, −180 to −50 in steps of 0.25 (521 values; negative = west of Greenwich) |
latitude |
latitude |
degrees_north |
Latitude of each cell center, stored from 84 down to 14 in steps of 0.25 (281 values) |
pressure_level |
pressure_level |
hPa |
850 and 700. Air pressure falls with height, so a pressure level is a height in the air: average sea-level pressure is about 1,013 hPa (hectopascals), and the 850 and 700 hPa levels lie about 1.5 km and 3 km above sea level |
valid_time |
valid_time |
days since <first day of the month> 00:00:00 |
The day: 0 is the first of the month. Each value is the mean over that UTC day. Calendar: proleptic Gregorian (the ordinary calendar) |
u |
longitude, latitude, pressure_level, valid_time |
m s**-1 (m/s) |
“U component of wind”: the eastward component of the wind, “the horizontal speed of air moving towards the east”; negative values mean air moving toward the west. Daily mean of the hourly values at 00–23 UTC. July 2024: −25.7 to 29.6 m/s |
v |
longitude, latitude, pressure_level, valid_time |
m s**-1 (m/s) |
“V component of wind”: the northward component of the wind; negative values mean air moving toward the south. Daily mean, as for u. July 2024: −24.9 to 22.9 m/s |
number |
none (a single value) | 1 |
“Ensemble member numerical id”. Always 0: ERA5’s main reanalysis, not one of the extra runs ECMWF makes to measure uncertainty |
The B2 files B2_pl_z500/era5_daymean_B2_<YYYY-MM>.nc have the same coordinate variables over a smaller area and one pressure level, and one data variable.
| Variable | Dimensions | Units | Description |
|---|---|---|---|
longitude |
longitude |
degrees_east |
Cell centers from −145 to −50 in steps of 0.25 (381 values) |
latitude |
latitude |
degrees_north |
Cell centers from 75 down to 20 in steps of 0.25 (221 values) |
pressure_level |
pressure_level |
hPa |
500 (one value), about 5.5 km above sea level |
valid_time |
valid_time |
days since <first day of the month> 00:00:00 |
The day, as in B1 |
z |
longitude, latitude, pressure_level, valid_time |
m**2 s**-2 |
“Geopotential”: “the gravitational potential energy of a unit mass, at a particular location, relative to mean sea level”. Dividing by g = 9.80665 m/s² gives the geopotential height in geopotential meters (gpm), close to the height above sea level, in meters, at which the pressure is 500 hPa. A high 500 hPa height is a ridge (often warm, dry, and calm under it); a low one is a trough. Daily mean of the hourly values at 00–23 UTC. July 2024: 50,480 to 59,140 m²/s² (5,147 to 6,031 gpm) |
number |
none | 1 |
Ensemble member id, always 0, as in B1 |
The B3 files B3_sl/era5_daymean_B3_<YYYY-MM>.nc hold surface (“single-level”) fields, so they have no pressure_level dimension.
| Variable | Dimensions | Units | Description |
|---|---|---|---|
longitude |
longitude |
degrees_east |
Cell centers from −125 to −66 in steps of 0.25 (237 values) |
latitude |
latitude |
degrees_north |
Cell centers from 50 down to 24 in steps of 0.25 (105 values) |
valid_time |
valid_time |
days since <first day of the month> 00:00:00 |
The day, as in B1 |
ssrd |
longitude, latitude, valid_time |
J m**-2 |
“Surface short-wave (solar) radiation downwards”: “the amount of solar radiation … that reaches a horizontal plane at the surface”, direct and diffuse sunlight together. ERA5 stores it as the energy received during each hour, in joules per square meter. The file holds the daily mean of the 24 hourly amounts that cover 00–24 UTC (each hourly amount is labeled with the end of its hour, hence the time_shift attribute -1 days +23:00:00). Divide by 3,600 seconds to get the average power over the whole day, nights included, in W/m² (watts per square meter). July 2024: 73,970 to 1,401,000 J/m² (20.5 to 389 W/m²) |
blh |
longitude, latitude, valid_time |
m |
“Boundary layer height”: “the depth of air next to the Earth’s surface which is most affected by the resistance to the transfer of momentum, heat or moisture across the surface”. It can be a few tens of meters on a cool night and several kilometers on a hot afternoon; when it is low, pollution emitted or brought near the ground stays concentrated. Daily mean of hourly values. July 2024: 21.3 to 2,610 m |
t2m |
longitude, latitude, valid_time |
K |
“2 metre temperature”: the temperature of the air 2 m above land, sea, or inland water, in kelvin (subtract 273.15 for °C). Daily mean of hourly values. July 2024: 277.5 to 314.2 K |
d2m |
longitude, latitude, valid_time |
K |
“2 metre dewpoint temperature”: the temperature to which the air 2 m above the surface would have to cool to become saturated; a measure of humidity (the closer to t2m, the more humid). Daily mean of hourly values. July 2024: 258.1 to 301.4 K |
u10 |
longitude, latitude, valid_time |
m s**-1 (m/s) |
“10 metre U wind component”: the eastward component of the wind 10 m above the surface; negative = toward the west. Daily mean of hourly values. July 2024: −11.7 to 10.2 m/s |
v10 |
longitude, latitude, valid_time |
m s**-1 (m/s) |
“10 metre V wind component”: the northward component of the wind 10 m above the surface; negative = toward the south. Daily mean of hourly values. July 2024: −16.5 to 15.1 m/s |
number |
none | 1 |
Ensemble member id, always 0, as in B1 |
Cleaning steps
You start from the clean county-day files, not from the raw grids. The research project’s script 02_winds.R turned each daily grid into county values, and the instructor exported them (see the appendix). For each county it took the 0.25° cells that contain the county’s 2020 block-group centers of population (the same block groups as in module m00) and averaged them, weighting each cell by the population whose block-group centers fall in it. Cells where nobody lives, such as lakes, therefore get no weight. It also converted the units: kelvin to °C, J/m² per hour to W/m², and geopotential to gpm.
The script R/m09_era5.R then makes these decisions:
- Read one year per file. The 20 county-day files hold about 1.1 million rows each. You read only the years you need with
map()andread_csv(), and the monthly table is built one year at a time, so the whole script needed about 1.7 GB of memory at its peak and about 1.5 minutes in our run. The chunkm09-month-buildfirst removes the three years of daily rows, because the monthly loop does not need them. - Direction from u and v. Weather services report the direction the wind blows from, in degrees clockwise from north: 0° (or 360°) from the north, 90° from the east, 180° from the south, 270° from the west. The formula is
(270 - atan2(v, u) * 180 / pi) %% 360. A calm day (u and v both 0.00 m/s after rounding) has no direction and getsNA. - Eight compass sectors. Each sector is 45° wide and centered on its direction: N covers 337.5–22.5°, NE 22.5–67.5°, and so on.
- Northerly days. A day counts as northerly when the daily-mean 10 m wind comes from 315° through 45°, that is, from anywhere between northwest and northeast. This quarter of the compass covers the N sector and half of the NW and NE sectors. Calm days are not counted.
- Average u and v, not directions. Monthly means of
u10_msandv10_msgive the average wind arrow. Averaging directions in degrees would be wrong: the mean of 350° and 10° is 180°, the opposite of north. The monthlyws10_msis the mean of the daily speeds. - Same precision as the daily files. Monthly means are rounded like the daily values: 2 decimals for temperatures and winds, 1 for humidity, radiation, and heights, and whole meters for the boundary layer.
The first chunk works through u and v with five made-up winds. Each wind is a pair of numbers; the chunk turns each pair into a speed and a direction.
# ERA5 stores wind as two numbers, not as a speed and a direction.
# u = the part of the wind blowing toward the east (negative: toward the west)
# v = the part of the wind blowing toward the north (negative: toward the south)
# Five made-up winds, in meters per second (m/s), show how the pair works.
wind_examples <- tribble(
~description, ~u10_ms, ~v10_ms,
"west wind: blows toward the east", 5, 0,
"north wind: blows toward the south", 0, -5,
"northwest wind: blows toward the SE", 4, -4,
"south wind: blows toward the north", 0, 3,
"east wind: blows toward the west", -2, 0
)
# Speed is the length of the arrow (Pythagoras). Direction follows the
# weather-service rule: the compass direction the wind comes FROM, in degrees
# clockwise from north (0 = from the north, 90 = east, 180 = south,
# 270 = west). atan2(v, u) is the angle of the arrow in radians, measured
# counterclockwise from east. "270 minus that angle in degrees" turns the
# direction the air moves TO into the direction it comes FROM, and %% 360
# wraps the result into 0-360.
wind_examples |>
mutate(
speed_ms = sqrt(u10_ms^2 + v10_ms^2),
from_deg = (270 - atan2(v10_ms, u10_ms) * 180 / pi) %% 360
)The same formula, wrapped in two functions, is tested on the same five winds. The sectors should read W, N, NW, S, E.
# The same formula as a function, for the real data below. When u and v are
# both exactly 0 the air is calm and has no direction, so the function
# returns NA (atan2(0, 0) would otherwise give a meaningless 270).
wind_from_deg <- function(u, v) {
if_else(u == 0 & v == 0, NA_real_, (270 - atan2(v, u) * 180 / pi) %% 360)
}
# Eight compass sectors, each 45 degrees wide and centered on its direction:
# "N" covers 337.5-22.5 degrees, "NE" 22.5-67.5, and so on. Adding 22.5 first
# shifts "N" to 0-45, so whole-number division by 45 (%/%) gives the sector
# number 0-7.
compass_sector <- function(deg) {
sectors <- c("N", "NE", "E", "SE", "S", "SW", "W", "NW")
factor(sectors[((deg + 22.5) %% 360) %/% 45 + 1], levels = sectors)
}
# Test both on the made-up winds: the sectors should read W, N, NW, S, E.
wind_examples |>
mutate(
from_deg = wind_from_deg(u10_ms, v10_ms),
sector = compass_sector(from_deg)
)This function reads one year’s file. The column types keep the leading zero of GEOID and read date as a date.
# One clean file per year, each about 1.1 million rows (3,108 counties x 365
# or 366 days). Reading GEOID as text keeps its leading zero ("01001"); every
# weather column is a number.
read_era5_year <- function(yr) {
read_csv(
file.path(clean_dir, "era5_county_day", str_c("era5_county_day_", yr, ".csv.gz")),
col_types = cols(GEOID = col_character(), date = col_date(),
.default = col_double())
)
}Read three years and check the grain: every county on every day, with no duplicates and no missing values.
# Pick a year range. Three years are about 3.4 million rows and 350 MB of
# memory. All 20 years at once would need more than 2 GB, so the monthly
# table further down is built one year at a time instead.
years <- 2021:2023
era5_days <- map(years, read_era5_year) |> list_rbind()
# Grain: one row per county x day, every county on every day, no missing values.
all_days <- seq(as.Date(str_c(min(years), "-01-01")),
as.Date(str_c(max(years), "-12-31")), by = "day")
stopifnot(
nrow(era5_days) == 3108 * length(all_days),
nrow(distinct(era5_days, GEOID, date)) == nrow(era5_days),
n_distinct(era5_days$GEOID) == 3108,
setequal(era5_days$date, all_days),
!anyNA(era5_days)
)
glimpse(era5_days)Add the direction and sector to every county-day. Only 13 of the 3,403,260 county-days in 2021–2023 are calm.
# Add the direction the 10 m wind comes from, and its compass sector.
era5_days <- era5_days |>
mutate(
wind_from_deg = wind_from_deg(u10_ms, v10_ms),
wind_sector = compass_sector(wind_from_deg)
)
# Calm county-days (u and v both rounded to 0.00 m/s) have no direction.
# They are very rare; every other day gets a sector.
era5_days |>
summarize(calm_days = sum(is.na(wind_from_deg)),
min_deg = min(wind_from_deg, na.rm = TRUE),
max_deg = max(wind_from_deg, na.rm = TRUE))Look at one county during the June 2023 smoke. On June 2–8 the wind in Livingston County, NY, came from the north, northeast, or northwest every day.
# Livingston County, NY (GEOID 36051), home of SUNY Geneseo, in early June
# 2023, when smoke from fires in Quebec drifted over New York. Note the
# negative v (air moving south) on June 2-8, with winds from N, NE, or NW.
era5_days |>
filter(GEOID == "36051",
between(date, as.Date("2023-06-01"), as.Date("2023-06-12"))) |>
select(date, t2m_c, rh_pct, u10_ms, v10_ms, ws10_ms, wind_from_deg,
wind_sector, blh_m, ssrd_wm2)A wind rose counts days by the direction the wind came from.
# A wind rose: on how many days the daily wind came from each sector in
# Livingston County, 2021-2023. Each bar points to where the wind came
# from; start = -pi / 8 turns the plot so that "N" sits at the top.
era5_days |>
filter(GEOID == "36051", !is.na(wind_sector)) |>
count(wind_sector) |>
ggplot(aes(wind_sector, n)) +
geom_col(fill = "steelblue", width = 0.9) +
coord_polar(start = -pi / 8) +
labs(x = NULL, y = "Days")This function turns one year of county-days into county-months.
# Monthly means, one year at a time: read a year (1.1 million rows), shrink it
# to 37,296 county-months, and let R free the daily rows before the next year.
# u and v are averaged as plain numbers, which gives the average wind arrow.
# ws10_ms is the average of the daily speeds. It is never smaller than the
# length of the average arrow, because winds from opposite directions cancel
# in u and v but not in speed.
summarize_era5_year <- function(yr) {
read_era5_year(yr) |>
mutate(
year = year(date),
month = month(date),
from_deg = wind_from_deg(u10_ms, v10_ms),
# Northerly: the wind comes from anywhere between northwest (315) and
# northeast (45), a quarter of the compass centered on north.
northerly = from_deg >= 315 | from_deg <= 45
) |>
summarize(
n_days = n(),
across(c(t2m_c, d2m_c, rh_pct, u10_ms, v10_ms, ws10_ms, blh_m, ssrd_wm2,
z500_gpm, z500_anom_gpm), mean),
# Calm days have no direction (NA), so they are not counted as northerly.
days_north_wind = sum(northerly, na.rm = TRUE),
.by = c(GEOID, year, month)
)
}Drop the daily table from memory, run the function for all 20 years, then round.
# The 3.4 million daily rows above are no longer needed. Removing them before
# the 20-year loop frees about 370 MB, so the loop has room for each year.
rm(era5_days)
invisible(gc())
# All 20 years, then rounding to the same precision as the daily files.
era5_months <- map(2006:2025, summarize_era5_year) |>
list_rbind() |>
mutate(
across(c(t2m_c, d2m_c, u10_ms, v10_ms, ws10_ms), \(x) round(x, 2)),
across(c(rh_pct, ssrd_wm2, z500_gpm, z500_anom_gpm), \(x) round(x, 1)),
blh_m = round(blh_m)
) |>
arrange(GEOID, year, month)Check the grain and write the monthly file.
# Grain: one row per county x month, 3,108 counties x 240 months (2006-2025),
# complete, no missing values. Every month has all its days, northerly days
# fit inside the month, and the mean speed is at least the length of the
# mean arrow (0.02 m/s of slack for rounding).
stopifnot(
nrow(era5_months) == 3108 * 240,
nrow(distinct(era5_months, GEOID, year, month)) == nrow(era5_months),
!anyNA(era5_months),
all(era5_months$n_days == days_in_month(make_date(era5_months$year,
era5_months$month))),
all(between(era5_months$days_north_wind, 0, era5_months$n_days)),
all(era5_months$ws10_ms >=
sqrt(era5_months$u10_ms^2 + era5_months$v10_ms^2) - 0.02)
)
write_csv(era5_months, file.path(clean_dir, "era5_county_month_2006_2025.csv"),
na = "")Finally, map the number of northerly days in June 2023. The county points come from counties_2020.csv (module m00).
# Days with a northerly surface wind in June 2023, one dot per county, placed
# at the county's population-weighted center (from module m00).
counties <- read_csv(file.path(clean_dir, "counties_2020.csv"),
col_types = cols_only(GEOID = col_character(),
lat = col_double(), lon = col_double()))
era5_months |>
filter(year == 2023, month == 6) |>
left_join(counties, by = "GEOID") |>
ggplot(aes(lon, lat, color = days_north_wind)) +
geom_point(size = 0.6) +
scale_color_viridis_c(name = "Northerly\ndays") +
coord_quickmap() +
labs(x = NULL, y = NULL)Clean files
The daily files come from the instructor export (see the appendix); the monthly file is written by the code above.
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
era5_county_day/era5_county_day_<year>.csv.gz, 20 files for 2006–2025 |
1,134,420 per year (1,137,528 in 2008, 2012, 2016, 2020, 2024); 22,703,940 in all | a county × UTC day (instructor export) | 28.5–28.7 MB each (about 80 MB uncompressed); 571.5 MB in all |
era5_county_month_2006_2025.csv |
745,920 | a county × month, January 2006 to December 2025 (3,108 × 240) | 53.0 MB |
The monthly file is slightly above the 50 MB size at which this guide usually switches to .csv.gz. It stays an uncompressed .csv because the course plan names it that way, and 53 MB still opens quickly in R.
Clean variables
The 20 daily files share these columns. Rows are sorted by GEOID and date, and no cell is missing. Every weather value is a population-weighted county mean of the daily (UTC-day) means of hourly ERA5 values. read_csv() guesses these types correctly (the leading zeros keep GEOID as text), but read_era5_year() sets them explicitly so that nothing depends on the guess.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (2-digit state + 3-digit county), 2020 boundaries; joins to counties_2020.csv |
date |
date | — | UTC day of the daily mean (from 8 pm the evening before to 8 pm in New York in summer) |
t2m_c |
dbl | °C | Air temperature 2 m above the ground (raw t2m − 273.15). 2 decimals |
d2m_c |
dbl | °C | Dewpoint temperature 2 m above the ground (raw d2m − 273.15); never above t2m_c. 2 decimals |
rh_pct |
dbl | % | Relative humidity, 0–100. For each grid cell, 100 × e(d2m) / e(t2m), where e() is the saturation vapor pressure from the Magnus formula, capped at 100, then averaged over the county. It uses the daily-mean temperature and dewpoint, so it is not the same as the average of hourly humidity. 1 decimal |
u10_ms |
dbl | m/s | Eastward wind 10 m above the ground (raw u10); negative = toward the west. 2 decimals |
v10_ms |
dbl | m/s | Northward wind 10 m above the ground (raw v10); negative = toward the south. 2 decimals |
ws10_ms |
dbl | m/s | Wind speed of the daily-mean wind: √(u10_ms² + v10_ms²), computed from the unrounded county means. On a day when the wind turns, winds from opposite directions cancel, so this can be well below the average of the hourly speeds (it is never above it). 2 decimals |
blh_m |
dbl | m | Boundary-layer height: the depth of the well-mixed air next to the ground, averaged over the 24 hours (raw blh). Whole meters |
ssrd_wm2 |
dbl | W/m² | Sunlight reaching the ground, averaged over all 24 hours, nights included (raw ssrd / 3,600). 1 decimal |
z500_gpm |
dbl | gpm | Height of the 500 hPa pressure level (raw z / 9.80665): 4,907 to 6,019 gpm in 2006–2025, lower in winter and farther north. 1 decimal |
z500_anom_gpm |
dbl | gpm | z500_gpm minus the county’s normal height for that day of the year. The normal is a smooth seasonal curve (a constant plus sine and cosine waves with periods of 1 year, 6 months, and 4 months) fitted to the county’s 1991–2005 days. No trend is removed, so later years tend to be positive (see the cautions). Positive = higher than normal (a ridge overhead). 1 decimal |
era5_county_month_2006_2025.csv: one row per county × month, sorted by GEOID, year, and month, with no missing cells. read_csv() reads year, month, n_days, and days_north_wind as dbl.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code, 2020 boundaries |
year |
dbl | — | Calendar year, 2006 to 2025 (of the UTC day) |
month |
dbl | — | Calendar month, 1 (January) to 12 (December) |
n_days |
dbl | days | Days in the month (28–31); every day is present |
t2m_c |
dbl | °C | Monthly mean of daily t2m_c. 2 decimals |
d2m_c |
dbl | °C | Monthly mean of daily d2m_c. 2 decimals |
rh_pct |
dbl | % | Monthly mean of daily rh_pct. 1 decimal |
u10_ms |
dbl | m/s | Monthly mean of daily u10_ms (the eastward part of the average wind arrow). 2 decimals |
v10_ms |
dbl | m/s | Monthly mean of daily v10_ms (the northward part of the average wind arrow). 2 decimals |
ws10_ms |
dbl | m/s | Mean wind speed: monthly mean of daily ws10_ms. At least √(u10_ms² + v10_ms²), the speed of the average arrow. 2 decimals |
blh_m |
dbl | m | Monthly mean of daily blh_m. Whole meters |
ssrd_wm2 |
dbl | W/m² | Monthly mean of daily ssrd_wm2. 1 decimal |
z500_gpm |
dbl | gpm | Monthly mean of daily z500_gpm. 1 decimal |
z500_anom_gpm |
dbl | gpm | Monthly mean of daily z500_anom_gpm. 1 decimal |
days_north_wind |
dbl | days | Days in the month whose daily-mean 10 m wind came from the north: from 315° through 45° (northwest through north to northeast). Calm days are not counted. 0 to n_days |
Checks and cautions
- Daily files match the research project.
R/check_m09.Rcompares all 22,703,940 county-days with the project’s Parquet tables (sl_county_dayandz500_county_day). Every county is present on every day of 2006–2025, noGEOID–datepair is duplicated, no value is missing, and every value equals the project’s value rounded to the decimals listed above.d2m_cis never abovet2m_c. - Monthly file matches the research project. Monthly 500 hPa means differ from the project’s own county-month table (
z500_county_month.parquet) by at most 0.074 gpm. Surface means differ from means recomputed from the project’s unrounded county-days (checked for 2006, 2012, 2023, and 2025) by at most 0.007 °C, 0.007 m/s, 0.07 percentage points of humidity, 0.07 W/m², and 0.7 m of boundary-layer height. These small gaps come from averaging rounded daily values and rounding again. - Northerly days near the edges. A direction computed from rounded u and v can fall on the other side of 315° or 45° than the direction from unrounded values. Compared with directions from the project’s unrounded county-days, 98.1% of the 149,184 county-months checked have the same
days_north_wind, and none differs by more than 2 days. - Direction functions. On 100,000 random winds,
wind_from_deg()agrees with an independent formula (the bearing the wind blows toward,atan2(u, v), plus 180°) to within 10⁻¹³ degrees, andcompass_sector()always agrees with rounding the direction to the nearest multiple of 45°. A direction exactly on a sector border goes to the next sector clockwise: 22.5° is NE and 337.5° is N. - Sector and “northerly” differ. The NW sector covers 292.5–337.5°, but only 315° and above counts as northerly. On June 6, 2023, the wind in Livingston County came from 301°: sector NW, not northerly. The county had 6 northerly days that June (June 2, 3, 4, 5, 7, and 8).
- Calm days. 98 county-days in 2006–2025 have
u10_ms=v10_ms= 0.00 after rounding. Their direction and sector areNA, and they are not counted as northerly. ws10_msis the speed of the average wind. In the daily files it differs from √(u10_ms² +v10_ms²) of the rounded columns by at most 0.012 m/s. Because the arrow is averaged before its length is taken, it is lower than the average hourly wind speed on days when the wind turns. For the same reason, the monthlyws10_ms(the mean of daily speeds) exceeds the speed of the monthly mean arrow by 1.55 m/s in the median county-month.- Northerly winds are more common in the cold season. Over 2006–2025, 20.9% of county-days have a northerly daily wind: 25.0% of February days and 15.1% of July days. In June 2023, counties averaged 6.0 northerly days, and 6.5 in New York, Pennsylvania, New Jersey, and New England. Counties near the coast of northern California and Oregon had the most (up to 26 days, in Humboldt County, California): summer winds along that coast usually blow from the north.
- ERA5 agrees with station-based temperatures. Compared with nClimGrid daily mean temperature (module m04) on the 1,134,420 county-days of 2023,
t2m_chas a correlation of 0.980, is 0.62 °C warmer on average, and differs by 1.62 °C on average (6.2 °C or less on 99% of county-days). Part of the gap is definitions: ERA5 averages 24 hourly values over a UTC day, while nClimGrid’s mean is (maximum + minimum) / 2 over the observers’ reporting day. - UTC days, not local days. For U.S. counties the UTC day starts 4–8 hours before local midnight (8 pm EDT the evening before in New York, 5 pm PDT in California). Weather on a local evening therefore belongs to ERA5’s next date. Keep this in mind when you match ERA5 days with daily smoke or pollution data; monthly means are hardly affected.
- 500 hPa anomalies drift upward. The normal heights come from 1991–2005 and no trend is removed, so later years run high: the mean anomaly over 2006–2025 is +9.2 gpm, and yearly means range from −9.8 gpm (2008) to +22.2 gpm (2024). Compare anomalies within a period, or include year effects in a regression.
- Grid-cell values, not stations. The cells are about 18–28 km wide, so a county value smooths over valleys, lake shores, and cities and will differ from a nearby weather station. A coastal block-group center can sit in a cell that is partly sea; the research project notes that such cells blend land and sea values.
- No missing rows. Both files are complete: every county has a row for every day (daily files) and every month (monthly file), and no cell is empty. There is nothing to interpret as zero or unobserved.
- No county winds aloft. The 850 and 700 hPa winds (bundle B1) describe how smoke moves above the surface. The research project keeps them only as gridded files (
data/cleaned/winds/winds_layer/winds_layer_<year>.nc: the mean of the two levels, shifted to each place’s local solar day, the day as the sun defines it at that longitude). The bundle has no county table of them.
Putting it together: county panels
This section joins the clean files of the earlier sections into two analysis-ready tables. The monthly panel has one row for every county and month of 2006–2025, with county facts, population, four smoke measures (CSU, HMS plumes, Childs, EPA monitors), two weather sources (nClimGrid and ERA5), and Canada-wide fire activity. The daily panel has one row for every county and day of 2023, the year of the large Canadian smoke episodes, with daily smoke, monitor, model, weather, wind, and fire-share columns. No new data enter here: every number is copied from a file built in an earlier section, and each column name starts with a prefix that tells you which one.
Use the monthly panel for regressions and maps across years, and the daily panel for case studies such as the June 2023 smoke in the East. The panels cannot settle where a county’s smoke came from: CSU, HMS, Childs, and the monitors count smoke from all fires, and the Canadian measures (EMBER’s Canadian-fire PM2.5 and the nearby-fire share) are a model run and a distance rule. The panels also hold no health or other outcome data.
| Provider | Built in this guide (R/m12_panel.R) from the clean files of the places, FIRMS, HMS, AQS, nClimGrid, Childs, SEER, CSU, EMBER, ERA5, and episodes sections. Each of those sections names its original provider |
| Coverage | Monthly panel: January 2006 – December 2025 · daily panel: 1 January – 31 December 2023 · both: the 3,108 counties of the contiguous U.S. and DC (2020 boundaries) |
| Raw unit | none: this module ships no raw files (see “Raw files”) |
| Clean unit | one row = a county × month (monthly panel); a county × day (daily panel) |
| Licence and citation | Derived tables; cite the sources of the columns you use. The licence and citation rows of each earlier section give them (for example, CSU smoke: Pierce et al. 2026, Dryad, CC0 1.0) |
| Documentation | The “Clean variables” and “Checks and cautions” parts of each earlier section of this guide, which describe every input column; dplyr left_join() and its relationship argument; readr read_csv_chunked() |
Raw files
This module ships no raw files. It reads only clean files written by the earlier sections, all in wildfire_data/clean/:
| Clean input | Section | Used in | How it joins |
|---|---|---|---|
counties_2020.csv |
Places | both panels | builds the frame: every GEOID |
county_cbsa_2023.csv |
Places | monthly | GEOID, many-to-one |
seer_county_year_2006_2024.csv |
SEER | monthly | GEOID + year, many-to-one |
csu_smoke_county_month_2006_2025.csv |
CSU | monthly | GEOID + year + month, one-to-one |
hms_county_month_2006_2025.csv |
HMS | monthly | GEOID + year + month, one-to-one |
childs_smoke_county_month_2006_2023.csv |
Childs | monthly | GEOID + year + month, one-to-one |
aqs_county_month_2006_2025.csv |
AQS | monthly | GEOID + year + month, one-to-one |
nclimgrid_county_month_2006_2025.csv |
nClimGrid | monthly | GEOID + year + month, one-to-one |
era5_county_month_2006_2025.csv |
ERA5 | monthly | GEOID + year + month, one-to-one |
firms_canada_province_day_2003_2024.csv |
FIRMS | monthly | summed to Canada × month, then year + month, many-to-one |
csu_smoke_county_day/csu_smoke_county_day_2023.csv.gz |
CSU | daily | GEOID + date, one-to-one |
hms_calendar_2005_2025.csv |
HMS | daily | date, many-to-one |
hms_county_day_2006_2025.csv.gz |
HMS | daily | GEOID + date, one-to-one (2023 rows only) |
aqs_county_day_2006_2025.csv.gz |
AQS | daily | GEOID + date, one-to-one (2023 rows only) |
ember_county_day_2023.csv |
EMBER | daily | GEOID + date, one-to-one |
nclimgrid_county_day/nclimgrid_county_day_2023.csv.gz |
nClimGrid | daily | GEOID + date, one-to-one |
era5_county_day/era5_county_day_2023.csv.gz |
ERA5 | daily | GEOID + date, one-to-one |
childs_smoke_county_day/childs_smoke_county_day_2023.csv.gz |
Childs | daily | GEOID + date, one-to-one |
county_day_fire_share_2006_2025.csv.gz |
Episodes | daily | GEOID + date, one-to-one (2023 rows only) |
Raw variables
None: no raw files ship with this module. The earlier sections describe every column of the clean inputs.
Cleaning steps
The script makes these decisions:
- Start from a complete frame. Each panel begins as every county crossed with every month (3,108 × 240 = 745,920 rows) or every day of 2023 (3,108 × 365 = 1,134,420 rows). Every source is attached with
left_join(), which keeps all rows of the frame. A county-month or county-day that a source does not cover keeps its row and gets NA (missing) in that source’s columns, so no county silently drops out. - Declare each join’s relationship.
relationship = "one-to-one"means each frame row matches at most one source row, and the reverse."many-to-one"means many frame rows share one source row: one county-year of population fills 12 county-months, and one Canada-wide month fills all 3,108 counties. If a key were repeated where it should not be,left_join()would stop with an error instead of duplicating rows. After every join,stopifnot()checks that the row count has not changed. - Prefix every column by its source.
pop_SEER,csu_CSU,hms_HMS,childs_Childs,aqs_EPA AQS monitors,wx_nClimGrid weather,era5_ERA5,ember_EMBER,fire_ca_FIRMS (all of Canada), andnearfire_the nearby-fire Canadian share of the episodes section. County facts (county_name,state_abbr, …) andn_dayshave no prefix. A few columns are renamed to fit (for example CSU’stotal_pm25_meanbecomescsu_total_pm25); the variable tables give the original name. - Fill a missing row only when the answer is known. EPA day counts become 0 where a county had no monitor reading (0 days measured is true), but monitor means stay NA. HMS plume columns become 0 on analysed days without a plume, and stay NA on the one 2023 day nobody analysed. Everything else that is missing stays NA, with one meaning per column (see the variable tables).
- Hold 2025 population flat. SEER ends in 2024, so 2025 rows repeat the 2024 numbers, and
pop_carried_forwardisTRUEon them. - Drop duplicate bookkeeping. Each monthly source carries its own day count. The script checks that HMS, nClimGrid, and ERA5 agree with the frame’s
n_daysand keeps only that one. CSU’sn_days_obs,complete_month, andcsu_releaseare dropped after checking that every month is complete (the CSU section lists the release of each year). - Read only 2023 from the long daily files. The HMS, AQS, and fire-share county-day files cover 2006–2025.
read_csv_chunked()reads them 500,000 rows at a time and keeps only the 2023 rows, so the whole file never sits in memory.
The first chunk builds the monthly frame. The CBSA type (metropolitan or micropolitan area) comes along here; a county in no CBSA gets "none", because that is a real answer, not missing data.
# Every panel starts from a complete frame: each of the 3,108 counties in
# every month of 2006-2025. Joining the sources onto this frame (instead of
# joining sources to each other) means a county-month that a source leaves
# out still has a row, with NA in that source's columns.
counties <- read_csv(
file.path(clean_dir, "counties_2020.csv"),
col_types = cols_only(
GEOID = col_character(), county_name = col_character(),
state_abbr = col_character(), census_region = col_character(),
census_division = col_character(), east_split = col_character()
)
)
# A county outside any metro or micro area has an empty cbsa_type. That is a
# real answer, not missing data, so we spell it out as "none".
cbsa <- read_csv(
file.path(clean_dir, "county_cbsa_2023.csv"),
col_types = cols_only(GEOID = col_character(), cbsa_type = col_character())
) |>
mutate(cbsa_type = replace_na(cbsa_type, "none"))
stopifnot(nrow(counties) == 3108, !anyDuplicated(counties$GEOID),
setequal(cbsa$GEOID, counties$GEOID))
panel <- expand_grid(GEOID = counties$GEOID, year = 2006:2025, month = 1:12) |>
mutate(n_days = as.integer(days_in_month(make_date(year, month, 1)))) |>
left_join(counties, by = "GEOID", relationship = "many-to-one") |>
left_join(cbsa, by = "GEOID", relationship = "many-to-one")
stopifnot(nrow(panel) == 3108 * 240)
count(panel, cbsa_type)Every join in the script goes through this small helper. It does a left_join(), lets dplyr check the declared relationship, and stops if the row count changes.
# All later joins follow one pattern: left_join() onto the panel, declare
# the relationship (dplyr stops with an error if a key repeats where it
# should not), then check that the panel still has the same number of rows.
# A join that adds rows would silently double-count county-months.
add_source <- function(panel, source, by, relationship = "one-to-one") {
n_before <- nrow(panel)
out <- left_join(panel, source, by = by, relationship = relationship)
stopifnot(nrow(out) == n_before)
out
}Population (SEER). Key: GEOID + year; many-to-one, because one county-year matches 12 months. No NA: every county has every year, and 2025 is filled from 2024 and flagged.
# SEER July 1 populations run to 2024. For 2025 we hold the 2024 numbers
# flat and flag those rows, so you can drop or treat them differently.
# One county-year matches 12 county-months: a many-to-one join.
pop <- read_csv(
file.path(clean_dir, "seer_county_year_2006_2024.csv"),
col_types = cols_only(
GEOID = col_character(), year = col_integer(), pop_total = col_double(),
pop_under5 = col_double(), pop_under18 = col_double(),
pop_65p = col_double()
)
)
stopifnot(nrow(pop) == 3108 * 19)
pop <- pop |>
bind_rows(pop |> filter(year == 2024) |> mutate(year = 2025L)) |>
mutate(pop_carried_forward = year == 2025)
panel <- add_source(panel, pop, by = c("GEOID", "year"),
relationship = "many-to-one")
rm(pop) # free memory: the panel now holds these columns
stopifnot(!anyNA(panel$pop_total))CSU smoke. Key: GEOID + year + month; one-to-one. No NA: the CSU monthly file is complete. A 0 is a true zero (no resident under a plume on any day); negative monthly means are real and should be kept.
# CSU smoke PM2.5 (median background), already monthly and complete. Every
# month is complete (n_days_obs equals the days in the month), so we check
# that once and drop the bookkeeping columns. rename_with() adds the source
# prefix to every column except the keys.
csu <- read_csv(
file.path(clean_dir, "csu_smoke_county_month_2006_2025.csv"),
col_types = cols(GEOID = col_character(), year = col_integer(),
month = col_integer(), complete_month = col_logical(),
csu_release = col_date(), .default = col_double())
)
stopifnot(nrow(csu) == 3108 * 240, all(csu$complete_month))
csu <- csu |>
select(-n_days_obs, -complete_month, -csu_release) |>
rename(total_pm25 = total_pm25_mean) |>
rename_with(\(x) str_c("csu_", x), -c(GEOID, year, month))
panel <- add_source(panel, csu, by = c("GEOID", "year", "month"))
rm(csu)HMS plumes. Key: GEOID + year + month; one-to-one. No NA. Counts are days with a plume over the county; hms_analysed_days is the denominator to use for a share of days.
# HMS plume days. The monthly file is complete, and a 0 is a real zero on
# the analysed days. analysed_days is the same for every county; divide by
# it, not by n_days, if you want a share of days.
hms <- read_csv(
file.path(clean_dir, "hms_county_month_2006_2025.csv"),
col_types = cols(GEOID = col_character(), year = col_integer(),
month = col_integer(), .default = col_double())
) |>
rename_with(\(x) str_c("hms_", x), -c(GEOID, year, month))
stopifnot(nrow(hms) == 3108 * 240)
panel <- add_source(panel, hms, by = c("GEOID", "year", "month"))
rm(hms)
# The panel already has n_days; check that HMS agrees, then drop its copy.
stopifnot(all(panel$hms_n_days == panel$n_days))
panel <- select(panel, -hms_n_days)Childs smoke. Key: GEOID + year + month; one-to-one. NA in all of 2024 and 2025, because the Childs file ends in 2023: NA means “not available”, not “no smoke”.
# Childs et al. smoke PM2.5 ends in December 2023, so its columns are NA
# ("not available") in 2024 and 2025. Before then the file is complete and
# 0 means no smoke day in the month.
childs <- read_csv(
file.path(clean_dir, "childs_smoke_county_month_2006_2023.csv"),
col_types = cols(GEOID = col_character(), year = col_integer(),
month = col_integer(), .default = col_double())
) |>
rename(smoke_pm25 = smoke_mean) |>
rename_with(\(x) str_c("childs_", x), -c(GEOID, year, month))
stopifnot(nrow(childs) == 3108 * 216)
panel <- add_source(panel, childs, by = c("GEOID", "year", "month"))
rm(childs)EPA monitors (AQS). Key: GEOID + year + month; one-to-one. Only 202,737 of the 745,920 county-months have a row. Where a row is missing, the day counts are set to 0 and the means stay NA (“no monitor reading”). A mean is present exactly when its day count is above 0.
# EPA monitors cover only about a third of counties, and the monthly file
# has a row only for county-months with at least one reading. After the
# join, a county-month without a row has NA everywhere. The day counts
# have a true answer there, 0 days measured, so we fill them with 0. The
# means stay NA: no monitor, no value. Never read an NA mean as clean air.
aqs <- read_csv(
file.path(clean_dir, "aqs_county_month_2006_2025.csv"),
col_types = cols(GEOID = col_character(), year = col_integer(),
month = col_integer(), .default = col_double())
) |>
rename(o3_mda8_ppb = o3_mda8_mean_ppb) |>
rename_with(\(x) str_c("aqs_", x), -c(GEOID, year, month))
panel <- add_source(panel, aqs, by = c("GEOID", "year", "month")) |>
mutate(across(c(aqs_pm25_days, aqs_pm25_days_gt35, aqs_o3_days,
aqs_o3_days_gt70),
\(x) replace_na(x, 0)))
rm(aqs)
# Means are present exactly when at least one day was measured.
stopifnot(all(is.na(panel$aqs_pm25_mean) == (panel$aqs_pm25_days == 0)),
all(is.na(panel$aqs_o3_mda8_ppb) == (panel$aqs_o3_days == 0)))nClimGrid weather. Key: GEOID + year + month; one-to-one. No NA. wx_imputed marks Lexington city, Virginia, whose values are copied from the surrounding county.
# nClimGrid station-based weather, complete with no missing cells. The
# prefix wx_ marks it as the weather source of record; ERA5 (next chunk)
# is a second, model-based weather source.
wx <- read_csv(
file.path(clean_dir, "nclimgrid_county_month_2006_2025.csv"),
col_types = cols(GEOID = col_character(), year = col_integer(),
month = col_integer(), weather_imputed = col_logical(),
.default = col_double())
) |>
rename(imputed = weather_imputed) |>
rename_with(\(x) str_c("wx_", x), -c(GEOID, year, month))
stopifnot(nrow(wx) == 3108 * 240)
panel <- add_source(panel, wx, by = c("GEOID", "year", "month"))
rm(wx)
stopifnot(all(panel$wx_n_days == panel$n_days))
panel <- select(panel, -wx_n_days)ERA5. Key: GEOID + year + month; one-to-one. No NA.
# ERA5 reanalysis: surface weather, 10 m winds, and the 500 hPa height.
# Its months are made of UTC days (Coordinated Universal Time, the clock at
# Greenwich), which hardly matters for monthly means.
era5 <- read_csv(
file.path(clean_dir, "era5_county_month_2006_2025.csv"),
col_types = cols(GEOID = col_character(), year = col_integer(),
month = col_integer(), .default = col_double())
) |>
rename_with(\(x) str_c("era5_", x), -c(GEOID, year, month))
stopifnot(nrow(era5) == 3108 * 240)
panel <- add_source(panel, era5, by = c("GEOID", "year", "month"))
rm(era5)
stopifnot(all(panel$era5_n_days == panel$n_days))
panel <- select(panel, -era5_n_days)Canada-wide fire activity (FIRMS). The province-day file is summed to one row per month for all of Canada, by the local solar day of each detection (the calendar day by the sun at the fire). Key: year + month; many-to-one, because every county gets the same national value. NA in 2025, because the FIRMS files in the bundle end in 2024.
# Context, not exposure: how much fire did satellites see in all of Canada
# that month? We add the 13 province-days up to one value per month. FRP
# (fire radiative power) is the heat a fire gives off at the moment a
# satellite passes, in MW; a monthly sum of snapshots is an index of fire
# activity, not an energy total. The same national value goes to every
# county (many-to-one). The FIRMS file ends in 2024, so 2025 is NA.
fire_ca <- read_csv(
file.path(clean_dir, "firms_canada_province_day_2003_2024.csv"),
col_types = cols(prov_abbr = col_character(), local_date = col_date(),
.default = col_double())
) |>
mutate(year = as.integer(year(local_date)),
month = as.integer(month(local_date))) |>
filter(year >= 2006) |>
summarize(fire_ca_n_detections = sum(n_detections),
fire_ca_frp_total_mw = round(sum(frp_total_mw), 1),
.by = c(year, month))
stopifnot(nrow(fire_ca) == 19 * 12)
panel <- add_source(panel, fire_ca, by = c("year", "month"),
relationship = "many-to-one")The write step checks the grain (one row per county-month, no duplicated key) and a few ranges, then writes the compressed file.
# Final checks on the grain: one row per county-month, all 745,920 of them,
# then a few range checks on values that must not be negative.
panel <- arrange(panel, GEOID, year, month)
stopifnot(
nrow(panel) == 745920,
nrow(distinct(panel, GEOID, year, month)) == nrow(panel),
n_distinct(panel$GEOID) == 3108,
all(panel$hms_plume_days <= panel$n_days),
all(panel$csu_hms_days <= panel$n_days),
all(panel$pop_total > 0),
all(panel$era5_days_north_wind <= panel$n_days)
)
out_month <- file.path(clean_dir, "wildfire_county_month_panel_2006_2025.csv.gz")
write_csv(panel, out_month, na = "")
ncol(panel)This chunk lists every column with any NA and checks that only the expected columns have them.
# Where are the NAs? Each NA has one meaning per source: Childs ends in
# 2023, FIRMS ends in 2024, and AQS means are NA where no monitor reported.
# Every other column should be complete.
na_share_month <- panel |>
summarize(across(everything(), \(x) mean(is.na(x)))) |>
pivot_longer(everything(), names_to = "column", values_to = "na_share")
na_share_month |>
filter(na_share > 0) |>
mutate(na_share = round(na_share, 4))
na_cols <- filter(na_share_month, na_share > 0)$column
stopifnot(all(str_starts(na_cols, "childs_") | str_starts(na_cols, "fire_ca_") |
na_cols %in% c("aqs_pm25_mean", "aqs_o3_mda8_ppb")))An example that writes no file: population-weighted CSU smoke by month in the four eastern groups of states (the east_split column). June 2023 stands out in every group.
# Example (writes no file): population-weighted CSU smoke PM2.5 by month in
# the four eastern groups of states. weighted.mean() weights each county by
# its residents, so the line shows what the average resident breathed.
panel |>
filter(east_split != "Other") |>
summarize(smoke = weighted.mean(csu_smoke_pm25, pop_total),
.by = c(east_split, year, month)) |>
mutate(month_start = make_date(year, month, 1)) |>
ggplot(aes(month_start, smoke)) +
geom_line(linewidth = 0.3) +
facet_wrap(~east_split, ncol = 1) +
labs(x = NULL, y = "Smoke PM2.5 (ug/m3), population-weighted")The daily panel starts the same way, from a complete frame of the 3,108 counties and the 365 days of 2023. The monthly panel is removed first to free memory.
# The daily panel covers 2023 only, the year of the big Canadian smoke
# episodes (June 2023 in the East). First free the memory the monthly panel
# used, then build the complete frame: 3,108 counties x 365 days.
rm(panel, fire_ca)
day_panel <- expand_grid(
GEOID = counties$GEOID,
date = seq(ymd("2023-01-01"), ymd("2023-12-31"), by = "day")
) |>
left_join(select(counties, GEOID, county_name, state_abbr, east_split),
by = "GEOID", relationship = "many-to-one")
stopifnot(nrow(day_panel) == 3108 * 365)This helper reads only the 2023 rows of a multi-year file, one piece at a time.
# Three daily sources come as one file for all of 2006-2025 (4 to 5.5
# million rows). read_csv_chunked() reads 500,000 rows at a time and keeps
# only the 2023 rows of each piece, so the whole file never sits in memory.
read_2023 <- function(file, col_types) {
read_csv_chunked(
file,
callback = DataFrameCallback$new(\(chunk, pos) filter(chunk, year(date) == 2023)),
chunk_size = 500000,
col_types = col_types
)
}CSU daily smoke. Key: GEOID + date; one-to-one. No NA. csu_hms_share is the share of residents under a plume; use it, not csu_smoke_pm25 == 0, to find plume-free days.
# CSU smoke and total PM2.5 for 2023, complete. hms_share is the share of
# residents under a plume; smoke_pm25 is exactly 0 when it is 0.
csu_day <- read_csv(
file.path(clean_dir, "csu_smoke_county_day", "csu_smoke_county_day_2023.csv.gz"),
col_types = cols_only(GEOID = col_character(), date = col_date(),
smoke_pm25 = col_double(), total_pm25 = col_double(),
hms_share = col_double())
) |>
rename_with(\(x) str_c("csu_", x), -c(GEOID, date))
stopifnot(nrow(csu_day) == 3108 * 365)
day_panel <- add_source(day_panel, csu_day, by = c("GEOID", "date"))
rm(csu_day)HMS daily plumes. Two joins. The calendar joins on date alone (many-to-one: one day, all counties) and adds hms_analysed. The county-day file joins on GEOID + date (one-to-one) but lists only county-days with a plume. On an analysed day without a row, the plume columns become 0 (no plume seen). On the one day of 2023 that HMS did not analyse, 12 May, they stay NA (unknown), for all 3,108 counties.
# The HMS county-day file lists only county-days with a plume. A county-day
# without a row had no plume, which is a real 0 only if HMS analysts worked
# that day. The calendar says which days were analysed. In 2023 every plume
# has a density label, so max_density = 0 can only mean "no plume".
hms_calendar <- read_csv(
file.path(clean_dir, "hms_calendar_2005_2025.csv"),
col_types = cols_only(date = col_date(), analysed = col_logical())
) |>
filter(year(date) == 2023) |>
rename(hms_analysed = analysed)
hms_day <- read_2023(
file.path(clean_dir, "hms_county_day_2006_2025.csv.gz"),
col_types = cols(GEOID = col_character(), date = col_date(),
.default = col_double())
) |>
rename_with(\(x) str_c("hms_", x), -c(GEOID, date))
# Every plume row must fall on an analysed day.
stopifnot(all(hms_day$date %in% hms_calendar$date[hms_calendar$hms_analysed]))
day_panel <- day_panel |>
add_source(hms_calendar, by = "date", relationship = "many-to-one") |>
add_source(hms_day, by = c("GEOID", "date")) |>
mutate(across(c(hms_n_plumes, hms_max_density, hms_pop_share_any,
hms_pop_share_medium_plus, hms_pop_share_heavy),
\(x) if_else(hms_analysed, replace_na(x, 0), NA_real_)))
rm(hms_day)
filter(hms_calendar, !hms_analysed)EPA daily monitors. Key: GEOID + date; one-to-one. NA in the means where no monitor reported that day (82.5% of county-days for PM2.5); the site counts are 0 there.
# EPA monitor means for the county. No row means no monitor reported that
# day: the means stay NA, and the site counts become 0.
aqs_day <- read_2023(
file.path(clean_dir, "aqs_county_day_2006_2025.csv.gz"),
col_types = cols(GEOID = col_character(), date = col_date(),
.default = col_double())
) |>
rename_with(\(x) str_c("aqs_", x), -c(GEOID, date))
day_panel <- add_source(day_panel, aqs_day, by = c("GEOID", "date")) |>
mutate(across(c(aqs_n_sites_pm25, aqs_n_sites_o3), \(x) replace_na(x, 0)))
rm(aqs_day)
stopifnot(all(is.na(day_panel$aqs_pm25_mean) == (day_panel$aqs_n_sites_pm25 == 0)))EMBER model. Key: GEOID + date; one-to-one. NA outside the modeled period, 11 April to 29 September 2023 (NA = not modeled).
# EMBER model runs cover 11 April to 29 September 2023. Outside those days
# its columns are NA: not modeled. pm25_fire is PM2.5 from U.S. and Canadian
# fires; pm25_canada_fire is the part from Canadian fires.
ember_day <- read_csv(
file.path(clean_dir, "ember_county_day_2023.csv"),
col_types = cols_only(GEOID = col_character(), date = col_date(),
pm25_base = col_double(), pm25_fire = col_double(),
pm25_canada_fire = col_double())
) |>
rename_with(\(x) str_c("ember_", x), -c(GEOID, date))
stopifnot(nrow(ember_day) == 3108 * 172)
day_panel <- add_source(day_panel, ember_day, by = c("GEOID", "date"))
rm(ember_day)nClimGrid daily weather. Key: GEOID + date; one-to-one. No NA.
# nClimGrid daily weather for 2023, complete. imputed is TRUE only for
# Lexington city, Virginia, which copies Rockbridge County.
wx_day <- read_csv(
file.path(clean_dir, "nclimgrid_county_day", "nclimgrid_county_day_2023.csv.gz"),
col_types = cols(GEOID = col_character(), date = col_date(),
weather_imputed = col_logical(), .default = col_double())
) |>
rename(imputed = weather_imputed) |>
rename_with(\(x) str_c("wx_", x), -c(GEOID, date))
stopifnot(nrow(wx_day) == 3108 * 365)
day_panel <- add_source(day_panel, wx_day, by = c("GEOID", "date"))
rm(wx_day)ERA5 daily winds. Key: GEOID + date; one-to-one. The script adds the wind direction, in degrees clockwise from north that the wind comes from, with the same formula as the ERA5 section. It is NA only on calm days, when both wind parts round to 0 (4 county-days in 2023).
# ERA5 10 m winds. u is the eastward part and v the northward part of the
# wind arrow. Weather reports give the direction the wind comes FROM, in
# degrees clockwise from north (0 = from the north, 90 = from the east).
# This is the same formula as in the ERA5 section; a calm day (u = v = 0)
# has no direction.
wind_from_deg <- function(u, v) {
if_else(u == 0 & v == 0, NA_real_, (270 - atan2(v, u) * 180 / pi) %% 360)
}
era5_day <- read_csv(
file.path(clean_dir, "era5_county_day", "era5_county_day_2023.csv.gz"),
col_types = cols_only(GEOID = col_character(), date = col_date(),
u10_ms = col_double(), v10_ms = col_double(),
ws10_ms = col_double())
) |>
mutate(wind_from_deg = round(wind_from_deg(u10_ms, v10_ms), 1)) |>
rename_with(\(x) str_c("era5_", x), -c(GEOID, date))
stopifnot(nrow(era5_day) == 3108 * 365)
day_panel <- add_source(day_panel, era5_day, by = c("GEOID", "date"))
rm(era5_day)Childs daily smoke. Key: GEOID + date; one-to-one. No NA: 0 on days that were not smoke days for Childs.
# Childs et al. smoke PM2.5 also covers 2023 and is complete: 0 on days
# that were not smoke days.
childs_day <- read_csv(
file.path(clean_dir, "childs_smoke_county_day", "childs_smoke_county_day_2023.csv.gz"),
col_types = cols(GEOID = col_character(), date = col_date(),
smoke_pm25 = col_double(), smoke_day = col_logical())
) |>
rename_with(\(x) str_c("childs_", x), -c(GEOID, date))
stopifnot(nrow(childs_day) == 3108 * 365)
day_panel <- add_source(day_panel, childs_day, by = c("GEOID", "date"))
rm(childs_day)Nearby-fire Canadian share. Key: GEOID + date; one-to-one. The file has a row only for county-days with positive CSU smoke (448,264 of the 1,134,420 county-days of 2023). Elsewhere the columns are NA, meaning “not applicable: no smoke to attribute”, not “no fire”. Within the covered rows, nearfire_share_ca_sameday is also NA on 86 county-days when no fire burned within 1,500 km that day.
# The nearby-fire Canadian share (Canadian FRP within 1,500 km divided by
# all FRP within 1,500 km) exists only on county-days with CSU smoke above
# 0. Elsewhere it is NA: not applicable, because there was no smoke to
# attribute. The lag version adds FRP over the day and the 3 days before.
fire_share_day <- read_2023(
file.path(clean_dir, "county_day_fire_share_2006_2025.csv.gz"),
col_types = cols_only(
GEOID = col_character(), date = col_date(),
share_ca_sameday = col_double(), share_ca_lag03 = col_double(),
ca_dominated_sameday = col_logical(), ca_dominated_lag03 = col_logical()
)
) |>
rename_with(\(x) str_c("nearfire_", x), -c(GEOID, date))
day_panel <- add_source(day_panel, fire_share_day, by = c("GEOID", "date"))
rm(fire_share_day)
# A share appears only where CSU smoke was positive (it can still round
# to 0.00 when tiny).
stopifnot(all(day_panel$csu_smoke_pm25[!is.na(day_panel$nearfire_share_ca_lag03)] >= 0))The daily write step checks the grain and two ranges, then writes the file.
# Grain: one row per county-day of 2023, all 1,134,420 of them.
day_panel <- arrange(day_panel, GEOID, date)
stopifnot(
nrow(day_panel) == 1134420,
nrow(distinct(day_panel, GEOID, date)) == nrow(day_panel),
all(between(day_panel$csu_hms_share, 0, 1)),
all(day_panel$era5_ws10_ms >= 0)
)
out_day <- file.path(clean_dir, "wildfire_county_day_2023.csv.gz")
write_csv(day_panel, out_day, na = "")
ncol(day_panel)The NA shares of the daily panel, column by column:
# NA shares in the daily panel. HMS columns are NA on the one 2023 day
# without HMS analysis, EMBER outside its modeled season, AQS where no
# monitor reported, and the fire share where CSU smoke was not positive.
na_share_day <- day_panel |>
summarize(across(everything(), \(x) mean(is.na(x)))) |>
pivot_longer(everything(), names_to = "column", values_to = "na_share")
na_share_day |>
filter(na_share > 0) |>
mutate(na_share = round(na_share, 4))An example that writes no file: the ten smokiest county-days of June 2023 by CSU smoke, next to the monitor reading, EMBER’s Canadian-fire PM2.5, the nearby-fire share, and the wind direction. Nine of the ten are in eastern Pennsylvania on 7 June, with winds from the northwest (315°–323°) and a Canadian share above 0.92.
# Example (writes no file): the ten smokiest county-days of June 2023 by
# CSU smoke PM2.5, next to the monitor reading, the EMBER Canadian-fire
# PM2.5, the nearby-fire Canadian share, and the wind direction.
day_panel |>
filter(month(date) == 6) |>
slice_max(csu_smoke_pm25, n = 10) |>
select(date, county_name, state_abbr, csu_smoke_pm25, aqs_pm25_mean,
ember_pm25_canada_fire, nearfire_share_ca_sameday,
era5_wind_from_deg) |>
print(width = Inf)In a later session, read the panels back with declared column types, one at a time:
# How to read the panels in a later session. Plain read_csv() guesses the
# types correctly here, but declaring them is safer: GEOID must stay text,
# and the logical flags should not depend on a guess. Every other column
# is a number (dbl). Each panel needs several hundred MB of memory, so we
# keep only one in memory at a time.
rm(day_panel)
month_types <- cols(
GEOID = col_character(), year = col_integer(), month = col_integer(),
county_name = col_character(), state_abbr = col_character(),
census_region = col_character(), census_division = col_character(),
east_split = col_character(), cbsa_type = col_character(),
pop_carried_forward = col_logical(), wx_imputed = col_logical(),
.default = col_double()
)
panel <- read_csv(out_month, col_types = month_types)
stopifnot(nrow(panel) == 745920, ncol(panel) == 64, nrow(problems(panel)) == 0)
rm(panel)
day_types <- cols(
GEOID = col_character(), date = col_date(), county_name = col_character(),
state_abbr = col_character(), east_split = col_character(),
hms_analysed = col_logical(), wx_imputed = col_logical(),
childs_smoke_day = col_logical(),
nearfire_ca_dominated_sameday = col_logical(),
nearfire_ca_dominated_lag03 = col_logical(),
.default = col_double()
)
day_panel <- read_csv(out_day, col_types = day_types)
stopifnot(nrow(day_panel) == 1134420, ncol(day_panel) == 36,
nrow(problems(day_panel)) == 0)Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
wildfire_county_month_panel_2006_2025.csv.gz |
745,920 | a county × month, January 2006 – December 2025 (3,108 × 240; complete) | 53.4 MB (202.2 MB unzipped) |
wildfire_county_day_2023.csv.gz |
1,134,420 | a county × day of 2023 (3,108 × 365; complete) | 39.8 MB (172.6 MB unzipped) |
Clean variables
wildfire_county_month_panel_2006_2025.csv.gz: 64 columns, one row per county × month, sorted by GEOID, year, and month; the key is GEOID + year + month. The types are the ones plain read_csv() gives; chunk m12-read-back reads year and month as int. “Source” names the earlier section and, when it differs, the original column name.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (2-digit state + 3-digit county), 2020 boundaries |
year |
dbl | — | Calendar year, 2006–2025 |
month |
dbl | — | Calendar month, 1–12 |
n_days |
dbl | days | Days in the month (28–31); equals the day counts of HMS, nClimGrid, and ERA5 |
county_name |
chr | — | County name with its legal description (places section) |
state_abbr |
chr | — | 2-letter state abbreviation |
census_region |
chr | — | Census region: Northeast, Midwest, South, West |
census_division |
chr | — | One of the 9 Census divisions |
east_split |
chr | — | The project’s eastern groups: New England, Mid-Atlantic, Great Lakes, Upper Midwest, or Other (places section) |
cbsa_type |
chr | — | metro (metropolitan area), micro (micropolitan area), or none (in no CBSA; empty in county_cbsa_2023.csv). July 2023 delineation; approximate for the 8 Connecticut counties |
pop_total |
dbl | persons | SEER July 1 population of the year, all ages. 2025 = 2024 |
pop_under5 |
dbl | persons | Ages 0–4 (SEER) |
pop_under18 |
dbl | persons | Ages 0–17 (SEER) |
pop_65p |
dbl | persons | Ages 65 and older (SEER) |
pop_carried_forward |
lgl | — | TRUE in 2025, where the SEER 2024 populations are repeated; FALSE otherwise |
csu_smoke_pm25 |
dbl | µg/m³ | CSU: monthly mean of daily smoke PM2.5 (median background, population-weighted). Can be negative; keep it. CSU smoke_pm25 |
csu_total_pm25 |
dbl | µg/m³ | CSU: monthly mean of daily total PM2.5. CSU total_pm25_mean |
csu_smoke_max |
dbl | µg/m³ | CSU: highest daily smoke PM2.5 in the month |
csu_hms_days |
dbl | days | CSU: days with more than half of residents under an HMS plume (hms_share > 0.5) |
csu_days_gt5 |
dbl | days | CSU: days with smoke PM2.5 above 5 µg/m³ |
csu_days_gt10 |
dbl | days | CSU: days above 10 µg/m³ |
csu_days_gt15 |
dbl | days | CSU: days above 15 µg/m³ |
csu_days_gt35 |
dbl | days | CSU: days above 35 µg/m³ |
hms_plume_days |
dbl | days | HMS: days with at least one plume over the county’s land. HMS plume_days |
hms_medium_plus_days |
dbl | days | HMS: days whose densest plume was Medium or Heavy. Comparable over time only from June 2010, when labels became complete |
hms_heavy_days |
dbl | days | HMS: days whose densest plume was Heavy |
hms_pop_weighted_plume_days |
dbl | days | HMS: plume days weighted by the share of residents under the plume |
hms_analysed_days |
dbl | days | HMS: days of the month with any plume anywhere (analysed days); the same for every county. Use it as the denominator of a share of days |
childs_smoke_pm25 |
dbl | µg/m³ | Childs (v2.0 beta): mean daily smoke PM2.5 over all days of the month, zeros included. Childs smoke_mean. NA in 2024–2025 (not available) |
childs_smoke_max |
dbl | µg/m³ | Childs: highest daily smoke PM2.5. NA in 2024–2025 |
childs_smoke_days |
dbl | days | Childs: smoke days (county-days in the raw Childs file). NA in 2024–2025 |
childs_days_gt5 |
dbl | days | Childs: days above 5 µg/m³. NA in 2024–2025 |
childs_days_gt15 |
dbl | days | Childs: days above 15 µg/m³. NA in 2024–2025 |
childs_days_gt35 |
dbl | days | Childs: days above 35 µg/m³. NA in 2024–2025 |
aqs_pm25_mean |
dbl | µg/m³ | AQS: mean of the county’s daily monitor PM2.5 over days with a reading. NA where no PM2.5 monitor reported in the month (81.1% of rows) |
aqs_pm25_days |
dbl | days | AQS: days with a PM2.5 reading; 0 where the source has no row |
aqs_pm25_days_gt35 |
dbl | days | AQS: days with county PM2.5 above 35.4 µg/m³; 0 where the source has no row |
aqs_o3_mda8_ppb |
dbl | ppb | AQS: mean daily maximum 8-hour ozone over days with a reading. AQS o3_mda8_mean_ppb. NA where no ozone monitor reported (78.7% of rows) |
aqs_o3_days |
dbl | days | AQS: days with an ozone reading; 0 where the source has no row |
aqs_o3_days_gt70 |
dbl | days | AQS: days with ozone above 70 ppb; 0 where the source has no row |
wx_tmax_mean_c |
dbl | °C | nClimGrid: mean daily maximum temperature |
wx_tmin_mean_c |
dbl | °C | nClimGrid: mean daily minimum temperature |
wx_tavg_mean_c |
dbl | °C | nClimGrid: mean of (max + min) / 2 |
wx_prcp_total_mm |
dbl | mm | nClimGrid: total precipitation |
wx_days_tmax_ge32 |
dbl | days | nClimGrid: days with maximum temperature ≥ 32 °C |
wx_days_tmin_le0 |
dbl | days | nClimGrid: days with minimum temperature ≤ 0 °C |
wx_days_prcp_0 |
dbl | days | nClimGrid: dry days (county precipitation rounds to 0.00 mm) |
wx_days_prcp_0_5 |
dbl | days | nClimGrid: days with more than 0 and at most 5 mm |
wx_days_prcp_5_15 |
dbl | days | nClimGrid: days with more than 5 and at most 15 mm |
wx_days_prcp_gt15 |
dbl | days | nClimGrid: days with more than 15 mm |
wx_imputed |
lgl | — | nClimGrid: TRUE for Lexington city, Virginia (51678), copied from Rockbridge County. nClimGrid weather_imputed |
era5_t2m_c |
dbl | °C | ERA5: mean air temperature 2 m above the ground (UTC days) |
era5_d2m_c |
dbl | °C | ERA5: mean dewpoint temperature at 2 m |
era5_rh_pct |
dbl | % | ERA5: mean relative humidity |
era5_u10_ms |
dbl | m/s | ERA5: mean eastward wind at 10 m (negative = toward the west) |
era5_v10_ms |
dbl | m/s | ERA5: mean northward wind at 10 m (negative = toward the south) |
era5_ws10_ms |
dbl | m/s | ERA5: mean of the daily wind speeds |
era5_blh_m |
dbl | m | ERA5: mean boundary-layer height (depth of the well-mixed air near the ground) |
era5_ssrd_wm2 |
dbl | W/m² | ERA5: mean sunlight reaching the ground, nights included |
era5_z500_gpm |
dbl | gpm | ERA5: mean height of the 500 hPa pressure level |
era5_z500_anom_gpm |
dbl | gpm | ERA5: mean 500 hPa height minus the county’s 1991–2005 normal; drifts upward over time |
era5_days_north_wind |
dbl | days | ERA5: days whose daily-mean wind came from 315° through 45° (northwest to northeast) |
fire_ca_n_detections |
dbl | count | FIRMS: MODIS vegetation-fire detections in all of Canada that month (local solar days), the same for every county. NA in 2025 |
fire_ca_frp_total_mw |
dbl | MW | FIRMS: sum of fire radiative power (FRP) over those detections, 1 decimal. A sum of satellite snapshots, an index of fire activity, not an energy total. NA in 2025 |
wildfire_county_day_2023.csv.gz: 36 columns, one row per county × day of 2023, sorted by GEOID and date; the key is GEOID + date. The types are the ones plain read_csv() gives.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code, 2020 boundaries |
date |
date | — | Day, 2023-01-01 to 2023-12-31. Each source dates its days its own way (see “Checks and cautions”) |
county_name |
chr | — | County name (places section) |
state_abbr |
chr | — | 2-letter state abbreviation |
east_split |
chr | — | The project’s eastern groups, or Other |
csu_smoke_pm25 |
dbl | µg/m³ | CSU: daily smoke PM2.5 (median background, population-weighted). Exactly 0 when no resident was under a plume; can be negative |
csu_total_pm25 |
dbl | µg/m³ | CSU: daily total PM2.5 (kriged from EPA monitors) |
csu_hms_share |
dbl | share (0–1) | CSU: share of residents in grid cells under an HMS plume. CSU hms_share |
hms_analysed |
lgl | — | HMS calendar: TRUE if HMS drew at least one plume anywhere that day; FALSE only on 12 May 2023. HMS analysed |
hms_n_plumes |
dbl | count | HMS: plume polygons touching the county’s land. 0 on analysed days without a plume; NA on 12 May 2023 |
hms_max_density |
dbl | code | HMS: densest plume, 1 = Light, 2 = Medium, 3 = Heavy; 0 = no plume (in 2023 every plume has a label). NA on 12 May 2023 |
hms_pop_share_any |
dbl | share (0–1) | HMS: share of the county’s 2020 population whose block-group center lies inside any plume. 0 / NA as for hms_n_plumes |
hms_pop_share_medium_plus |
dbl | share (0–1) | HMS: the same for Medium or Heavy plumes |
hms_pop_share_heavy |
dbl | share (0–1) | HMS: the same for Heavy plumes |
aqs_pm25_mean |
dbl | µg/m³ | AQS: mean daily PM2.5 over the county’s monitors that reported. NA where none reported (82.5% of rows) |
aqs_n_sites_pm25 |
dbl | count | AQS: number of PM2.5 sites in the mean; 0 where none reported |
aqs_o3_mda8_ppb |
dbl | ppb | AQS: mean daily maximum 8-hour ozone over the county’s sites. NA where none reported (79.4% of rows) |
aqs_n_sites_o3 |
dbl | count | AQS: number of ozone sites; 0 where none reported |
ember_pm25_base |
dbl | µg/m³ | EMBER: modeled PM2.5 with all emissions. NA outside 11 April – 29 September 2023 (52.9% of rows) |
ember_pm25_fire |
dbl | µg/m³ | EMBER: PM2.5 from U.S. and Canadian fires (base run minus no-fire run). NA outside the modeled period |
ember_pm25_canada_fire |
dbl | µg/m³ | EMBER: PM2.5 from Canadian fires (base run minus no-Canadian-fire run). NA outside the modeled period |
wx_tmax_c |
dbl | °C | nClimGrid: daily maximum temperature |
wx_tmin_c |
dbl | °C | nClimGrid: daily minimum temperature |
wx_tavg_c |
dbl | °C | nClimGrid: (max + min) / 2 |
wx_prcp_mm |
dbl | mm | nClimGrid: daily precipitation |
wx_imputed |
lgl | — | nClimGrid: TRUE for Lexington city, Virginia |
era5_u10_ms |
dbl | m/s | ERA5: daily-mean eastward wind at 10 m (UTC day) |
era5_v10_ms |
dbl | m/s | ERA5: daily-mean northward wind at 10 m |
era5_ws10_ms |
dbl | m/s | ERA5: speed of the daily-mean wind |
era5_wind_from_deg |
dbl | degrees | Direction the daily-mean wind comes from, clockwise from north (0 = from the north, 90 = from the east), computed here from the two wind columns; 1 decimal. NA on calm days (4 rows) |
childs_smoke_pm25 |
dbl | µg/m³ | Childs: daily smoke PM2.5; 0 on non-smoke days |
childs_smoke_day |
lgl | — | Childs: TRUE if the county-day is a smoke day for Childs |
nearfire_share_ca_sameday |
dbl | share (0–1) | Episodes section: Canadian FRP ÷ all FRP within 1,500 km, same local solar day. NA where CSU smoke was not positive (not applicable), and on 86 smoky county-days with no fire within 1,500 km. share_ca_sameday |
nearfire_share_ca_lag03 |
dbl | share (0–1) | The same with FRP summed over the day and the 3 days before. NA where CSU smoke was not positive. share_ca_lag03 |
nearfire_ca_dominated_sameday |
lgl | — | TRUE when the unrounded same-day share is at least 0.5; NA where CSU smoke was not positive. ca_dominated_sameday |
nearfire_ca_dominated_lag03 |
lgl | — | The same for the lag share. ca_dominated_lag03 |
Checks and cautions
- Every value equals its source.
R/check_m12.Rcompares all 92 joined columns of both panels with the clean files they came from, over every row, not only a sample: there are 0 mismatches and a largest difference of 0. For each source it also prints a spot check of 5 random keys, with the panel value next to the source value (logs/check_m12.log). The fills behave as described: the AQS day counts are 0 exactly where the source has no row, the HMS columns are 0 on analysed days without a plume (the 532,963 plume rows of 2023 are exactly the panel rows withhms_n_plumes> 0), andera5_wind_from_degequals an independent formula (the bearing the wind blows toward, plus 180°) on every county-day. - Agreement with the research project. HMS plume days match the project’s monthly HMS table in 745,606 of 745,920 county-months (314 differ by 1 day, as the HMS section explains). Childs monthly smoke agrees within 0.005 µg/m³ (rounding), with identical smoke-day counts, and SEER populations are identical in all 59,052 county-years.
- Complete panels. Both files have every county on every month or day, with no duplicated key. Missing values (NA) appear only where a source does not apply or was not observed. Monthly panel: Childs columns 10.0% (2024–2025),
aqs_pm25_mean81.1%,aqs_o3_mda8_ppb78.7%,fire_ca_columns 5.0% (2025); all other columns 0. Daily panel: HMS plume columns 0.27% (12 May),aqs_pm25_mean82.5%,aqs_o3_mda8_ppb79.4%, EMBER 52.9%,nearfire_columns 60.5%,era5_wind_from_deg4 rows; all other columns 0. - An NA is not a zero. NA means “not observed” for AQS and for HMS on 12 May 2023, “not available” for Childs after 2023, FIRMS in 2025, and EMBER outside its season, and “not applicable” for the nearby-fire share on days without CSU smoke. Do not replace these NAs with 0. If you average a column with NAs,
mean(x, na.rm = TRUE)averages only the counties and months that have a value, which for AQS is a different, mostly urban set of counties. - Monitors cover few counties. Only 140,915 county-months (18.9%) have a PM2.5 mean and 158,957 (21.3%) an ozone mean; in 2023 only 198,112 county-days (17.5%) have a PM2.5 reading. A regression on
aqs_pm25_meanuses a small, urban sample; CSU total PM2.5 covers every county but is interpolated from those same monitors. - Several smoke measures, not one. CSU, Childs, and EMBER estimate surface smoke PM2.5 in three different ways, HMS shows smoke anywhere in the air column, and the monitors measure total PM2.5. They do not agree day by day. On 28 June 2023, De Witt County, Illinois, has 197 µg/m³ of CSU smoke and 140 µg/m³ of EMBER Canadian-fire PM2.5, but a nearby-fire Canadian share of only 0.41: the share counts fires within 1,500 km that day, not the fires the smoke came from. Pick one measure for your main analysis and use the others as checks.
- Days are not all the same day. CSU, AQS, Childs, and nClimGrid use local days (nClimGrid’s are observers’ reporting days); HMS and ERA5 use UTC days, which start 4–8 hours before local midnight in the U.S.; the nearby-fire share uses fires’ local solar days; and EMBER’s day is probably local standard time (see its section). A same-date join can be off by part of a day, which matters for single days but hardly for months.
- Choices carried over from the sources. CSU smoke uses the median background (the CSU section explains the choice; the mean background gives lower smoke). HMS density labels are incomplete before June 2010. Childs is a beta version. AQS county means include both original and corrected T640 monitor data. Lexington city’s weather is a copy of Rockbridge County’s (
wx_imputed). The 2024 and 2025 CSU years come from later releases than 2006–2023. - 2025 population is held flat.
pop_carried_forwardmarks the 2025 rows. Weighted averages for 2025 use 2024 populations. - Canadian fire activity is a national total.
fire_ca_frp_total_mwis the same for every county in a month and peaks in September 2023 (8.40 million MW from 46,810 detections) and June 2023 (8.08 million MW from 53,144 detections). It says how much fire Canada had, not how much smoke reached a county. In a regression with month or year-by-month fixed effects it is absorbed completely. - Eastern smoke peaked in June 2023. In the example chunk, population-weighted CSU smoke reaches 19.9 µg/m³ in the Great Lakes group, 17.2 in the Mid-Atlantic group, and 14.1 in the Upper Midwest group in June 2023, the three highest group-months of 2006–2025; the next is the Upper Midwest in July 2021 (12.4).
- Reading the files. Plain
read_csv()reads both files without problems:GEOIDstays text because the first county,01001, has a leading zero, and the logical columns are guessed correctly. Chunkm12-read-backdeclares the types anyway. In R the monthly panel takes about 360 MB of memory and the daily panel about 290 MB; keep only one in memory if your computer has 8 GB or less. - Memory and time. The whole script runs in about 30 seconds on the instructor’s Mac, with a peak memory use of about 2 GB (the operating system’s peak “memory footprint” was 2.0 GB). It removes each source after joining it and reads the long daily files in 500,000-row pieces to stay there. When you run it with
Rscriptfrom the Terminal instead of in RStudio, R saves the example plot toRplots.pdfin your project folder; you can delete it.
Use, licences, and citation
- All sources are free public data. U.S. federal data (Census, NOAA, NASA, EPA, NCI SEER) are public domain; Statistics Canada boundaries and CWFIS NBAC are under the Open Government Licence – Canada; the CSU product (Dryad) is CC0; EMBER (Zenodo) is CC BY 4.0. Each section gives the citation to use.
- Cite the original provider, not this course, when you use a dataset in your paper.
- Do not add mortality or other health-outcome data to this folder or to your project repository.
- Keep
wildfire_data/out of Git: addwildfire_data/to your.gitignore. Share code, not data.
Appendix: how the instructor exported project-built files
Three clean datasets (CSU smoke for all years, ERA5 weather, and the smoke episodes) come from raw files too large to share or from the research project’s own pipeline. Students do not run this code; it is here so every clean file has a documented origin.
CSU kriged smoke PM2.5: instructor export
The raw CSU product is 40 NetCDF files of about 515 MB each (20.6 GB in all), too large for the student bundle. This export gives students the county results for every year instead. It reads the research project’s county panels, which the project’s script 05g_csu.R built from all 20 yearly median-background files with the same method as the student section (block-group population weights, nearest cell center, 25 km cut). It keeps the population-weighted, median-background values, renames the columns, rounds them, and writes one CSV file per year plus one monthly file.
Students do not run this script: it needs the research project folder, which is not in the bundle. They use its output files, which are documented in the CSU section and below. The script is R/m07x_instructor_export.R. Run it from the bundle root with the project folder next to the bundle (../wildfire-claude), or set the environment variable WILDFIRE_PROJECT_DIR to another copy. Its setup chunk loads tidyverse and arrow; the chunks below need both. The validation script R/check_m07.R compares the export with the project files. The export’s settings (background, weighting, years) are in the chunk m07x-paths, so a mean-background version needs one changed line. The output file names do not record the background, so such a run would overwrite the median files; write it to another folder.
| Provider | Research project build (scripts/R/05_exposure_panel/05g_csu.R) from the CSU Dryad files (version 8) by Pierce, Ford, O’Dell, McGinnis, and Fischer (Colorado State University, its CIRA institute, and the U.S. Department of Energy; see the CSU section) |
| Coverage | 2006–2025, daily and monthly · the 3,108 contiguous-U.S. counties (DC included) |
| Raw unit | one Parquet row = a weighting × county × day (daily panel, one file per background × year) or a weighting × background × county × month (monthly panel) |
| Clean unit | one row = a county × day (one file per year) or a county × month |
| Licence and citation | CC0 1.0; cite the dataset and O’Dell et al. (2019) as in the CSU section |
| Documentation | CSU section of this guide; Dryad dataset page and README; project codebook data/cleaned/codebook.csv |
Raw files
These are the inputs, in the research project folder (default ../wildfire-claude, next to the bundle). They are not in the bundle.
File (in the project’s data/cleaned/panel/) |
Size | Format | One row is |
|---|---|---|---|
csu_county_day/background=median/year=<year>/part-0.parquet, 20 files for 2006–2025 |
23.8–32.1 MB each; 528.6 MB in all | Apache Parquet (a compressed column-based table format) in a “hive” folder layout: the folder names hold the partition keys background and year |
a weighting × county × day: 2 × 1,134,420 rows per year (2 × 1,137,528 in leap years) |
csu_county_month.parquet |
96.6 MB | Apache Parquet | a weighting × background × county × month: 2 × 2 × 3,108 × 240 = 2,983,680 rows |
Raw variables
The daily Parquet files (csu_county_day/background=median/year=<year>/part-0.parquet) have these columns; background and year are folder names, not columns.
| Variable | Type | Description |
|---|---|---|
weight_basis |
string | population: weights from 2020 block-group centers of population, as in the student section (exported). area_land: equal weight on every cell whose center lies in the county’s land area; a county with no cell center inside gets the cell nearest its internal point (not exported) |
GEOID |
string | 5-character county FIPS code, 2020 boundaries |
date |
date32 | Day of the 24-hour average |
smoke |
double | Smoke PM2.5, µg/m³: weighted county mean of HMS_Smoke × (PM25 − Background_PM25); negatives kept |
total |
double | Total kriged PM2.5, µg/m³ (weighted mean of PM25) |
background_pm25 |
double | Seasonal non-smoke background, µg/m³ (weighted mean of Background_PM25) |
hms_share |
double | Weighted share of the county under an HMS smoke plume, 0 to 1 |
csu_background |
string | Background definition of the source file: median in these folders |
csu_release |
string | Date Dryad published the source NetCDF: 2025-03-21 (2006–2023), 2026-02-25 (2024), 2026-08-06 (2025). The NetCDF records an earlier creation date in its description attribute: 2025-01-13, 2025-10-22, and 2026-07-30 |
source_file |
string | Name of the source NetCDF, e.g., krigedPM25_2023_v2_statfix_medianbk.nc (the project codebook mislabels it “Raw FIRMS file”) |
The monthly Parquet file csu_county_month.parquet has these columns. The project computed every monthly value from unrounded daily values.
| Variable | Type | Description |
|---|---|---|
weight_basis |
string | population (exported) or area_land, as above |
csu_background |
string | median (exported) or mean |
csu_release |
string | Date Dryad published the source NetCDF, as above |
GEOID |
string | 5-character county FIPS code |
month_start |
date32 | First day of the month |
S |
double | Monthly mean of daily smoke PM2.5 over observed days, µg/m³ (the project’s monthly smoke measure) |
total_mean |
double | Monthly mean of daily total PM2.5, µg/m³ |
n_days_obs |
int32 | Days with a value in the month |
smoke_max |
double | Highest daily smoke PM2.5 in the month, µg/m³ |
hms_days |
int32 | Days with hms_share > 0.5 |
days_gt5 |
int32 | Days with smoke PM2.5 > 5 µg/m³ |
days_gt10 |
int32 | Days with smoke PM2.5 > 10 µg/m³ |
days_gt15 |
int32 | Days with smoke PM2.5 > 15 µg/m³ |
days_gt35 |
int32 | Days with smoke PM2.5 > 35 µg/m³ |
share_days_obs |
double | n_days_obs / days in the month (1 in every row; not exported) |
complete_month |
bool | TRUE if every day of the month has a value |
Cleaning steps
The script R/m07x_instructor_export.R makes these decisions:
- Median background, population weights. It keeps the population-weighted, median-background values, the same choices as the student sample.
- Student column names.
smokebecomessmoke_pm25,totalbecomestotal_pm25,Sbecomessmoke_pm25, andtotal_meanbecomestotal_pm25_mean. In the monthly file, the project’smonth_startdate becomes two integer columns,yearandmonth, the keys of the other monthly files in the bundle. - Rounding. PM2.5 values are rounded to 2 decimals and
hms_shareto 3, as in the student sample. - Releases. Daily files list the release by year in the table below; the monthly file keeps a
csu_releasecolumn. - Checks before writing. Every file must be complete (all 3,108 counties on every day or month), have unique keys and no missing values, and come from a single source file per year.
The first chunk sets the paths and the export settings.
# INSTRUCTOR ONLY. The 2006-2025 CSU files are 20 GB, so students get the
# research project's county panels instead of the raw grids. The project
# folder sits next to the bundle; set WILDFIRE_PROJECT_DIR to use another copy.
project_dir <- Sys.getenv("WILDFIRE_PROJECT_DIR", unset = "../wildfire-claude")
panel_dir <- file.path(project_dir, "data", "cleaned", "panel")
stopifnot(dir.exists(file.path(panel_dir, "csu_county_day")))
# Read Parquet single-threaded, as the project build does: its build log
# recorded garbled rows from a threaded arrow read (arrow 24.0.0, 2026-09-29).
options(arrow.use_threads = FALSE)
# Export settings: the background definition (the project built "median" and
# "mean") and the spatial weighting (the project built "population" and
# "area_land").
csu_background <- "median"
csu_weight_basis <- "population"
csu_years <- 2006:2025
day_dir <- file.path(clean_dir, "csu_smoke_county_day")
dir.create(day_dir, recursive = TRUE, showWarnings = FALSE)The county frame from module m00 defines the complete set of counties.
# The county frame written by module m00: 3,108 contiguous counties plus DC.
counties <- read_csv(file.path(clean_dir, "counties_2020.csv"),
col_types = cols_only(GEOID = col_character()))
stopifnot(nrow(counties) == 3108, !anyDuplicated(counties$GEOID))This function exports one year: it reads the year’s Parquet file, keeps one weighting, renames and rounds, checks the grain, and writes the CSV.
# Export one year of county-day values. The project stores one Parquet file
# per background x year (a "hive" folder layout: background=median/year=2023/),
# holding both weightings; we keep one weighting and rename the columns to
# the student names.
export_csu_year <- function(year) {
part_file <- file.path(panel_dir, "csu_county_day",
str_c("background=", csu_background), str_c("year=", year),
"part-0.parquet")
# Select and filter inside arrow, so only the rows we keep become an R table.
part <- read_parquet(part_file, as_data_frame = FALSE) |>
filter(weight_basis == csu_weight_basis) |>
select(GEOID, date, smoke, total, background_pm25, hms_share, csu_release, source_file) |>
collect()
out <- part |>
transmute(
GEOID,
date = as.Date(date),
smoke_pm25 = round(smoke, 2),
total_pm25 = round(total, 2),
background_pm25 = round(background_pm25, 2),
hms_share = round(hms_share, 3)
) |>
arrange(GEOID, date)
# Grain: one row per county x day of the year, complete, no missing values.
year_days <- seq(as.Date(str_c(year, "-01-01")), as.Date(str_c(year, "-12-31")), by = "day")
stopifnot(
nrow(out) == nrow(counties) * length(year_days),
!anyDuplicated(select(out, GEOID, date)),
setequal(out$GEOID, counties$GEOID),
setequal(out$date, year_days),
!anyNA(out),
all(between(out$hms_share, 0, 1)),
n_distinct(part$csu_release) == 1, n_distinct(part$source_file) == 1
)
out_file <- file.path(day_dir, str_c("csu_smoke_county_day_", year, ".csv.gz"))
write_csv(out, out_file, na = "")
# One summary row per year, for the guide's table of releases.
year_summary <- tibble(
year = year, rows = nrow(out), csu_release = part$csu_release[1],
source_file = part$source_file[1], mean_smoke_pm25 = round(mean(out$smoke_pm25), 3),
size_mb = round(file.size(out_file) / 1e6, 1)
)
# Free this year's tables before the next year: without gc(), R holds on to
# them and peak memory climbs from about 1 GB to over 2 GB across 20 years.
rm(part, out)
invisible(gc())
year_summary
}Run it for all 20 years and print a summary row per year.
# One year at a time: about 1.1 million county-days reach R per year.
day_summary <- map(csu_years, export_csu_year) |> list_rbind()
stopifnot(nrow(day_summary) == length(csu_years))
print(day_summary, n = Inf)The monthly file comes from the project’s monthly panel.
# County-month panel: the project's monthly means of daily smoke for the same
# background and weighting. open_dataset() scans the file in pieces, and the
# filter runs inside arrow, so only the 745,920 rows we need (of 2,983,680)
# reach R.
month_raw <- open_dataset(file.path(panel_dir, "csu_county_month.parquet")) |>
filter(weight_basis == csu_weight_basis, csu_background == .env$csu_background) |>
select(GEOID, month_start, S, total_mean, smoke_max, hms_days, starts_with("days_gt"),
n_days_obs, complete_month, csu_release) |>
collect()
# Integer year and month columns, the keys of the other monthly files in the
# bundle, replace the project's month_start date.
csu_month <- month_raw |>
transmute(
GEOID,
year = as.integer(year(month_start)),
month = as.integer(month(month_start)),
smoke_pm25 = round(S, 2),
total_pm25_mean = round(total_mean, 2),
smoke_max = round(smoke_max, 2),
hms_days, days_gt5, days_gt10, days_gt15, days_gt35,
n_days_obs, complete_month, csu_release
) |>
arrange(GEOID, year, month)Check the monthly grain and the logic of the counts, then write the file and count rows by release.
# Grain: one row per county x month, 3,108 x 240, complete, no missing values;
# every month has all its days observed.
months <- seq(as.Date("2006-01-01"), as.Date("2025-12-01"), by = "month")
month_dates <- make_date(csu_month$year, csu_month$month)
stopifnot(
nrow(csu_month) == nrow(counties) * length(months),
!anyDuplicated(select(csu_month, GEOID, year, month)),
setequal(csu_month$GEOID, counties$GEOID),
setequal(month_dates, months),
!anyNA(csu_month),
all(csu_month$n_days_obs == days_in_month(month_dates)),
all(csu_month$complete_month),
all(csu_month$hms_days <= csu_month$n_days_obs),
all(csu_month$days_gt35 <= csu_month$days_gt15),
all(csu_month$days_gt15 <= csu_month$days_gt10),
all(csu_month$days_gt10 <= csu_month$days_gt5)
)
write_csv(csu_month, file.path(clean_dir, "csu_smoke_county_month_2006_2025.csv"), na = "")
count(csu_month, csu_release)Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
csu_smoke_county_day/csu_smoke_county_day_<year>.csv.gz, 20 files for 2006–2025 |
1,134,420 per year (1,137,528 in 2008, 2012, 2016, 2020, 2024); 22,703,940 in all | a county × day | 5.9–7.1 MB each (about 37 MB uncompressed); 124.8 MB in all |
csu_smoke_county_month_2006_2025.csv |
745,920 | a county × month (3,108 × 240) | 41.07 MB |
CSU release, source file, and mean county-day smoke PM2.5 by year (population-weighted, median background):
| Year | Rows | CSU release | Source NetCDF | Mean smoke_pm25 (µg/m³) |
Size |
|---|---|---|---|---|---|
| 2006 | 1,134,420 | 2025-03-21 | krigedPM25_2006_v2_statfix_medianbk.nc |
0.160 | 5.93 MB |
| 2007 | 1,134,420 | 2025-03-21 | krigedPM25_2007_v2_statfix_medianbk.nc |
0.479 | 6.13 MB |
| 2008 | 1,137,528 | 2025-03-21 | krigedPM25_2008_v2_statfix_medianbk.nc |
0.215 | 6.00 MB |
| 2009 | 1,134,420 | 2025-03-21 | krigedPM25_2009_v2_statfix_medianbk.nc |
0.114 | 5.86 MB |
| 2010 | 1,134,420 | 2025-03-21 | krigedPM25_2010_v2_statfix_medianbk.nc |
0.181 | 5.98 MB |
| 2011 | 1,134,420 | 2025-03-21 | krigedPM25_2011_v2_statfix_medianbk.nc |
0.565 | 6.24 MB |
| 2012 | 1,137,528 | 2025-03-21 | krigedPM25_2012_v2_statfix_medianbk.nc |
0.472 | 6.32 MB |
| 2013 | 1,134,420 | 2025-03-21 | krigedPM25_2013_v2_statfix_medianbk.nc |
0.364 | 6.06 MB |
| 2014 | 1,134,420 | 2025-03-21 | krigedPM25_2014_v2_statfix_medianbk.nc |
0.185 | 5.91 MB |
| 2015 | 1,134,420 | 2025-03-21 | krigedPM25_2015_v2_statfix_medianbk.nc |
0.347 | 6.00 MB |
| 2016 | 1,137,528 | 2025-03-21 | krigedPM25_2016_v2_statfix_medianbk.nc |
0.171 | 5.91 MB |
| 2017 | 1,134,420 | 2025-03-21 | krigedPM25_2017_v2_statfix_medianbk.nc |
0.327 | 6.05 MB |
| 2018 | 1,134,420 | 2025-03-21 | krigedPM25_2018_v2_statfix_medianbk.nc |
0.498 | 6.11 MB |
| 2019 | 1,134,420 | 2025-03-21 | krigedPM25_2019_v2_statfix_medianbk.nc |
0.233 | 6.08 MB |
| 2020 | 1,137,528 | 2025-03-21 | krigedPM25_2020_v2_statfix_medianbk.nc |
0.544 | 6.13 MB |
| 2021 | 1,134,420 | 2025-03-21 | krigedPM25_2021_v2_statfix_medianbk.nc |
1.354 | 6.62 MB |
| 2022 | 1,134,420 | 2025-03-21 | krigedPM25_2022_v2_statfix_medianbk.nc |
1.045 | 6.83 MB |
| 2023 | 1,134,420 | 2025-03-21 | krigedPM25_2023_v2_statfix_medianbk.nc |
2.390 | 7.08 MB |
| 2024 | 1,137,528 | 2026-02-25 | krigedPM25_2024_v2_statfix_medianbk.nc |
1.272 | 6.90 MB |
| 2025 | 1,134,420 | 2026-08-06 | krigedPM25_2025_v2_statfix_medianbk.nc |
1.072 | 6.70 MB |
Clean variables
The yearly files csu_smoke_county_day_<year>.csv.gz share these columns with the student sample csu_sample_county_day_2023.csv.gz. Rows are sorted by GEOID and date, and no cell is missing.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code (state + county), 2020 boundaries; joins to counties_2020.csv |
date |
date | — | Day of the 24-hour average (from the raw doy) |
smoke_pm25 |
dbl | µg/m³ | Smoke PM2.5: population-weighted mean over the county’s cells of HMS_Smoke × (PM25 − Background_PM25). Exactly 0 when no resident lives under a plume (hms_share = 0): a true zero, not a missing value. The reverse does not hold: it can also be 0.00 on plume days (1,103 county-days in 2023). Because hms_share is rounded to 3 decimals, 7 county-days in 2006–2025 show hms_share = 0 with smoke_pm25 = 0.01. Can be negative; keep those values. 2 decimals |
total_pm25 |
dbl | µg/m³ | Total PM2.5: population-weighted mean of the kriged PM25, smoke and non-smoke together. Rarely negative, where kriging undershoots (516 county-days in 2023; 217 to 2,478 per year). 2 decimals |
background_pm25 |
dbl | µg/m³ | Non-smoke background: population-weighted mean of the seasonal median Background_PM25. Constant within each quarter, so it changes only on January 1, April 1, July 1, and October 1. Positive except in July–September 2016 in 11 counties of North Dakota and Minnesota (minimum −0.69) and July–September 2022 in Douglas County, Wisconsin (−0.35): kriged PM2.5 can be negative, and so can its median. 2 decimals |
hms_share |
dbl | share, 0 to 1 | Share of the county’s 2020 population living in grid cells under an HMS smoke plume that day (population-weighted mean of HMS_Smoke). 3 decimals, so a share below 0.0005 shows as 0 (31 to 70 county-days per year). The project calls a county-day a smoke day when hms_share > 0.5 |
csu_smoke_county_month_2006_2025.csv: one row per county × month, sorted by GEOID, year, and month, no missing cells. read_csv() reads year, month, and the counts as dbl and csu_release as a date.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code, 2020 boundaries |
year |
dbl | — | Calendar year, 2006 to 2025 (from the project’s month_start) |
month |
dbl | — | Calendar month, 1 (January) to 12 (December) (from month_start) |
smoke_pm25 |
dbl | µg/m³ | Monthly mean of daily smoke PM2.5 (the project’s monthly smoke measure, S). 2 decimals |
total_pm25_mean |
dbl | µg/m³ | Monthly mean of daily total PM2.5. 2 decimals |
smoke_max |
dbl | µg/m³ | Highest daily smoke PM2.5 in the month. 2 decimals |
hms_days |
dbl | days | Days with hms_share > 0.5 (most residents under a plume) |
days_gt5 |
dbl | days | Days with daily smoke PM2.5 > 5 µg/m³ |
days_gt10 |
dbl | days | Days with daily smoke PM2.5 > 10 µg/m³ |
days_gt15 |
dbl | days | Days with daily smoke PM2.5 > 15 µg/m³ |
days_gt35 |
dbl | days | Days with daily smoke PM2.5 > 35 µg/m³ (35 µg/m³ is the EPA 24-hour standard for total PM2.5; here only the smoke part is counted) |
n_days_obs |
dbl | days | Days in the month with a value; equals the number of days in the month in every row |
complete_month |
lgl | — | TRUE when every day of the month has a value; TRUE in every row |
csu_release |
date | — | Release: the date Dryad published the file behind the month: 2025-03-21 for 2006–2023, 2026-02-25 for 2024, 2026-08-06 for 2025. This is not the date CSU made the file: the NetCDF records its creation date in its description attribute (2025-01-13 for 2006–2023, 2025-10-22 for 2024, 2026-07-30 for 2025) |
Checks and cautions
- Exact copy of the project panels, up to rounding.
R/check_m07.Rcompares every exported value with the project’s Parquet files. All 20 daily files have the expected rows (3,108 counties × every day of the year), no duplicatedGEOID–datepairs, and no missing values. The largest difference from the project is 0.005 µg/m³ for the PM2.5 columns and 0.0005 forhms_share, which is rounding. In the monthly file, the PM2.5 columns also differ by at most 0.005, and every count column andcsu_releaseis identical. - Same numbers as the student code. The exported 2023 file is identical, cell by cell, to
csu_sample_county_day_2023.csv.gz, which the student code builds from the raw 2023 NetCDF. - Zeros. In the project’s unrounded panel,
smokeis exactly 0 on every county-day withhms_share= 0, in all 20 years. After rounding,smoke_pm25= 0.00 also occurs on 354 to 1,219 plume county-days per year, and 7 county-days in 2006–2025 showhms_share= 0.000 withsmoke_pm25= 0.01 (06073 on 2007-11-01, 53033 on 2012-09-21, 06085 on 2013-07-30, 12055 on 2015-03-20, 37173 on 2019-05-29, 32003 on 2020-09-26, 37173 on 2021-04-06). A check of the zero rule on the rounded files would fail in 2007, 2012, 2013, 2015, 2019, 2020, and 2021; the student script runs it on unrounded values. - Background can be slightly negative. In July–September 2016, 11 counties in North Dakota and Minnesota (1,012 county-days, minimum −0.69 µg/m³), and in July–September 2022, Douglas County, Wisconsin (92 county-days, −0.35), have a negative background. Total PM2.5 is negative on 217 to 2,478 county-days per year.
- Monthly panel.
n_days_obsequals the days in the month in every row andcomplete_monthis alwaysTRUE: the project’s daily panel has no gaps. Monthly smoke averages 0.597 µg/m³ (minimum −1.94, maximum 161.77), and 7.2% of county-months are negative. - Releases. 2006–2023 come from one CSU release (2025-03-21), and 2024 and 2025 from two later ones (2026-02-25 and 2026-08-06).
csu_releaseis the Dryad publication date; the NetCDF creation dates are 2025-01-13, 2025-10-22, and 2026-07-30. The “Downloaded” dates in the project’sREADME_sources.mdare file timestamps kept from the provider’s archive (they match the NetCDF creation dates to within a day), not download dates; all files were downloaded from Dryad version 8. If CSU revises a year, re-run the project build and then this export. - Arrow threads. The script reads Parquet with
options(arrow.use_threads = FALSE), as the project build does: the project’s build log recorded garbled rows from a multi-threaded read with arrow 24.0.0. - Memory and time. The export runs in about 3 to 6 minutes, depending on how busy the machine is. Its peak memory was 1.6 to 2.0 GB in our runs; in an earlier version without the
gc()call inexport_csu_year(), R held on to each year’s tables and the peak passed 2.2 GB.
ERA5 winds and surface weather: instructor export
The raw ERA5 files take 17.1 GB and can only be downloaded with a Copernicus account, so the bundle ships none of them. This export gives students the county results for every year instead. It reads the research project’s county-day tables, which the project’s script 02_winds.R built from the raw bundles B2 (500 hPa geopotential) and B3 (surface fields) as population-weighted county means (see the ERA5 section). It keeps ten weather columns, rounds them, joins the surface fields to the 500 hPa height, and writes one CSV file per year.
Students do not run this script: it needs the research project folder, which is not in the bundle. They use its output files, which are documented in the ERA5 section and below. The script is R/m09x_instructor_export.R. Run it from the bundle root with the project folder next to the bundle (../wildfire-claude), or set the environment variable WILDFIRE_PROJECT_DIR to another copy. Its setup chunk loads tidyverse and arrow. The validation script R/check_m09.R compares the export with the project files. The project has no county table of the 850/700 hPa winds (bundle B1); it keeps them only as gridded NetCDF files on the local solar day (data/cleaned/winds/winds_layer/winds_layer_<year>.nc), so the export has no winds above the surface.
| Provider | Research project build (scripts/R/02_winds.R) from ERA5 daily means (ECMWF for the Copernicus Climate Change Service), bundles B2 and B3 |
| Coverage | Project tables: 2003-01-01 to 2026-03-31 (surface) and 1991-01-01 to 2026-03-31 (500 hPa); export: 2006–2025, daily · the 3,108 contiguous-U.S. counties (DC included) |
| Raw unit | one Parquet row = a county × UTC day |
| Clean unit | one row = a county × UTC day (one file per year) |
| Licence and citation | CC BY 4.0; cite Hersbach et al. (2020) and the ERA5 datasets as in the ERA5 section |
| Documentation | ERA5 section of this guide; project codebook data/cleaned/winds/codebook_winds.csv; project script scripts/R/02_winds.R (header and sections 2–3) |
Raw files
These are the inputs, in the research project folder (default ../wildfire-claude, next to the bundle). They are not in the bundle.
File (in the project’s data/cleaned/winds/) |
Size | Format | One row is |
|---|---|---|---|
sl_county_day/era5_sl_county_day_<year>.parquet, 24 files for 2003–2026 (the export reads 2006–2025) |
86.2–86.5 MB each (2026, January–March only: 22.7 MB); 1,726.2 MB for 2006–2025 | Apache Parquet (a compressed, column-based table format), 2 row groups per file | a county × UTC day: 1,134,420 rows per year (1,137,528 in leap years); 26,390,028 rows for 2003-01-01 to 2026-03-31 |
z500_county_day.parquet |
749.9 MB | Apache Parquet, 39 row groups (blocks of about one million rows), sorted by county and then date | a county × UTC day, 1991-01-01 to 2026-03-31: 40,012,392 rows (3,108 counties × 12,874 days) |
codebook_winds.csv |
0.003 MB | CSV | one variable of the two tables: label, units, source, construction, and summary statistics |
Raw variables
The yearly files sl_county_day/era5_sl_county_day_<year>.parquet have these columns. Every weather value is a population-weighted county mean of daily (UTC-day) means of hourly ERA5 values; the weights come from 2020 block-group centers of population.
| Variable | Type | Description |
|---|---|---|
GEOID |
string | 5-character county FIPS code, 2020 boundaries (exported) |
date |
date32 | UTC day (exported) |
ssrd_wm2 |
double | Surface solar radiation downwards, W/m²: the daily mean of hourly accumulations (J/m²) divided by 3,600 (exported) |
blh_m |
double | Boundary-layer height, m (exported) |
t2m_c |
double | 2 m temperature, °C (t2m − 273.15) (exported) |
d2m_c |
double | 2 m dewpoint temperature, °C (d2m − 273.15) (exported) |
vp_hpa |
double | Vapor pressure, hPa: the Magnus saturation vapor pressure at the dewpoint, computed per grid cell, then averaged (not exported: it carries the same information as d2m_c) |
rh_pct |
double | Relative humidity, %: 100 × e(d2m) / e(t2m) per grid cell from the daily-mean temperature and dewpoint, capped at 100, then averaged (exported) |
u10_ms |
double | 10 m eastward wind, m/s (exported) |
v10_ms |
double | 10 m northward wind, m/s (exported) |
ws10_ms |
double | Speed of the county-mean daily wind, √(u10_ms² + v10_ms²), m/s (exported) |
source_product |
string | “ERA5 post-processed daily statistics (Copernicus CDS), daily_mean, UTC day” in every row, including the months the project computed from hourly data with the same rule (not exported) |
download_date |
string | Latest download date among the year’s raw files, e.g., 2026-10-01 for 2006 (not exported) |
weight_basis |
string | “population (Census 2020 block-group centres of population)” in every row (not exported) |
z500_county_day.parquet has these columns.
| Variable | Type | Description |
|---|---|---|
GEOID |
string | 5-character county FIPS code (exported) |
date |
date32 | UTC day (exported) |
z500_gpm |
double | 500 hPa geopotential height, gpm: geopotential / 9.80665 (exported) |
z500_anom_gpm |
double | z500_gpm minus the county’s 1991–2005 day-of-year climatology (a constant plus sine and cosine waves with periods of 1 year, 6 months, and 4 months, fitted county by county; no trend removed) (exported) |
z500_bin |
int32 | Anomaly quintile bin, 1 (lowest) to 5, from county-specific cut points of 1991–2005 May–September days, applied to every day (not exported: it belongs to the project’s design) |
source_product |
large_string | As in the surface files (not exported) |
download_date |
string | Latest download date among all B2 raw files: 2026-10-04 in every row (not exported) |
weight_basis |
large_string | As in the surface files (not exported) |
Cleaning steps
The script R/m09x_instructor_export.R makes these decisions:
- Check coverage first. Both tables must cover every day of 2006–2025. They do: the surface table runs from 2003-01-01 to 2026-03-31 (26,390,028 rows) and the 500 hPa table from 1991-01-01 to 2026-03-31 (40,012,392 rows), both for all 3,108 counties.
- Ten weather columns. It keeps temperature, dewpoint, humidity, the 10 m wind (u, v, and speed), boundary-layer height, sunlight, and the 500 hPa height and anomaly. It drops
vp_hpa(same information as the dewpoint),z500_bin(a project design choice), and the three provenance columns, which hold the same text in every row of a file. - Read the 500 hPa file in blocks. The file is sorted by county and then date, so every row group holds days from every year. The script reads one row group at a time and keeps only the days of the year it is exporting, which keeps memory low.
- Rounding. Temperatures and winds to 2 decimals; humidity, sunlight, and the 500 hPa height and anomaly to 1; boundary-layer height to whole meters.
- Checks before writing. Each year must have every county on every day, unique keys, no missing values, and values in physical ranges (temperature between −50 and 50 °C, dewpoint not above temperature, humidity above 0 and at most 100%, non-negative speed and sunlight, positive boundary-layer height, and 500 hPa height between 4,500 and 6,500 gpm).
The first chunk sets the paths and the years to export.
# INSTRUCTOR ONLY. The raw ERA5 files are 17 GB and need a Copernicus account,
# so students get the research project's county-day weather tables instead.
# The project folder sits next to the bundle; set WILDFIRE_PROJECT_DIR to use
# another copy.
project_dir <- Sys.getenv("WILDFIRE_PROJECT_DIR", unset = "../wildfire-claude")
winds_dir <- file.path(project_dir, "data", "cleaned", "winds")
sl_dir <- file.path(winds_dir, "sl_county_day")
z500_file <- file.path(winds_dir, "z500_county_day.parquet")
stopifnot(dir.exists(sl_dir), file.exists(z500_file))
# Read Parquet single-threaded, as the project build does: its build log
# recorded garbled rows from a threaded arrow read (arrow 24.0.0, 2026-09-29).
options(arrow.use_threads = FALSE)
era5_years <- 2006:2025
day_dir <- file.path(clean_dir, "era5_county_day")
dir.create(day_dir, recursive = TRUE, showWarnings = FALSE)Check the date range and county count of both tables before exporting.
# Check year coverage before exporting. The project keeps surface weather in
# one Parquet file per year (2003 to March 2026) and the 500 hPa height in one
# file (1991 to March 2026). The summaries run inside arrow, so no table of
# county-days reaches R.
sl_files <- file.path(sl_dir, str_c("era5_sl_county_day_", 2003:2026, ".parquet"))
stopifnot(all(file.exists(sl_files)))
sl_coverage <- open_dataset(sl_files) |>
summarize(first_day = min(date), last_day = max(date), rows = n(),
counties = n_distinct(GEOID)) |>
collect()
z500_coverage <- open_dataset(z500_file) |>
summarize(first_day = min(date), last_day = max(date), rows = n(),
counties = n_distinct(GEOID)) |>
collect()
bind_rows(surface = sl_coverage, z500 = z500_coverage, .id = "table")
# Both tables must cover every day of the export years.
stopifnot(
sl_coverage$first_day <= as.Date(str_c(min(era5_years), "-01-01")),
sl_coverage$last_day >= as.Date(str_c(max(era5_years), "-12-31")),
z500_coverage$first_day <= as.Date(str_c(min(era5_years), "-01-01")),
z500_coverage$last_day >= as.Date(str_c(max(era5_years), "-12-31"))
)The county frame from module m00 defines the complete set of counties.
# The county frame written by module m00: 3,108 contiguous counties plus DC.
counties <- read_csv(file.path(clean_dir, "counties_2020.csv"),
col_types = cols_only(GEOID = col_character()))
stopifnot(nrow(counties) == 3108, !anyDuplicated(counties$GEOID))A reader for the large 500 hPa file that returns the rows of a date range.
# The z500 file holds 40 million county-days, sorted by county and then date,
# so every part of the file holds every year. A filtered arrow scan of it
# held about 1 GB at once. Reading it one row group (a block of about one
# million rows) at a time, and keeping only the days we need, stays near
# 0.6 GB.
z500_reader <- ParquetFileReader$create(z500_file)
z500_cols <- match(c("GEOID", "date", "z500_gpm", "z500_anom_gpm"),
names(z500_reader$GetSchema())) - 1L # arrow counts from 0
stopifnot(!anyNA(z500_cols))
read_z500_days <- function(first_day, last_day) {
map(seq_len(z500_reader$num_row_groups) - 1L, \(i) {
as_tibble(z500_reader$ReadRowGroup(i, z500_cols)) |>
filter(date >= first_day, date <= last_day)
}) |>
list_rbind()
}This function exports one year: it reads the year’s surface file and the year’s 500 hPa rows, joins and rounds them, checks the grain, and writes the CSV.
# Export one year of county-day weather: the surface fields from that year's
# Parquet file, plus the 500 hPa height for the same days.
export_era5_year <- function(year) {
first_day <- as.Date(str_c(year, "-01-01"))
last_day <- as.Date(str_c(year, "-12-31"))
year_days <- seq(first_day, last_day, by = "day")
surface <- read_parquet(
file.path(sl_dir, str_c("era5_sl_county_day_", year, ".parquet")),
col_select = c(GEOID, date, t2m_c, d2m_c, rh_pct, u10_ms, v10_ms, ws10_ms,
blh_m, ssrd_wm2)
)
z500 <- read_z500_days(first_day, last_day)
# Round to the precision the data can support; the CSV stays small.
out <- surface |>
inner_join(z500, by = c("GEOID", "date")) |>
transmute(
GEOID,
date = as.Date(date),
t2m_c = round(t2m_c, 2),
d2m_c = round(d2m_c, 2),
rh_pct = round(rh_pct, 1),
u10_ms = round(u10_ms, 2),
v10_ms = round(v10_ms, 2),
ws10_ms = round(ws10_ms, 2),
blh_m = round(blh_m),
ssrd_wm2 = round(ssrd_wm2, 1),
z500_gpm = round(z500_gpm, 1),
z500_anom_gpm = round(z500_anom_gpm, 1)
) |>
arrange(GEOID, date)
# Grain: one row per county x day of the year, complete, no missing values,
# and every value in a physically possible range.
stopifnot(
nrow(surface) == nrow(counties) * length(year_days),
nrow(z500) == nrow(counties) * length(year_days),
nrow(out) == nrow(counties) * length(year_days),
nrow(distinct(out, GEOID, date)) == nrow(out),
setequal(out$GEOID, counties$GEOID),
setequal(out$date, year_days),
!anyNA(out),
all(between(out$t2m_c, -50, 50)),
all(out$d2m_c <= out$t2m_c + 0.01),
all(out$rh_pct > 0 & out$rh_pct <= 100),
all(out$ws10_ms >= 0),
all(out$blh_m > 0),
all(out$ssrd_wm2 >= 0),
all(between(out$z500_gpm, 4500, 6500))
)
out_file <- file.path(day_dir, str_c("era5_county_day_", year, ".csv.gz"))
write_csv(out, out_file, na = "")
# One summary row per year for the log.
year_summary <- tibble(
year = year, rows = nrow(out), mean_t2m_c = round(mean(out$t2m_c), 2),
mean_ws10_ms = round(mean(out$ws10_ms), 2),
mean_z500_anom_gpm = round(mean(out$z500_anom_gpm), 1),
size_mb = round(file.size(out_file) / 1e6, 1)
)
# Free this year's tables before the next one, so peak memory stays near
# one year's worth.
rm(surface, z500, out)
invisible(gc())
year_summary
}Run it for all 20 years and print a summary row per year.
# One year at a time. The years can also be run in pieces, e.g.
# map(2006:2010, export_era5_year), if a session is short on time.
day_summary <- map(era5_years, export_era5_year) |> list_rbind()
stopifnot(nrow(day_summary) == length(era5_years))
print(day_summary, n = Inf)Clean files
File (in wildfire_data/clean/) |
Rows | One row is | Size |
|---|---|---|---|
era5_county_day/era5_county_day_<year>.csv.gz, 20 files for 2006–2025 |
1,134,420 per year (1,137,528 in 2008, 2012, 2016, 2020, 2024); 22,703,940 in all | a county × UTC day | 28.5–28.7 MB each (about 80 MB uncompressed); 571.5 MB in all |
The summary the script prints, one row per year: rows, means over all county-days, and file size.
| Year | Rows | Mean t2m_c (°C) |
Mean ws10_ms (m/s) |
Mean z500_anom_gpm (gpm) |
Size |
|---|---|---|---|---|---|
| 2006 | 1,134,420 | 13.86 | 2.77 | 4.1 | 28.6 MB |
| 2007 | 1,134,420 | 13.60 | 2.72 | 11.9 | 28.6 MB |
| 2008 | 1,137,528 | 12.74 | 2.82 | −9.8 | 28.7 MB |
| 2009 | 1,134,420 | 12.69 | 2.77 | −5.6 | 28.6 MB |
| 2010 | 1,134,420 | 13.21 | 2.78 | −7.6 | 28.6 MB |
| 2011 | 1,134,420 | 13.51 | 2.81 | −0.3 | 28.6 MB |
| 2012 | 1,137,528 | 14.56 | 2.71 | 10.8 | 28.6 MB |
| 2013 | 1,134,420 | 12.67 | 2.76 | 1.0 | 28.6 MB |
| 2014 | 1,134,420 | 12.38 | 2.80 | −6.9 | 28.6 MB |
| 2015 | 1,134,420 | 13.79 | 2.68 | 22.0 | 28.5 MB |
| 2016 | 1,137,528 | 14.26 | 2.73 | 18.4 | 28.6 MB |
| 2017 | 1,134,420 | 14.08 | 2.78 | 15.0 | 28.6 MB |
| 2018 | 1,134,420 | 13.43 | 2.70 | 18.6 | 28.5 MB |
| 2019 | 1,134,420 | 13.35 | 2.69 | 13.2 | 28.5 MB |
| 2020 | 1,137,528 | 13.95 | 2.74 | 19.1 | 28.6 MB |
| 2021 | 1,134,420 | 14.03 | 2.70 | 17.7 | 28.5 MB |
| 2022 | 1,134,420 | 13.48 | 2.83 | 11.6 | 28.6 MB |
| 2023 | 1,134,420 | 14.30 | 2.67 | 11.4 | 28.5 MB |
| 2024 | 1,137,528 | 14.39 | 2.73 | 22.2 | 28.6 MB |
| 2025 | 1,134,420 | 13.83 | 2.70 | 17.8 | 28.6 MB |
Clean variables
The 20 yearly files share these columns (the same as in the ERA5 section). Rows are sorted by GEOID and date, and no cell is missing.
| Variable | Type | Units | Description |
|---|---|---|---|
GEOID |
chr | — | 5-character county FIPS code, 2020 boundaries; joins to counties_2020.csv |
date |
date | — | UTC day |
t2m_c |
dbl | °C | 2 m air temperature (project t2m_c). 2 decimals |
d2m_c |
dbl | °C | 2 m dewpoint temperature (project d2m_c). 2 decimals |
rh_pct |
dbl | % | Relative humidity (project rh_pct). 1 decimal |
u10_ms |
dbl | m/s | 10 m eastward wind (project u10_ms). 2 decimals |
v10_ms |
dbl | m/s | 10 m northward wind (project v10_ms). 2 decimals |
ws10_ms |
dbl | m/s | Speed of the daily-mean 10 m wind (project ws10_ms, computed before rounding). 2 decimals |
blh_m |
dbl | m | Boundary-layer height (project blh_m). Whole meters |
ssrd_wm2 |
dbl | W/m² | Surface solar radiation, 24-hour mean (project ssrd_wm2). 1 decimal |
z500_gpm |
dbl | gpm | 500 hPa geopotential height (project z500_gpm). 1 decimal |
z500_anom_gpm |
dbl | gpm | 500 hPa height anomaly against the 1991–2005 seasonal normal (project z500_anom_gpm). 1 decimal |
Checks and cautions
- Exact copy of the project tables, up to rounding.
R/check_m09.Rre-reads every exported file and joins it to the project’s Parquet tables. All 20 files have the expected rows (3,108 counties × every day of the year), every row matches a project row, noGEOID–datepair is duplicated, and no value is missing. Every exported value equals the project’s value rounded to the stated decimals. - Coverage beyond 2006–2025. The project tables also hold 2003–2005 (surface) and 1991–2005 (500 hPa), and both run to March 31, 2026. To export other years, change
era5_yearsin the chunkm09x-paths; a 2026 file would be partial, and the grain check would stop it as written. - No winds above the surface. The 850 and 700 hPa winds exist only as gridded fields, averaged over the two levels and shifted to each grid cell’s local solar day, with February 29 folded into February 28. Making county tables of them would need a new aggregation step; this export does not do it.
- The anomaly baseline is 1991–2005. No trend is removed, so later years run high: the yearly mean anomaly ranges from −9.8 gpm (2008) to +22.2 gpm (2024), and every year since 2015 is above +11 gpm.
- Arrow threads. The script reads Parquet with
options(arrow.use_threads = FALSE), as the project build does: the project’s build log recorded garbled rows from a multi-threaded read with arrow 24.0.0. - Time and memory. The export took 3.6 minutes in our final run (5.5 and 12.8 minutes in earlier runs on a busier machine). Its peak memory footprint was 1.8 GB.
R/m09_era5.Rpeaked at 1.7 GB andR/check_m09.Rat 2.1 GB. - Plots in batch runs.
R/m09_era5.Rdraws two plots. In RStudio or Quarto they appear on screen, but a plainRscript R/m09_era5.Rwrites them toRplots.pdfin the project root. For batch validation runs, useRscript -e 'pdf(NULL); source("R/m09_era5.R", print.eval = TRUE)', and before uploading the bundle delete anyRplots.pdffrom its root, together with the._*files. - Rebuilds. If the project rebuilds its wind tables (for example, after new ERA5 downloads), re-run this export and then
R/m09_era5.R.