Prof. Marc Los Huertos • Pomona College
Summary. This document walks through a thirty-year analysis of daily fine particulate matter (PM2.5) concentrations for a single US county, using publicly available EPA monitoring data. You will download the data, clean it, visualize long-term trends, and compute monthly summary statistics with 95% confidence intervals. By the end, you will be able to say whether air quality in your county has improved over the past three decades, which seasons and stations are worst, and whether concentrations have regularly exceeded federal health standards.
Updated: 2026-04-09 00:51
This page is a reference and guide, not a template.
Read through it carefully before writing a single line of code. Your job
is to understand what each section does, then build your own
.Rmd file from scratch that reproduces the same analyses
for your chosen county.
Code scaffolds appear throughout. Where you see a
chunk with ___ blanks, those are arguments you must supply
yourself. The output you are aiming for appears just above the scaffold
in each section. Use ?function_name in the R console to
look up what arguments are available.
📋 Scaffold quick-reference
| Symbol | Meaning |
|---|---|
___ |
Fill in a value yourself |
"___" |
Fill in a quoted string |
c("___", "___") |
Fill in one or more quoted strings |
data = ___ |
Replace ___ with your dataframe abbreviation
(e.g. SB) |
When stuck, look at the example output just above each scaffold chunk — that shows exactly what the completed code should produce.
🖥️ Where do I type? The Console vs. the Terminal RStudio has two places where you can type commands, and mixing them up is the most common first-week mistake.
CONSOLE
runs R code. It lives in the lower-left panel. When it
is ready for input you see a blue > prompt. This is
where you run install.packages(), test code interactively,
and look up ?help pages. For this assignment, the
Console is the only tab you need.
TERMINAL runs bash (Linux shell) commands and is used for things like creating folders from the command line. For this assignment you will not need the Terminal — the Files pane handles folder creation with a button instead (see the next section).
Fine particulate matter, or PM2.5, refers to airborne particles smaller than 2.5 micrometers in diameter — roughly 1/30th the width of a human hair. The “2.5” is the upper size limit in micrometers (µm); one micrometer is one millionth of a meter. Because these particles are so small, they stay suspended in the air for days, can travel hundreds of kilometers on the wind, and — most importantly — bypass the body’s normal defenses. When inhaled, they penetrate deep into the lungs and can cross into the bloodstream.
Long-term exposure is linked to respiratory disease, cardiovascular disease, and premature death. Short-term spikes — a smoke-filled day from a nearby wildfire, for example — can trigger asthma attacks and hospital admissions within hours. PM2.5 is not uniformly distributed: concentrations are highest near major roads, industrial facilities, and agricultural burning, which means that communities living in those places bear a disproportionate health burden.
Sources fall into two broad categories. Primary PM2.5 is emitted directly as particles: diesel exhaust, wood smoke, dust from unpaved roads, and agricultural operations. Secondary PM2.5 forms in the atmosphere when gases — sulfur dioxide from power plants, nitrogen oxides from vehicles — react chemically and condense into particles.
Concentrations are also shaped by meteorology. Temperature inversions trap pollution near the surface on still winter mornings; onshore sea breezes and afternoon convection can flush coastal basins; precipitation scavenges particles from the air. These processes produce strong seasonal cycles that vary by region.
Because PM2.5 patterns depend so heavily on local geography and emission sources, each student in this class analyzes a different county so we can compare results across regions. Using thirty years of EPA monitoring data, you will:
The EPA sets two National Ambient Air Quality Standards (NAAQS) for PM2.5. Understanding both is essential for interpreting your results.
The 24-hour standard is 35 ug/m3. No more than one exceedance per year is permitted on a three-year rolling average. It protects against acute exposure — a single smoky day from a nearby wildfire.
The annual standard is 12 ug/m3. The three-year mean of annual averages must stay below this value. It protects against chronic exposure: living near a freeway for years.
All time-series plots in this document carry a reference line at 35 ug/m3 because our data are daily values.
🔑 Key takeaways — standards to keep in mind
For your own document, include:
You are working on a shared RStudio Server. Each
student has their own home directory (/home/yourusername/).
Keep your work there — do not write files to shared system
directories.
Create your project folders using the Files pane (lower-right panel in RStudio):
EJnPi, and
click OK.EJnPi to open it.data, and
click OK.✅ What you should see You should now be inside
~/EJnPi/ and see an empty data/ folder listed.
If you do not see the Files pane at all, go to View → Panes →
Show All Panes.
Then:
.Rmd file: File → New File → R
Markdown, give it any title, and click OK.EJnPi folder, and name it
Analyzing_EPA_PM25_YourLastName.Rmd..Rmd, add this line so knitr always
runs in the right place:knitr::opts_knit$set(root.dir = "~/EJnPi")
Your folder structure before the first knit:
~/EJnPi/
Analyzing_EPA_PM25_YourLastName.Rmd
data/ <- empty; the download loop will populate it
This assignment uses R functions written for this course that handle downloading and cleaning the EPA data, so you can focus on the analysis. They are maintained in the course GitHub repository.
Browse the code: https://github.com/marclos/EJnPi/tree/master/docs
You do not need to download or copy anything. Your Rmd loads them automatically with this line (already in the setup section below):
source("https://raw.githubusercontent.com/marclos/EJnPi/master/docs/EJnPi_functions.R")
If you are curious how the functions work, click the link above, open
EJnPi_functions.R, and read the comments. You are not
expected to modify them.
On the server, packages install to your personal library
(~/R/). Run this once in the RStudio
Console — not inside your .Rmd — before knitting for the
first time:
CONSOLE Paste into the Console (lower-left panel) and press Enter:
install.packages(c("dplyr", "lubridate", "xtable"))
This may take 1–3 minutes. You will see a lot of text scroll by — that is normal.
✅ What you should see When each package finishes
you will see a line like * DONE (dplyr). The blue
> prompt returns when all four are installed. If asked
“Would you like to use a personal library?” — answer
Yes.
⏱️ Time check — setup should take about 10–15 minutes total If you have been working on creating the folder, the Rmd file, and installing packages for more than 15 minutes and something is not working, stop and ask for help. Contact Marc or an EA30 mentor before getting too frustrated — setup problems are almost always quick to fix with a second pair of eyes.
EPA data files are indexed by state and county FIPS codes —
standardized numeric identifiers used across all federal datasets. You
need a 2-digit state code and a 3-digit county
code, both zero-padded (California = "06", not
"6").
COUNTYNAME; note the value in
COUNTYFP.STATEFP) is in the first column.| State | FIPS | County | FIPS |
|---|---|---|---|
| California | 06 | San Bernardino | 071 |
| California | 06 | Los Angeles | 037 |
| Texas | 48 | Harris | 201 |
| New York | 36 | Kings (Brooklyn) | 047 |
| Washington | 53 | King | 033 |
Before any analysis, you need to understand what the EPA data contain and how the download pipeline works. The next two sections handle the technical setup; the analysis begins in Section 7.
Always load packages at the very top of your script so dependencies are explicit and any missing-package errors appear before your analysis starts. Also set the working directory in your setup chunk so knitr resolves file paths correctly on the server:
🔑 Functions used in this document — quick reference
| Function | Package | What it does |
|---|---|---|
library() |
base | Load an installed package |
source() |
base | Run an external R script |
str() / head() |
base | Inspect a dataframe’s structure / first rows |
format() / as.integer() |
base | Convert dates to strings / to integers |
factor() |
base | Create an ordered categorical variable |
floor() / ceiling() |
base | Round down / up to the nearest integer |
subset() |
base | Keep rows matching a condition |
aggregate() |
base | Apply a function to grouped data |
unique() |
base | Return one row per distinct combination |
table() |
base | Count occurrences of each value |
mutate() |
dplyr | Add or transform columns |
group_by() + summarise() |
dplyr | Split → apply → combine |
filter() |
dplyr | Keep rows matching a condition (same as subset()) |
month() |
lubridate | Extract the month number from a date |
print(xtable(...)) |
xtable | Render a dataframe as a formatted HTML table |
Use ?function_name in the Console for full documentation
on any function.
knitr::opts_knit$set(root.dir = "~/EJnPi") # adjust to your actual folder name
library(dplyr) # group_by(), summarise(), mutate(), filter()
library(lubridate) # ymd(), month() -- date parsing and extraction
✅ What you should see No red error text. Some grey
“Attaching package” or “Conflicts” messages are normal and can be
ignored. If you see there is no package called ‘dplyr’, go
back to the Install Required Packages section and run the install
command in the Console.
These three lines are the only ones you must change to adapt the analysis to your county. Everything downstream reads from these variables, so editing them here updates plot titles, table captions, and file paths throughout.
⚠️ You must change these values — do not use San
Bernardino The values below are filled in with San Bernardino
as a working example. You must replace them with your own
assigned county before running anything. Using the example
county will download San Bernardino data, not yours, and your analysis
will be wrong. Change all three lines: county,
state_fips, and county_fips. See Section 3.4
for how to look up your FIPS codes.
county <- "San Bernardino" # appears in plot titles
state_fips <- "06" # 2-digit string; quotes preserve the leading zero
county_fips <- "071" # 3-digit string
⚠️ Do not rename these three variables The course
helper functions and later code chunks refer to county,
state_fips, and county_fips by those exact
names. If you rename them (e.g. to my_county), the
functions will not find them and your download will fail with a cryptic
error. Change the values inside the quotes — not the variable
names themselves.
You also need to choose a short abbreviation for
your county — 2 or 3 letters. You will use this as the name of your
dataframe throughout your document. This document uses SB
for San Bernardino. Some examples:
| County | Abbreviation |
|---|---|
| San Bernardino | SB |
| Los Angeles | LA |
| Harris (Houston) | HH |
| King (Seattle) | KG |
| Kings (Brooklyn) | BK |
Pick yours now and use it consistently. Every time you see
SB in this document, substitute your own abbreviation in
your .Rmd.
⚠️ Keep the quotes around FIPS codes R stores
numbers and text differently. “06” is stored as the
two-character string “06” — the leading zero is preserved. Without
quotes, R reads 06 as the number 6, which will not match
the EPA file names and your download will fail silently or return no
data.
The EPA Air Quality System (AQS) is the federal database of air pollution measurements from thousands of monitoring stations across the United States. Stations report daily average concentrations; the data are freely available as annual CSV files at a stable public URL — no account or API key required.
The variable we analyze is PM2.5 FRM/FEM mass (EPA pollutant code 88101), measured in micrograms per cubic meter (µg/m³). Each annual file covers the entire country; the helper function filters it to your county using the FIPS codes you configured above and caches the result locally so you only download once.
A helper function handles the download and caching. It is defined below in a hidden chunk — you do not need to read or modify it. On the first knit it downloads thirty years of data (allow 20–30 minutes on first knit). On every subsequent knit it finds the cached file and loads it in seconds.
The helper functions live in a shared file on GitHub. The chunk below sources them directly — no download or installation needed. If the server has no outbound internet access, ask your instructor for the local path.
source("https://raw.githubusercontent.com/marclos/EJnPi/master/docs/EJnPi_functions.R")
get_epa_pm25() handles downloading and caching;
internally it calls prepare_epa_pm25() to select and rename
columns. Call just one line:
end_year <- as.integer(format(Sys.Date(), "%Y")) - 1
start_year <- end_year - 29 # 30 years
# I name my dataframe SB for San Bernardino.
# In your document, replace SB with your chosen abbreviation everywhere.
SB <- get_epa_pm25(
state_fips = state_fips,
county_fips = county_fips,
start_year = start_year,
end_year = end_year
)
## Loading cached data: data/06071_PM25_daily.rds
✅ What you should see On subsequent knits (cached):
Loading cached data: data/06071_PM25_daily.rds — done in a
few seconds.
On the first knit: one line per year as each annual file downloads and is filtered. This takes 20–30 minutes — start it and step away. Do not close the browser.
🔴 Common errors and fixes
object 'get_epa_pm25' not found — the
source() chunk above did not run. Check your internet
connection, then run the source chunk manually in the Console.cannot open URL — the server cannot
reach GitHub. Ask your instructor for a local path to
EJnPi_functions.R and replace the URL in the source()
call.cannot create directory 'data' — your
data/ subfolder does not exist. Go to the Files pane,
navigate into EJnPi, click New Folder, and
create data.⏱️ Time check — the first download takes 20–30 minutes This is normal and expected. Start the download, then use the time to read ahead through the rest of this document so you know what is coming. If the download is still running after 45 minutes, or if you see repeated error messages, contact Marc or an EA30 mentor.
This section covers data cleaning — the decisions made before any analysis begins. Good cleaning is not glamorous, but it is where most errors are introduced or prevented. Document every decision you make here in your own write-up: which stations you removed, why, and what effect that had on sample size.
Always look at a new dataframe before doing anything with
it. The function returns five columns. Confirm that
Date is class Date (not character) and that
PM2.5.AQI.Value is numeric. If either looks wrong, plots
and statistics will silently produce incorrect results.
str(SB)
## 'data.frame': 21998 obs. of 5 variables:
## $ Site.ID : chr "06-071-0014" "06-071-0014" "06-071-0014" "06-071-0014" ...
## $ Site.Name : chr "Victorville-Amargosa Road" "Victorville-Amargosa Road" "Victorville-Amargosa Road" "Victorville-Amargosa Road" ...
## $ Date : Date, format: "1999-01-03" "1999-01-09" ...
## $ PM2.5.AQI.Value: int 62 60 58 57 52 65 56 55 66 34 ...
## $ PM2.5.Conc : num 15.2 13.9 12.7 12.1 9.9 16.7 11.7 11.1 17.4 6.2 ...
✅ What to verify
Date must show class Date
(e.g. Date, format: "1999-01-03"). If it shows
chr, dates will not plot on a time axis correctly.PM2.5.AQI.Value and
PM2.5.Conc must show int or
num. If they show chr, all numeric summaries
will fail.If either type looks wrong, stop and ask your instructor before going further — a type problem here will cause silent errors downstream.
A quick look at the first few rows confirms the column names and values are sensible:
head(SB)
## Site.ID Site.Name Date PM2.5.AQI.Value PM2.5.Conc
## 1 06-071-0014 Victorville-Amargosa Road 1999-01-03 62 15.2
## 2 06-071-0014 Victorville-Amargosa Road 1999-01-09 60 13.9
## 3 06-071-0014 Victorville-Amargosa Road 1999-01-15 58 12.7
## 4 06-071-0014 Victorville-Amargosa Road 1999-01-21 57 12.1
## 5 06-071-0014 Victorville-Amargosa Road 1999-01-27 52 9.9
## 6 06-071-0014 Victorville-Amargosa Road 1999-01-30 65 16.7
With 30 years of data we can ask whether air quality has changed over time. We split the record into three 10-year blocks and store the label as a factor so plots order the decades correctly.
# Extract the 4-digit year, round down to the decade start, build a label
SB$Year <- as.integer(format(SB$Date, "%Y"))
SB$Decade <- factor(
paste0(floor(SB$Year / 10) * 10, "s"), # e.g. 2010 -> "2010s"
levels = sort(unique(paste0(floor(SB$Year / 10) * 10, "s")))
)
table(SB$Decade) # confirm three roughly equal groups
##
## 1990s 2000s 2010s 2020s NAs
## 532 1496 9345 3371 7254
ℹ️ How the decade math works
floor(2013 / 10) * 10 → floor(201.3) * 10 →
201 × 10 → 2010.
paste0(2010, "s") → "2010s". Every year from
2010–2019 gets the label "2010s".
Why factor() matters: Without it, R
treats the decade labels as plain text and may sort them alphabetically
or in first-seen order in plots (e.g. “2020s” before “2000s”). Wrapping
the labels in factor() with an explicit
levels = argument tells R the correct chronological order
so plot legends and axis ticks always run oldest → newest.
Some monitoring sites are quality-assurance collocations — duplicate
instruments installed next to a primary monitor to audit its
performance. Including them double-counts measurements from those
locations. They are identified by site numbers in the 9000-range (fourth
segment starting with 9).
SB <- subset(SB, !(Site.ID %in% c("06-071-9010", "06-071-9002")))
ℹ️ subset() vs
dplyr::filter() Both do the same thing: keep rows
where a condition is TRUE. subset(SB, condition) is base R
— no packages needed. filter(SB, condition) is the dplyr
equivalent — identical result. You will see both in the wild; either
works here. This document uses subset() so it runs even if
dplyr is not loaded.
A station with very few observations over thirty years cannot support reliable monthly averages. We count observations per station, print the table so you can see the full picture, then filter.
# aggregate() applies a function (length = count) to one column grouped by another
SB.N <- aggregate(PM2.5.AQI.Value ~ Site.ID, data = SB, FUN = length)
names(SB.N)[2] <- "N"
print(SB.N)
## Site.ID N
## 1 06-071-0014 114
## 2 06-071-0025 914
## 3 06-071-0027 7003
## 4 06-071-0306 7410
## 5 06-071-2002 2170
## 6 06-071-8001 2286
## 7 06-071-9004 2101
# Keep only stations that contributed at least 100 daily observations
keep_ids <- SB.N$Site.ID[SB.N$N >= 100]
SB <- subset(SB, Site.ID %in% keep_ids)
✅ Checkpoint Run nrow(SB) to confirm
rows remain, and unique(SB$Site.ID) to see which stations
survived. In your Rmd, write one sentence explaining how many stations
you retained and why any were removed.
⏱️ Time check — you should reach this point within about 45 minutes of starting (Not counting the 20–30 minute download wait.) If it has taken longer and you feel stuck, reach out to Marc or an EA30 mentor. It is much faster to get help early than to spin on a problem for an hour.
🔑 Key takeaways — data cleaning
str() before doing
anything else.9 in the fourth
segment) must be removed to avoid double-counting.Now that the data are clean, we move to exploration. The goal of the next section is not yet to draw conclusions — it is to understand the shape of the data before computing any statistics.
Never skip exploratory visualization. Plotting the raw data before computing statistics helps you spot outliers, record gaps, and unusual station behavior that summary numbers alone would hide.
Before plotting we create a small table mapping each
Site.ID to its human-readable Site.Name. A
station ID alone is not informative in a figure legend.
# unique() on two columns returns one row per distinct combination
SB.unique <- unique(SB[c("Site.ID", "Site.Name")])
SB.unique
## Site.ID Site.Name
## 1 06-071-0014 Victorville-Amargosa Road
## 115 06-071-0025 Ontario Fire Station
## 211 06-071-2002 Fontana
## 332 06-071-8001 Big Bear
## 429 06-071-9004 San Bernardino
## 635 06-071-0306 Victorville-Park Avenue
## 4022 06-071-0027 Ontario-Route 60 Near Road
A box plot shows the full distribution — median, interquartile range, and outliers — for every station simultaneously. The horizontal reference line at 35 ug/m3 makes it easy to see which stations routinely approach or exceed the 24-hour standard.
Here is the output your code should produce:
Distribution of daily PM2.5 AQI by monitoring station. Box = IQR; line = median; points = outliers. Red dashed line = 24-hr NAAQS (35 ug/m3).
What this chart shows. Each box represents one monitoring station’s full thirty-year record of daily PM2.5 values. The thick horizontal line inside each box is the median — half of all daily readings fell below this value, half above. The box itself spans the middle 50% of readings (the interquartile range, or IQR). Points above the whiskers are outliers: unusually high days, often driven by wildfires or pollution events.
What to look for in your data. Are some stations consistently higher than others? Do any stations have their median near or above the red dashed line? Wide boxes indicate high day-to-day variability; tall outlier stacks suggest occasional severe episodes. These differences between stations often reflect proximity to roads, industrial zones, or the geography of valley air basins.
Scaffold to adapt for your own document.
ℹ️ A few things worth knowing before filling in the
blanks Text arguments like titles and axis labels need quotes:
main = “My Title”. Numeric arguments like line type and
width do not: lty = 2. Legal values for lty: 1
= solid, 2 = dashed, 3 = dotted. For color names, run
colors() in the Console to see the full list.
# par() sets margins and label orientation before the plot is drawn.
# Skipping it often causes station labels to be clipped — mentors see this a lot.
par(las = ___, mar = c(___, ___, ___, ___))
boxplot(PM2.5.AQI.Value ~ Site.ID, data = ___, # your dataframe abbreviation, e.g. SB
main = paste("PM2.5 by Station --", county),
ylab = ___,
xlab = ___,
ylim = c(0, ___),
col = ___)
abline(h = ___, col = ___, lty = ___, lwd = ___)
legend(___, legend = ___, col = ___, lty = ___, bty = "n")
Before choosing a focus station for the monthly analysis, rank all stations by their median PM2.5. This identifies both the cleanest and most-polluted monitoring locations in your county.
SB.median <- aggregate(PM2.5.AQI.Value ~ Site.ID, data = SB, FUN = median)
SB.median <- merge(SB.median, SB.unique, by = "Site.ID")
SB.median <- SB.median[order(SB.median$PM2.5.AQI.Value), ]
print(SB.median)
## Site.ID PM2.5.AQI.Value Site.Name
## 6 06-071-8001 31 Big Bear
## 4 06-071-0306 46 Victorville-Park Avenue
## 1 06-071-0014 55 Victorville-Amargosa Road
## 7 06-071-9004 55 San Bernardino
## 5 06-071-2002 56 Fontana
## 2 06-071-0025 58 Ontario Fire Station
## 3 06-071-0027 59 Ontario-Route 60 Near Road
Plotting every daily value over thirty years reveals long-term
trends, year-to-year variability, and episodic spikes (often wildfire
smoke). The fourth argument of rgb() sets transparency (0 =
invisible, 1 = solid); using 0.25 prevents overplotting when thousands
of points stack on top of each other.
Here is the output your code should produce:
Daily PM2.5 AQI for all retained stations over 30 years. Semi-transparent points reveal density. Red dashed line = 24-hr NAAQS (35 ug/m3).
What this chart shows. Every daily measurement from every retained station, plotted over thirty years. Semi-transparent points prevent the high-density periods from becoming a solid black blob — where points are darker, many readings stack on top of each other. The red dashed line marks the 24-hour NAAQS at 35 µg/m³.
What to look for. A downward drift in the cloud of points over time indicates improving air quality — a real trend seen in many US counties since the Clean Air Act was strengthened in the 1990s and 2000s. Vertical spikes above the reference line are exceedance events; if they cluster in late summer, wildfire smoke is a likely cause. Gaps in the record reveal when stations were offline.
ℹ️ The formula y ~ x R uses
~ (tilde) to mean “as a function of” — the same syntax as
boxplot() and later lm(). So
PM2.5.AQI.Value ~ Date puts Date on x and PM2.5 on y.
Scaffold:
par(las = 1)
plot(___ ~ ___, data = ___, # y ~ x; replace ___ with your dataframe abbreviation
xlab = ___,
ylab = ___,
main = paste("PM2.5 --", county, "(All Stations)"),
pch = ___,
cex = ___,
col = rgb(0, 0, 0, ___))
abline(h = ___, col = ___, lty = ___)
Separating stations into panels reveals whether all
sites share the same seasonal pattern or whether some behave differently
— a clue to localized sources or topographic effects.
par(mfrow = c(nrows, ncols)) arranges plots in a grid;
always reset it with par(mfrow = c(1, 1)) when done.
This section uses a for loop — one of the most powerful
ideas in programming. Instead of writing seven nearly-identical
plot() calls by hand, the loop runs the same block of code
once for each station, swapping in a different site each time. If you
had 50 stations, the code would be exactly the same length. That is the
point: loops let the computer do the repetitive work so you don’t have
to.
That said, I strongly recommend writing and running the code for a
single station first before wrapping it in a loop. Pick
one Site.ID, subset the data manually, and get a plot you
are happy with. Once that works, it is straightforward to drop the same
logic inside the loop. Debugging a broken loop is much harder than
debugging a single plot.
Here is the output your code should produce:
Daily PM2.5 per station. Each panel is one site. Compare the magnitude and timing of peaks across locations. I used par(mfrow=c(3,3)) for San Bernardino — adjust the grid to match your number of stations.
What this chart shows. The same time-series as above, but split into one panel per station. This makes it possible to see whether all stations rise and fall together (county-wide events like regional smoke) or whether some stations behave differently from the rest (local sources, topographic effects, or data gaps at specific monitors).
What to look for. Do peaks in one station align with peaks in others? Is one station consistently higher across all decades? Does any station have a long gap where no data were collected? These observations should inform which station you choose as your focus site in the next section.
🔑 Key takeaways — exploratory graphics
With the data explored, we now move to quantitative summaries. The goal shifts from seeing the shape of the data to measuring it precisely, with uncertainty estimates that let us compare months and decades rigorously.
Scaffold — note that a for loop iterates over a vector;
inside the loop, i takes each value in turn:
site_ids <- unique(___$Site.ID) # replace ___ with your dataframe abbreviation
n_sites <- length(site_ids)
ncols <- min(3, n_sites)
nrows <- ceiling(n_sites / ncols)
colors <- rainbow(n_sites)
par(las = 1, mfrow = c(nrows, ncols), mar = c(3, 4, 3, 1))
for (i in seq_along(site_ids)) {
sub <- subset(___, Site.ID == ___)
# ^ second ___: the loop counter i moves through site_ids one at a time.
# On the first iteration i=1, on the second i=2, etc.
# So to get the current station's ID, index site_ids with i: site_ids[i]
site_label <- ___.unique$Site.Name[___.unique$Site.ID == ___]
# ^ last ___: same answer — site_ids[i]. You are asking:
# "find the row in ___.unique where Site.ID matches this iteration's station,
# and return its Site.Name." The answer to both blanks in this loop is the same thing.
plot(___ ~ ___, data = sub,
main = ___,
xlab = "",
ylab = "PM2.5 AQI (ug/m3)",
pch = 20,
cex = 0.5,
ylim = c(0, ___),
col = colors[i])
abline(h = ___, col = ___, lty = ___)
}
par(mfrow = c(1, 1))
Aggregating by month across all years reveals the
seasonal cycle. dplyr’s group_by() +
summarise() pipeline computes grouped statistics in one
readable block. .groups = "drop" removes the grouping
structure after summarising so the result is a plain dataframe.
# I use SB here — replace with your abbreviation in your own document
Monthly <- SB %>%
mutate(Month = month(Date)) %>% # integer month 1-12
group_by(Decade, Month, Site.ID) %>% # grouped by decade so we can compare blocks
summarise(
median = median(PM2.5.AQI.Value, na.rm = TRUE),
mean = mean(PM2.5.AQI.Value, na.rm = TRUE),
sd = sd(PM2.5.AQI.Value, na.rm = TRUE),
N = n(),
.groups = "drop"
)
head(Monthly)
## # A tibble: 6 × 7
## Decade Month Site.ID median mean sd N
## <fct> <dbl> <chr> <dbl> <dbl> <dbl> <int>
## 1 1990s 1 06-071-0014 59 59 4.47 6
## 2 1990s 1 06-071-0025 102. 110. 33.7 10
## 3 1990s 1 06-071-2002 84 91.7 37.1 17
## 4 1990s 1 06-071-9004 67 69 28.7 5
## 5 1990s 2 06-071-0014 57 58.8 12.8 9
## 6 1990s 2 06-071-0025 81 87.3 26.3 9
ℹ️ Reading the pipe %>% x
%>% f(y) means “take x, then apply function f with argument
y.” It chains steps in top-to-bottom reading order instead of nesting
functions. Read each %>% out loud as “and then.” So the
chunk above reads: “start with SB, and then add a Month column,
and then group by Decade/Month/Site, and then
summarise each group.”
The multiplier 1.96 is the critical value of the standard normal distribution that cuts off 2.5% in each tail, leaving 95% in the middle. The plot below shows this geometrically: the shaded regions are the rejection regions for a two-tailed test at α = 0.05.
Standard normal density curve. Red shading = rejection regions at α = 0.05 (2.5% per tail). The vertical dashed lines fall at ±1.96, the critical values that define the boundary. In R: qnorm(0.975) = 1.96.
The sample mean \(\bar{x}\) estimates the true long-run monthly average, but carries uncertainty. The 95% confidence interval quantifies it:
\[CI_{95} = \bar{x} \pm 1.96 \times \frac{s}{\sqrt{N}}\]
If we repeated this sampling process many times, 95% of the resulting intervals would contain the true mean. Narrow CI = many observations have pinned the mean down precisely.
# qnorm(0.975) = 1.96 -- the z critical value shown in the plot above
Monthly$UCL95 <- Monthly$mean + qnorm(0.975) * Monthly$sd / sqrt(Monthly$N)
Monthly$LCL95 <- Monthly$mean - qnorm(0.975) * Monthly$sd / sqrt(Monthly$N)
For the table and CI plot we work with one station. The median-ranked station is a representative middle case — not the best or worst.
# ceiling() ensures we always land on a valid row index
focus_site <- SB.median$Site.ID[ceiling(nrow(SB.median) / 2)]
focus_site
## [1] "06-071-9004"
✅ Checkpoint The chunk should print a single
Site.ID string like “06-071-0306”. If it prints
character(0) or an NA, SB.median is empty —
check that the QA removal and low-N filter steps ran without error.
You may also choose a different station. Look at your ranked table
from Section 8.3 and pick whichever site is most relevant to the
community you are writing about. Just replace focus_site
with a quoted Site.ID string.
xtable converts a dataframe to formatted HTML or LaTeX.
The digits vector controls decimal places column by column:
position 1 is the (unused) row name; positions 2 onward correspond to
each data column left to right.
Here is the table your code should produce:
| Month | N | median | mean | sd | LCL95 | UCL95 |
|---|---|---|---|---|---|---|
| Jan | 5 | 67.0 | 69.0 | 28.7 | 43.8 | 94.2 |
| Feb | 8 | 84.5 | 85.0 | 38.7 | 58.2 | 111.8 |
| Mar | 9 | 70.0 | 76.8 | 17.2 | 65.5 | 88.0 |
| Apr | 6 | 67.5 | 78.5 | 26.3 | 57.5 | 99.5 |
| May | 8 | 78.5 | 82.0 | 13.6 | 72.6 | 91.4 |
| Jun | 9 | 91.0 | 94.6 | 27.6 | 76.5 | 112.6 |
| Jul | 9 | 71.0 | 75.0 | 17.2 | 63.7 | 86.3 |
| Aug | 11 | 70.0 | 72.5 | 10.0 | 66.7 | 78.4 |
| Sep | 10 | 97.0 | 105.1 | 40.7 | 79.9 | 130.3 |
| Oct | 9 | 69.0 | 69.4 | 21.0 | 55.8 | 83.1 |
| Nov | 10 | 85.0 | 106.3 | 50.6 | 74.9 | 137.7 |
| Dec | 10 | 48.0 | 50.8 | 26.2 | 34.5 | 67.1 |
| Jan | 27 | 44.0 | 45.7 | 22.8 | 37.1 | 54.3 |
| Feb | 25 | 47.0 | 48.8 | 26.7 | 38.3 | 59.3 |
| Mar | 22 | 62.0 | 59.5 | 20.9 | 50.8 | 68.3 |
| Apr | 30 | 57.5 | 60.2 | 15.7 | 54.5 | 65.8 |
| May | 27 | 62.0 | 63.9 | 18.7 | 56.9 | 71.0 |
| Jun | 26 | 62.5 | 61.7 | 11.3 | 57.3 | 66.0 |
| Jul | 24 | 61.0 | 64.2 | 15.6 | 57.9 | 70.4 |
| Aug | 25 | 60.0 | 61.2 | 6.2 | 58.8 | 63.6 |
| Sep | 28 | 58.0 | 59.4 | 11.1 | 55.2 | 63.5 |
| Oct | 29 | 56.0 | 57.7 | 32.7 | 45.8 | 69.6 |
| Nov | 28 | 59.5 | 69.4 | 41.3 | 54.1 | 84.7 |
| Dec | 29 | 46.0 | 49.8 | 22.9 | 41.4 | 58.1 |
| Jan | 99 | 46.0 | 45.0 | 21.5 | 40.8 | 49.3 |
| Feb | 82 | 36.0 | 44.3 | 25.1 | 38.8 | 49.7 |
| Mar | 82 | 46.5 | 47.7 | 21.1 | 43.1 | 52.2 |
| Apr | 87 | 49.0 | 47.6 | 15.9 | 44.2 | 50.9 |
| May | 86 | 53.0 | 49.0 | 13.4 | 46.2 | 51.8 |
| Jun | 85 | 57.0 | 56.9 | 8.1 | 55.2 | 58.7 |
| Jul | 90 | 56.0 | 59.7 | 17.1 | 56.1 | 63.2 |
| Aug | 88 | 56.5 | 57.6 | 6.1 | 56.3 | 58.9 |
| Sep | 80 | 54.0 | 51.6 | 9.0 | 49.6 | 53.5 |
| Oct | 81 | 52.0 | 52.4 | 24.3 | 47.1 | 57.7 |
| Nov | 81 | 49.0 | 47.8 | 20.2 | 43.4 | 52.2 |
| Dec | 90 | 49.5 | 48.1 | 20.4 | 43.9 | 52.3 |
| Jan | 22 | 55.0 | 58.5 | 33.7 | 44.5 | 72.6 |
| Feb | 18 | 37.5 | 40.1 | 16.8 | 32.3 | 47.8 |
| Mar | 19 | 43.0 | 42.5 | 15.1 | 35.7 | 49.3 |
| Apr | 20 | 44.5 | 45.9 | 13.8 | 39.8 | 52.0 |
| May | 21 | 57.0 | 53.3 | 9.9 | 49.0 | 57.5 |
| Jun | 20 | 58.0 | 58.0 | 5.7 | 55.5 | 60.4 |
| Jul | 6 | 57.5 | 69.5 | 31.7 | 44.2 | 94.8 |
| Aug | 10 | 57.5 | 57.4 | 3.7 | 55.1 | 59.7 |
| Sep | 10 | 61.0 | 74.3 | 32.6 | 54.1 | 94.5 |
| Oct | 8 | 56.0 | 54.4 | 14.5 | 44.3 | 64.4 |
| Nov | 10 | 52.5 | 53.3 | 15.1 | 43.9 | 62.7 |
| Dec | 10 | 68.0 | 77.0 | 25.7 | 61.1 | 92.9 |
| 472 | 54.0 | 51.6 | 17.5 | 50.0 | 53.2 |
ℹ️ The digits= argument
digits is a vector of integers, one per column plus one for
row names. Use 0 for counts and labels, 1 or
2 for concentrations. Mentors tell me this argument trips
people up more than anything else in xtable.
Scaffold:
library(xtable)
tbl <- Monthly[Monthly$Site.ID == focus_site, ]
tbl$Month <- month.abb[tbl$Month] # converts 1-12 to Jan-Dec
tbl <- tbl[, c("Month", "N", "median", "mean", "sd", "LCL95", "UCL95")]
print(
xtable(tbl,
caption = ___, # quoted caption text
digits = c(0, 0, ___, ___, ___, ___, ___)),# one value per column; 0 = no decimals
include.rownames = FALSE,
type = "html",
caption.placement = "top"
)
With 30 years of data we can ask: has PM2.5 changed over time? We plot three lines — one per decade — on the same axes. Lines that shift downward over time indicate improving air quality; lines that converge or diverge reveal whether seasonal patterns have changed. Error bars are 95% CIs on the monthly mean, so non-overlapping bars between decades indicate a statistically meaningful difference.
Here is the output your code should produce:
Monthly mean PM2.5 AQI +/- 95% CI for the focus station, split by decade. Each line is one 10-year block. Non-overlapping error bars between decades indicate a statistically meaningful shift in that month’s typical concentration. Red dashed line = 24-hr NAAQS (35 ug/m3).
What this chart shows. Each line represents one decade’s monthly average PM2.5 concentration at the focus station, with error bars showing the 95% confidence interval around each mean. Darker lines are older decades; lighter lines are more recent.
What to look for. Lines shifting downward from dark to light indicate improving air quality over time — a statistically meaningful improvement if the error bars do not overlap between decades. The shape of each line reveals the seasonal cycle: which months are typically worst and which are cleanest, and whether that pattern has changed across decades. A seasonal peak in late summer often signals wildfire smoke; a winter peak often reflects temperature inversions trapping vehicle emissions near the surface.
🔑 Key takeaways — monthly statistics
Scaffold — the key new ideas here are plot(NULL, ...) to
set up empty axes before drawing anything, and the offset jitter so
overlapping error bars stay readable.
ℹ️ Build this plot in three stages — run each one before adding the next
This is the most complex chunk in the document. Write and run it in three separate passes in your Console rather than filling in all the blanks at once.
Stage 1 — empty axes only. Fill in just the
par() and plot(NULL, ...) lines plus the
axis() and abline() lines. Run those lines by
highlighting them and pressing Ctrl+Enter (Windows/Linux) or
Cmd+Enter (Mac). You should see a blank plot with month
labels on the x-axis and the red dashed line at 35. If the axes look
wrong, fix them now — before any data is drawn.
Stage 2 — add the loop for one decade. Change
seq_along(decades) to just 1 so the loop runs
once: for (i in 1) { ... }. Run it. You should see one
colored line with error bars. Check that the points land on the right
months and the bars point the right direction.
Stage 3 — restore the full loop. Change
1 back to seq_along(decades) and run again.
All decades should now appear with the legend.
Why this works: if something is broken, you know exactly which stage introduced the problem. A single misplaced parenthesis in a 30-line chunk produces an error message that points to the wrong line — catching it in a 6-line Stage 1 is much faster.
focus <- Monthly[Monthly$Site.ID == focus_site, ]
decades <- levels(focus$Decade)
n_dec <- length(decades)
pal <- colorRampPalette(c(___, ___, ___))(n_dec)
ylim_top <- max(focus$UCL95, 35, na.rm = TRUE) * 1.15
par(las = ___, mar = c(___, ___, ___, ___))
plot(NULL,
xlim = c(0.5, 12.5), ylim = c(0, ylim_top),
xaxt = "n",
xlab = ___, ylab = ___, main = ___)
axis(1, at = 1:12, labels = month.abb)
abline(h = ___, col = ___, lty = ___, lwd = ___)
offsets <- seq(-0.15, 0.15, length.out = n_dec)
for (i in seq_along(decades)) {
d <- focus[focus$Decade == decades[i], ]
d <- d[order(d$Month), ]
mx <- d$Month + offsets[i]
lines(mx, d$___, col = pal[i], lwd = ___)
points(mx, d$___, col = pal[i], pch = ___)
arrows(mx, d$LCL95, mx, d$UCL95,
angle = 90, code = 3, length = ___, col = pal[i], lwd = ___)
}
legend(___, legend = c(decades, ___),
col = c(pal, ___), lty = ___, pch = ___, bty = "n")
⏱️ Time check — the analysis sections should take 1–2 hours after your data loads If you have been working through the graphics and statistics sections for more than 2 hours and are not yet producing plots, something has likely gone sideways earlier in the pipeline (often the data types from Section 7, or a wrong FIPS code). Contact Marc or an EA30 mentor — catching a root cause early saves a lot of time.
The quantitative results are only half the story. The discussion is where you connect the patterns you observed in your plots to the real-world processes that produced them. What sources explain the seasonal cycle? What policy or regulatory changes explain the long-term trend? Who lives near the most polluted stations, and what does that mean for health equity?
Write 2–4 paragraphs in your own words. Do not structure this as a list — write flowing prose that connects ideas across the themes below. You are synthesizing your quantitative results with what you know about the region, not summarizing what the figures look like.
Regional meteorology and source characterization. Describe the physical geography and emission sources shaping PM2.5 at your location. What industries, traffic corridors, or land uses are present? What meteorological patterns — inversions, sea breezes, katabatic flows — control how pollutants accumulate or disperse?
Seasonal patterns. What does your monthly CI plot show? Which months are highest and lowest, and why? If wildfire smoke affects your region, connect episodic spikes in the time-series to the broader trend of lengthening fire seasons.
Exceedance context. How often did concentrations exceed 35 ug/m3? Which stations or periods were worst? What does this imply for residents living near those stations?
Environmental justice. Who lives near the stations you analyzed? Are there disparities in proximity to pollution sources, access to green space, or health-care infrastructure that amplify exposure risk for certain communities?
Your conclusion should do three things. First, briefly restate what you found — not a list of every number, but the one or two most important patterns. Did PM2.5 decline significantly over thirty years? Was there a clear seasonal cycle? Did any stations consistently exceed the federal standard?
Second, say what those findings mean in practical terms. Improving concentrations are evidence that regulation works; persistent exceedances are evidence that more work remains. A seasonal spike in August in a western county connects your data to the broader story of worsening wildfires.
Third, acknowledge limitations. Your analysis rests on fixed monitoring stations, which may not capture exposure in areas without monitors. Daily averages smooth out hour-by-hour variation. The AQI index combines multiple pollutants in ways that can obscure the specific contribution of PM2.5.
A strong conclusion leaves the reader with a clear answer to the central question: Is the air in your county safe, getting safer, and getting safer for everyone?
sessionInfo() records the exact computational environment used to produce this document: the R version, operating system, and the version number of every package that was loaded. It appears at the bottom of every well-documented R analysis for one practical reason — reproducibility.
If your output looks different from a classmate’s, or your code throws an error that works fine on another machine, the first diagnostic question is always: are you running the same versions? This output answers that without anyone having to ask.
You do not need to interpret or discuss it. Just include it at the end of your document and let it run.
sessionInfo()
## R version 4.5.2 (2025-10-31)
## Platform: x86_64-pc-linux-gnu
## Running under: Rocky Linux 8.10 (Green Obsidian)
##
## Matrix products: default
## BLAS/LAPACK: /usr/lib64/libopenblas-r0.3.15.so; LAPACK version 3.9.0
##
## locale:
## [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
## [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
## [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
## [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
## [9] LC_ADDRESS=C LC_TELEPHONE=C
## [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
##
## time zone: America/Los_Angeles
## tzcode source: system (glibc)
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] xtable_1.8-4 lubridate_1.9.4 dplyr_1.1.4
##
## loaded via a namespace (and not attached):
## [1] vctrs_0.7.1 cli_3.6.5 knitr_1.51 rlang_1.1.7
## [5] xfun_0.56 otel_0.2.0 generics_0.1.4 jsonlite_2.0.0
## [9] glue_1.8.0 htmltools_0.5.9 sass_0.4.10 rmarkdown_2.30
## [13] evaluate_1.0.5 jquerylib_0.1.4 tibble_3.3.1 fastmap_1.2.0
## [17] yaml_2.3.12 lifecycle_1.0.5 compiler_4.5.2 timechange_0.3.0
## [21] pkgconfig_2.0.3 rstudioapi_0.18.0 digest_0.6.39 R6_2.6.1
## [25] utf8_1.2.6 tidyselect_1.2.1 pillar_1.11.1 magrittr_2.0.4
## [29] bslib_0.10.0 tools_4.5.2 cachem_1.1.0