Analyzing EPA PM2.5 Data

Marc Los Huertos

EA030 · v7 · Compiled 2026-04-09

How to Use Introduction Setup Data Overview Configure Analysis Findings Discussion Conclusion

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


Part I: Thirty Years of Regulatory Monitoring


How to Use This Document

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).


Introduction

What Is PM2.5 and Why Does It Matter?

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.

Where Does PM2.5 Come From?

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.

What This Analysis Does

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:

Regulatory Standards

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

  • 35 µg/m³ is the 24-hour limit. A single day above this value is a health event.
  • 12 µg/m³ is the annual limit. Chronic exposure above this level is a long-term risk.
  • All time-series plots in this document show the 24-hour line as a red dashed reference.
  • Your job is to describe how often your county exceeds these thresholds and whether the trend is improving.

What Your Submission Should Include

For your own document, include:


Before You Begin

Project Directory Setup

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):

  1. In the Files pane, make sure you are in your home directory — click the house icon (🏠) if you are not sure.
  2. Click New Folder, type EJnPi, and click OK.
  3. Double-click into EJnPi to open it.
  4. Click New Folder again, type 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:

  1. Create a new .Rmd file: File → New File → R Markdown, give it any title, and click OK.
  2. Immediately save it: File → Save As, navigate into the EJnPi folder, and name it Analyzing_EPA_PM25_YourLastName.Rmd.
  3. At the top of your .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
The Files pane New Folder button does the same thing as the bash command mkdir. No Terminal needed.

Course Functions

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.

Install Required Packages

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.

Never put install.packages() inside a chunk that will knit. It re-installs on every knit, is slow, and can cause errors if the package is already loaded. Install once in the Console, load with library() in your Rmd.

Find Your FIPS Codes

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").

  1. Go to: https://www.census.gov/library/reference/code-lists/ansi.html#cou
  2. Click your state to open a CSV lookup table.
  3. Find your county in COUNTYNAME; note the value in COUNTYFP.
  4. The state code (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

Data Overview

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.

Load Libraries

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.

root.dir tells knitr where to look for files when knitting. Without it, paths like ‘data/myfile.rds’ may not resolve on the server even if they work interactively in the Console.

Configure Your Location

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
<- is the assignment operator: it stores the value on the right under the name on the left. county <- ‘San Bernardino’ creates an R object called county. Anywhere R sees county in later code, it substitutes that text — so changing it here updates plot titles, file paths, and captions throughout the whole document automatically.

⚠️ 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.


Load EPA 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() runs an R script as if you had typed it into the console. Sourcing from a raw GitHub URL means you always get the current version without copying any code into your Rmd.
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
Breaking down end_year <- as.integer(format(Sys.Date(), ‘%Y’)) - 1: Sys.Date() returns today’s date. format(…, ‘%Y’) extracts the four-digit year as a character string (e.g. ‘2026’). as.integer() converts ‘2026’ to the number 2026 so arithmetic works. Subtracting 1 gives last year — because the current year’s EPA files are not yet available. This updates automatically each time you knit, so the 30-year window always ends at last year.

✅ 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.
  • Download stalls partway through — run the chunk again. The function skips years already saved and picks up where it left off.

⏱️ 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.


Methods

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.

Inspect the Data

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.

I always run str() and head() on any new dataset before touching it. It takes 10 seconds and has saved me hours of debugging. Skipping this step is one of the most common reasons students get stuck later on.
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

Assign Decade Blocks

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
The $ operator extracts a column from a dataframe: SB\(Year is the Year column of SB. SB\)Year <- … creates that column if it does not exist, or overwrites it if it does. Think of it as ‘SB’s Year column’.

ℹ️ 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.

Remove QA Collocated Monitors

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).

Run unique(SB$Site.ID) to see all site IDs in your county before deciding which to remove. Your QA site IDs will differ from San Bernardino’s. Mentors tell me this step catches a lot of students off guard — your county may have zero, one, or several QA sites, and you need to look before assuming.
SB <- subset(SB, !(Site.ID %in% c("06-071-9010", "06-071-9002")))
%in% checks membership: x %in% c(‘a’,‘b’) returns TRUE wherever x equals ‘a’ or ‘b’. The ! in front flips TRUE to FALSE and vice versa, so !(Site.ID %in% …) means ‘keep rows where Site.ID is NOT in this list’.

ℹ️ 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.

Remove Low-Sampling Stations

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
aggregate(response ~ group, data, FUN) applies FUN to response for each value of group. The ~ (tilde) means ‘grouped by’ — same syntax as boxplot() and lm(). FUN = length counts rows; FUN = mean would compute means instead. names(SB.N)[2] <- ‘N’ renames the second column from its default name to ‘N’ for clarity.
# 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

  • Always check data types with str() before doing anything else.
  • QA collocated monitors (site IDs with a 9 in the fourth segment) must be removed to avoid double-counting.
  • Stations with fewer than ~100 observations cannot support reliable monthly averages.
  • Every removal decision should be documented and justified in your write-up.

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.


Analysis

Exploratory Graphics

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.

I recommend making the exploratory plots before writing a single line of the discussion. You will almost always see something in the raw data — a spike, a gap, a station that behaves differently — that changes what you want to say.

Station Name Lookup Table

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
SB[c(‘Site.ID’, ‘Site.Name’)] selects just those two columns from SB. The square brackets [ ] subset a dataframe: df[rows, columns]. Leaving rows blank (before the comma) means keep all rows; c(‘col1’,‘col2’) selects specific columns by name. unique() then drops any duplicate rows, leaving one row per station.

Box Plot by Station

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).

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.

Key arguments: las= rotates axis labels (0=parallel, 1=horizontal, 2=perpendicular, 3=vertical). mar= sets margin widths in lines: c(bottom, left, top, right). See ?par.

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")

Rank Stations by Median PM2.5

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

Time-Series: All Stations

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).

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.

rgb(red, green, blue, alpha) creates a color with transparency. All values range 0–1. rgb(0,0,0,0.25) is 25% opaque black. See ?rgb.

ℹ️ 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 = ___)

Time-Series by Station

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.

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

  • Box plots reveal the distribution and spread at each station; use them to spot stations that are consistently high or have extreme outliers.
  • The all-stations time-series shows the big picture: is the county getting cleaner? Are there episodic spikes? When did the record begin and end?
  • The per-station panels reveal whether patterns are county-wide or localized — critical context for the environmental justice discussion.

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.

ceiling(n_sites / ncols) rounds up so you always have enough rows even when n_sites is not divisible by ncols. Try it: ceiling(5/3) = 2, giving a 2x3 grid with one empty panel. The par() call used here is: las = 1 (horizontal axis labels), mfrow = c(nrows, ncols) (grid of panels), mar = c(3, 4, 3, 1) (margins: bottom=3, left=4, top=3, right=1 lines). For San Bernardino I used par(mfrow=c(3,3)) — adjust the grid to match your number of stations.

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))
What I used for San Bernardino: subset(SB, Site.ID == site_ids[i]); SB.unique\(Site.Name[SB.unique\)Site.ID == site_ids[i]]; plot(PM2.5.AQI.Value ~ Date, …); main = site_label; ylim = c(0, 200); abline(h = 35, col = ‘red’, lty = 2).

Findings

Monthly Summary Statistics

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.

Mentors tell me this is often a stumbling point. The pipe chain looks intimidating at first, but I find it helps to run just the first line (through the first %>%) in the Console and look at the result, then add one line at a time. You can highlight any partial chunk and press Ctrl+Enter to run just that piece.
# 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.”

Where Does 1.96 Come From?

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.

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.

Try qnorm(0.995) in the console — that gives 2.576, the critical value for a 99% CI (alpha = 0.01). A tighter interval requires a larger multiplier and therefore wider error bars.

Confidence Interval Formula

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)

Choose a Focus Station

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.


Summary Table

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:

Monthly PM2.5 AQI summary – station 06-071-9004
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
month.abb is a built-in R character vector: c(‘Jan’,‘Feb’,…,‘Dec’). Indexing it with an integer month number returns the abbreviation: month.abb[3] = ‘Mar’.

ℹ️ 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"
)

Monthly Mean +/- 95% CI by Decade

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).

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

  • The CI plot is the quantitative heart of this analysis: it shows both the seasonal pattern and whether that pattern has changed over thirty years.
  • Non-overlapping error bars between decades = statistically meaningful difference.
  • Overlapping bars = the difference may be real but is not well-supported by the data.
  • A declining trend across decades reflects the effects of Clean Air Act regulations and fleet turnover — use this in your discussion.
colorRampPalette() creates a function that interpolates between two or more colors and returns a palette of any length. Here it grades from dark navy (oldest) to pale blue (newest), so time runs light-to-dark left-to-right in the legend.

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.


Discussion

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?


Conclusion

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