Prof. Marc Los Huertos • Pomona College • Spring 2026
Background Setup Clock Correction Data Loading Descriptive Stats Visualization Inference Advanced Conclusions
Particulate matter (PM) is a mixture of solid particles and liquid droplets suspended in air. Regulatory agencies classify particles by their aerodynamic diameter:
| Fraction | Diameter | Health relevance |
|---|---|---|
| PM1 | ≤ 1 µm | Penetrates deepest into lung tissue; linked to cardiovascular effects |
| PM2.5 | ≤ 2.5 µm | EPA primary NAAQS standard; major focus of air quality policy |
| PM10 | ≤ 10 µm | Larger particles; irritate upper airways |
The EPA Air Quality Index (AQI) categories for PM2.5 (24-h average) are:
| Category | PM2.5 (µg/m³) |
|---|---|
| Good | 0 – 12.0 |
| Moderate | 12.1 – 35.4 |
| Unhealthy for Sensitive Groups (USG) | 35.5 – 55.4 |
| Unhealthy | 55.5 – 150.4 |
Students deployed Raspberry Pi Zero units equipped with Enviro+ boards (PMS5003 optical particle sensor + BME280 meteorological sensor + MICS6814 gas sensor) at multiple campus locations. Each unit logs:
pm1_ug_m3, pm25_ug_m3,
pm10_ug_m3 — particulate concentrations (µg/m³)temperature_C, humidity_pct,
pressure_hPa — meteorological variableslight_lux — ambient lightoxidised_kO, reduced_kO,
nh3_kO — gas-phase proxies (oxidising gases, reducing
gases, ammonia)Primary questions:
H1 (Spatial): Sensors near higher-traffic areas or denser vegetation will show elevated PM2.5 relative to open-courtyard sensors, due to proximity to combustion sources or local resuspension.
H2 (Diurnal): PM2.5 will peak during morning and evening hours, consistent with traffic-related emissions patterns and reduced atmospheric mixing during stable boundary-layer conditions.
H3 (Meteorological): PM2.5 will be positively correlated with humidity (hygroscopic growth of particles) and negatively correlated with pressure (higher pressure → stronger inversions → accumulation) and temperature (higher temperature → stronger convective mixing → dilution).
Run the install block once in the RStudio Console — not inside the Rmd.
⚠️ Install once in the Console, never inside a knitting
chunk The block below only installs missing packages. Run it
interactively once; the eval=FALSE tag prevents it
re-running on every knit.
pkgs <- c("tidyverse", "lubridate", "patchwork", "scales",
"slider", "ggridges", "moments",
"broom", "broom.mixed", "knitr", "kableExtra",
"lme4", "lmerTest", "nlme")
to_install <- pkgs[!pkgs %in% rownames(installed.packages())]
if (length(to_install)) install.packages(to_install, quiet = TRUE)
library(tidyverse); library(lubridate); library(patchwork); library(scales)
library(slider); library(ggridges); library(moments)
library(broom); library(knitr); library(kableExtra)
theme_set(theme_minimal(base_size = 13))
# Color palette — scales automatically to however many sensors are present
sensor_pal <- c(
"#0072B2", "#D55E00", "#009E73", "#CC79A7",
"#E69F00", "#56B4E9", "#F0E442", "#999999",
"#882255", "#44AA99"
)
# pal is built after df exists; placeholder here, rebuilt in Section 6
pal <- sensor_pal # overwritten once df is loaded
Why does this matter? Raspberry Pi Zeros have no real-time clock (RTC) battery. Every time a unit loses power it resets its internal clock to an arbitrary default epoch. If we analyzed the raw timestamps, each sensor’s time series would be offset from real time by a different amount — making cross-sensor comparisons meaningless.
At deployment, each team member recorded:
The offset is simply:
\[\text{offset} = t_{\text{real}} - t_{\text{Pi}}\]
This constant is then added to every raw timestamp in that sensor’s CSV.
# sensor_calibration.R must live in the same directory as this Rmd.
# It creates the `calibration` tibble with one row per sensor (two for PiZ15).
source("sensor_calibration.R")
## Calibration table:
##
##
## sensor team_member location offset_label reboot notes
## ------- ------------ ---------------------------------------------------- ---------------- ------- --------------------------------------------------------
## PiZ1 NA NA NA 1 No calibration data recorded
## PiZ2 NA NA NA 1 No calibration data recorded
## PiZ3 NA NA NA 1 No calibration data recorded
## PiZ4 NA NA NA 1 No calibration data recorded
## PiZ5 NA NA NA 1 No calibration data recorded
## PiZ6 NA NA NA 1 No calibration data recorded
## PiZ7 Naomi Outside Blaisdell dorm, by the back stairs 10d 05h 33m 00s 1 NA
## PiZ8 Sorren Outside Wig Lounge patio, side facing Lyon 9d 22h 26m 00s 1 NA
## PiZ9 NA NA NA 1 No calibration data recorded
## PiZ10 NA NA NA 1 No calibration data recorded
## PiZ11 NA NA NA 1 No calibration data recorded
## PiZ12 Ashley Mudd-Blaisdell Courtyard 7d 09h 46m 02s 1 NA
## PiZ13 Mariana Outside Harwood by the bicycle racks 10d 00h 31m 17s 1 NA
## PiZ14 NA NA NA 1 No calibration data recorded
## PiZ15 Dessa Outside Seaver Theater, second floor 4d 20h 40m 22s 1 First power-on
## PiZ15 Dessa Outside Seaver Theater, second floor 14d 02h 51m 23s 2 Second power-on / reboot
## PiZ2W1 Kate Outside 2F Oldenborg window facing Frary 9d 16h 22m 32s 1 Sheet entry '2/9' interpreted as 3/9 — verify with Kate
## PiZ2W2 Matteo Outside bathroom window, first floor (building TBD) 13d 15h 28m 10s 1 NA
## PiZ2W3 Matthew Outside ground floor, Mudd-Honnold Library 9d 20h 50m 30s 1 NA
## PiZ2W4 NA NA NA 1 No calibration data recorded
## PiZ2W5 Anna Pomona Organic Farm (near dome) 9d 12h 35m 54s 1 NA
##
## 20 sensors total | 9 with clock corrections | 11 missing data
clock_offsets <- calibration |>
filter(reboot == 1) |>
select(sensor, offset_sec)
calibration |>
filter(reboot == 1) |>
select(sensor, team_member, location, offset_label, notes) |>
kable(caption = "Deployment metadata and clock offsets (reboot 1 only)") |>
kable_styling(full_width = FALSE) |>
column_spec(3, width = "22em") |>
row_spec(which(is.na(calibration$offset_label[calibration$reboot == 1])),
color = "#aaa")
| sensor | team_member | location | offset_label | notes |
|---|---|---|---|---|
| PiZ1 | NA | NA | NA | No calibration data recorded |
| PiZ2 | NA | NA | NA | No calibration data recorded |
| PiZ3 | NA | NA | NA | No calibration data recorded |
| PiZ4 | NA | NA | NA | No calibration data recorded |
| PiZ5 | NA | NA | NA | No calibration data recorded |
| PiZ6 | NA | NA | NA | No calibration data recorded |
| PiZ7 | Naomi | Outside Blaisdell dorm, by the back stairs | 10d 05h 33m 00s | NA |
| PiZ8 | Sorren | Outside Wig Lounge patio, side facing Lyon | 9d 22h 26m 00s | NA |
| PiZ9 | NA | NA | NA | No calibration data recorded |
| PiZ10 | NA | NA | NA | No calibration data recorded |
| PiZ11 | NA | NA | NA | No calibration data recorded |
| PiZ12 | Ashley | Mudd-Blaisdell Courtyard | 7d 09h 46m 02s | NA |
| PiZ13 | Mariana | Outside Harwood by the bicycle racks | 10d 00h 31m 17s | NA |
| PiZ14 | NA | NA | NA | No calibration data recorded |
| PiZ15 | Dessa | Outside Seaver Theater, second floor | 4d 20h 40m 22s | First power-on |
| PiZ2W1 | Kate | Outside 2F Oldenborg window facing Frary | 9d 16h 22m 32s | Sheet entry ‘2/9’ interpreted as 3/9 — verify with Kate |
| PiZ2W2 | Matteo | Outside bathroom window, first floor (building TBD) | 13d 15h 28m 10s | NA |
| PiZ2W3 | Matthew | Outside ground floor, Mudd-Honnold Library | 9d 20h 50m 30s | NA |
| PiZ2W4 | NA | NA | NA | No calibration data recorded |
| PiZ2W5 | Anna | Pomona Organic Farm (near dome) | 9d 12h 35m 54s | NA |
Note on PiZ2W1: The sheet entry reads “2/9 4:47 PM”. Because the raw data begins 2026-02-27 and every other Pi clock was behind real time (never ahead), this is interpreted as 3/9 (March 9). Verify with the student if needed.
We auto-discover every *_ea30_sp26_v05.csv file in
data_dir so no manual list is needed — and no
trailing-comma errors.
# ── Set to the folder containing your CSV files ──────────────────────────────
data_dir <- "/home/mwl04747/RTricks/A08_Raspberry_Pi_Analysis/Data_S2026"
# ── Helper: read one CSV and apply the clock correction ──────────────────────
read_sensor <- function(path, sensor_id, offsets_tbl) {
offset_s <- offsets_tbl |>
filter(sensor == sensor_id) |>
pull(offset_sec)
if (length(offset_s) == 0 || is.na(offset_s)) {
warning(sprintf("No clock offset for %s — timestamps uncorrected.", sensor_id))
offset_s <- 0
}
read_csv(path, show_col_types = FALSE) |>
mutate(
sensor = sensor_id,
datetime_pi = ymd_hms(timestamp, tz = "America/Los_Angeles"),
datetime = datetime_pi + seconds(offset_s), # ← corrected
date = as_date(datetime),
hour = hour(datetime),
wday = wday(datetime, label = TRUE, abbr = TRUE),
tod = cut(hour,
breaks = c(-1, 5, 11, 17, 20, 23),
labels = c("Night (0–5)", "Morning (6–11)",
"Afternoon (12–17)", "Evening (18–20)",
"Night (21–23)"))
) |>
select(-timestamp, -datetime_pi)
}
# ── Auto-discover CSVs ────────────────────────────────────────────────────────
csv_paths <- list.files(data_dir, pattern = "_ea30_sp26_v05\\.csv$",
full.names = TRUE)
sensor_ids <- sub("_ea30_sp26_v05\\.csv$", "", basename(csv_paths))
if (length(csv_paths) == 0)
stop("No *_ea30_sp26_v05.csv files found. Set data_dir correctly.")
files <- setNames(as.list(csv_paths), sensor_ids)
message(sprintf("Discovered %d file(s): %s",
length(files), paste(sensor_ids, collapse = ", ")))
raw <- imap_dfr(files, ~ read_sensor(.x, .y, clock_offsets))
glimpse(raw)
## Rows: 15,569
## Columns: 16
## $ temperature_C <dbl> 9.32, 15.78, 12.18, 18.49, 18.38, 18.09, 17.70, 17.89, 1…
## $ pressure_hPa <dbl> 707.11, 707.11, 697.76, 697.76, 973.24, 973.36, 973.48, …
## $ humidity_pct <dbl> 74.60, 74.60, 75.24, 75.24, 40.73, 41.67, 42.57, 42.87, …
## $ light_lux <dbl> 260.37, 152.57, 3.85, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00…
## $ oxidised_kO <dbl> 22.17, 116.37, 23.83, 11.53, 2.37, 3.78, 5.11, 6.38, 7.6…
## $ reduced_kO <dbl> 2.67, 7.70, 1.51, 13.72, 624.66, 614.48, 596.72, 578.23,…
## $ nh3_kO <dbl> 0.67, 2.91, 0.23, 1.18, 70.73, 129.96, 165.98, 187.78, 2…
## $ pm1_ug_m3 <dbl> 2, 1, 7, 5, 4, 5, 5, 5, 5, 5, 5, 4, 5, 5, 5, 5, 5, 5, 5,…
## $ pm25_ug_m3 <dbl> 2, 1, 8, 5, 5, 6, 7, 6, 6, 6, 6, 5, 6, 6, 7, 6, 6, 6, 6,…
## $ pm10_ug_m3 <dbl> 2, 1, 8, 5, 5, 6, 8, 7, 7, 7, 6, 6, 6, 9, 9, 9, 8, 8, 8,…
## $ sensor <chr> "PiZ11", "PiZ11", "PiZ12", "PiZ12", "PiZ12", "PiZ12", "P…
## $ datetime <dttm> 2026-02-27 14:57:43, 2026-02-27 15:02:17, 2026-03-09 01…
## $ date <date> 2026-02-27, 2026-02-27, 2026-03-09, 2026-03-09, 2026-03…
## $ hour <int> 14, 15, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, …
## $ wday <ord> Fri, Fri, Mon, Mon, Mon, Mon, Mon, Mon, Mon, Mon, Mon, M…
## $ tod <fct> Afternoon (12–17), Afternoon (12–17), Night (0–5), Night…
We apply three QC filters:
NA in the
target variable are removed.# Sensors with enough data
valid_sensors <- raw |>
filter(!is.na(pm25_ug_m3)) |>
count(sensor) |>
filter(n >= 10) |>
pull(sensor)
dropped <- setdiff(sensor_ids, valid_sensors)
if (length(dropped) > 0)
message("Dropped (< 10 obs): ", paste(dropped, collapse = ", "))
df <- raw |>
filter(sensor %in% valid_sensors,
!is.na(pm25_ug_m3),
!is.na(datetime))
# Attach location label for plot facets / legends
df <- df |>
left_join(
calibration |>
filter(reboot == 1) |>
select(sensor, location),
by = "sensor"
) |>
mutate(
sensor = factor(sensor),
# Short label: just the location, truncated
loc_label = str_trunc(coalesce(location, sensor), 35)
)
Final dataset: 15559 rows across 10 sensor(s).
# Build named color palette from the sensors actually present in df
n_sensors <- n_distinct(df$sensor)
pal <- setNames(sensor_pal[seq_len(n_sensors)], levels(df$sensor))
df |>
group_by(sensor, loc_label) |>
summarise(
n_obs = n(),
start = min(date),
end = max(date),
days_span = as.integer(end - start) + 1L,
pct_missing_pm25 = mean(is.na(pm25_ug_m3)) * 100,
.groups = "drop"
) |>
kable(digits = 1,
caption = "Data coverage per sensor after QC",
col.names = c("Sensor","Location","N obs","Start","End",
"Days spanned","% PM2.5 missing")) |>
kable_styling(full_width = FALSE)
| Sensor | Location | N obs | Start | End | Days spanned | % PM2.5 missing |
|---|---|---|---|---|---|---|
| PiZ12 | Mudd-Blaisdell Courtyard | 1954 | 2026-03-09 | 2026-03-22 | 14 | 0 |
| PiZ13 | Outside Harwood by the bicycle r… | 1721 | 2026-03-11 | 2026-03-23 | 13 | 0 |
| PiZ14 | PiZ14 | 325 | 2026-03-01 | 2026-03-03 | 3 | 0 |
| PiZ15 | Outside Seaver Theater, second f… | 1229 | 2026-03-06 | 2026-03-15 | 10 | 0 |
| PiZ2W1 | Outside 2F Oldenborg window faci… | 2006 | 2026-03-09 | 2026-03-23 | 15 | 0 |
| PiZ2W2 | Outside bathroom window, first f… | 1387 | 2026-03-13 | 2026-03-23 | 11 | 0 |
| PiZ2W3 | Outside ground floor, Mudd-Honno… | 1815 | 2026-03-09 | 2026-03-22 | 14 | 0 |
| PiZ2W5 | Pomona Organic Farm (near dome) | 1996 | 2026-03-09 | 2026-03-23 | 15 | 0 |
| PiZ8 | Outside Wig Lounge patio, side f… | 1857 | 2026-03-10 | 2026-03-23 | 14 | 0 |
| PiZ9 | PiZ9 | 1269 | 2026-02-28 | 2026-03-13 | 14 | 0 |
desc <- df |>
group_by(sensor) |>
summarise(
N = n(),
Mean = mean(pm25_ug_m3),
SD = sd(pm25_ug_m3),
Median = median(pm25_ug_m3),
IQR = IQR(pm25_ug_m3),
Skewness = moments::skewness(pm25_ug_m3),
P5 = quantile(pm25_ug_m3, .05),
P95 = quantile(pm25_ug_m3, .95),
Max = max(pm25_ug_m3),
`% Good (≤12)` = mean(pm25_ug_m3 <= 12) * 100,
`% Moderate (12–35)` = mean(pm25_ug_m3 > 12 & pm25_ug_m3 <= 35.4) * 100,
`% USG (>35)` = mean(pm25_ug_m3 > 35.4) * 100,
.groups = "drop"
)
kable(desc, digits = 2,
caption = "PM2.5 descriptive statistics (µg/m³) with EPA AQI breakdown") |>
kable_styling(full_width = TRUE) |>
add_header_above(c(" " = 10, "EPA AQI Category (%)" = 3))
| sensor | N | Mean | SD | Median | IQR | Skewness | P5 | P95 | Max | % Good (≤12) | % Moderate (12–35) | % USG (>35) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| PiZ12 | 1954 | 8.15 | 5.74 | 6 | 6 | 2.08 | 3 | 26.0 | 31 | 88.54 | 11.46 | 0.00 |
| PiZ13 | 1721 | 7.97 | 5.45 | 5 | 7 | 1.43 | 3 | 22.0 | 27 | 87.51 | 12.49 | 0.00 |
| PiZ14 | 325 | 2.06 | 1.16 | 2 | 2 | 1.46 | 0 | 4.0 | 9 | 100.00 | 0.00 | 0.00 |
| PiZ15 | 1229 | 6.86 | 8.28 | 3 | 7 | 1.19 | 0 | 22.0 | 29 | 76.97 | 23.03 | 0.00 |
| PiZ2W1 | 2006 | 5.84 | 4.44 | 5 | 2 | 3.17 | 3 | 21.0 | 24 | 93.27 | 6.73 | 0.00 |
| PiZ2W2 | 1387 | 2.87 | 4.32 | 1 | 5 | 1.61 | 0 | 13.0 | 19 | 93.08 | 6.92 | 0.00 |
| PiZ2W3 | 1815 | 7.19 | 4.14 | 6 | 8 | 0.20 | 2 | 13.3 | 17 | 90.41 | 9.59 | 0.00 |
| PiZ2W5 | 1996 | 4.58 | 4.78 | 3 | 1 | 3.20 | 1 | 20.0 | 26 | 93.14 | 6.86 | 0.00 |
| PiZ8 | 1857 | 7.40 | 3.86 | 6 | 6 | 0.50 | 3 | 15.0 | 17 | 91.98 | 8.02 | 0.00 |
| PiZ9 | 1269 | 11.85 | 12.07 | 5 | 20 | 2.05 | 1 | 27.0 | 98 | 62.17 | 35.93 | 1.89 |
Reading the table: High skewness (> 1) indicates a right-skewed distribution — a small number of high-concentration events pull the mean above the median. This is typical for PM data and motivates the use of non-parametric tests later.
df |>
select(sensor, pm1_ug_m3, pm25_ug_m3) |>
pivot_longer(-sensor, names_to = "fraction", values_to = "conc") |>
mutate(fraction = recode(fraction,
pm1_ug_m3 = "PM1.0",
pm25_ug_m3 = "PM2.5")) |>
group_by(sensor, fraction) |>
summarise(Mean = mean(conc), Median = median(conc),
SD = sd(conc), .groups = "drop") |>
pivot_wider(names_from = fraction,
values_from = c(Mean, Median, SD)) |>
mutate(`PM1/PM2.5 ratio (mean)` = `Mean_PM1.0` / `Mean_PM2.5`) |>
kable(digits = 2,
caption = "PM1.0 vs PM2.5 summary — a ratio near 1 means nearly all fine
particles are in the sub-1µm range (combustion dominated)") |>
kable_styling(full_width = FALSE)
| sensor | Mean_PM1.0 | Mean_PM2.5 | Median_PM1.0 | Median_PM2.5 | SD_PM1.0 | SD_PM2.5 | PM1/PM2.5 ratio (mean) |
|---|---|---|---|---|---|---|---|
| PiZ12 | 5.65 | 8.15 | 4 | 6 | 4.64 | 5.74 | 0.69 |
| PiZ13 | 5.88 | 7.97 | 4 | 5 | 4.23 | 5.45 | 0.74 |
| PiZ14 | 0.74 | 2.06 | 1 | 2 | 0.84 | 1.16 | 0.36 |
| PiZ15 | 3.30 | 6.86 | 1 | 3 | 4.76 | 8.28 | 0.48 |
| PiZ2W1 | 3.73 | 5.84 | 3 | 5 | 3.59 | 4.44 | 0.64 |
| PiZ2W2 | 1.70 | 2.87 | 0 | 1 | 2.72 | 4.32 | 0.59 |
| PiZ2W3 | 4.26 | 7.19 | 4 | 6 | 2.61 | 4.14 | 0.59 |
| PiZ2W5 | 2.88 | 4.58 | 2 | 3 | 3.81 | 4.78 | 0.63 |
| PiZ8 | 4.87 | 7.40 | 4 | 6 | 2.87 | 3.86 | 0.66 |
| PiZ9 | 8.06 | 11.85 | 3 | 5 | 8.54 | 12.07 | 0.68 |
# Disabled: requires ggplot2 >= 3.4 with matching scales/vctrs on R 4.2.2
# Upgrade R to 4.4.x to re-enable this chunk
# Disabled: same ggplot2/R 4.2.2 version conflict as timeseries chunk
p_dens <- df |>
ggplot(aes(x = pm25_ug_m3, fill = sensor, color = sensor)) +
geom_density(alpha = 0.30, linewidth = 0.7) +
geom_vline(xintercept = c(12, 35.4),
linetype = "dashed",
color = c("forestgreen", "darkorange"),
linewidth = 0.7) +
scale_fill_manual(values = pal) +
scale_color_manual(values = pal) +
labs(title = "PM2.5 Density", x = "PM2.5 (µg/m³)",
y = "Density", fill = "Sensor", color = "Sensor") +
theme(legend.position = "top")
p_box <- df |>
ggplot(aes(x = sensor, y = pm25_ug_m3, fill = sensor)) +
geom_boxplot(outlier.alpha = 0.25, outlier.size = 0.8, width = 0.5) +
geom_jitter(width = 0.12, alpha = 0.04, size = 0.5) +
stat_summary(fun = mean, geom = "point",
shape = 23, fill = "white", size = 2.5) +
scale_fill_manual(values = pal) +
labs(title = "PM2.5 by Sensor",
subtitle = "◇ = mean; box = IQR",
x = "Sensor", y = "PM2.5 (µg/m³)") +
theme(legend.position = "none")
p_dens + p_box
Right skew is normal for PM data. Most readings are low, but episodic events produce a long right tail. The mean will always exceed the median. Use median and IQR for central tendency, and non-parametric tests for inference.
df |>
group_by(sensor, hour) |>
summarise(
mean_pm25 = mean(pm25_ug_m3),
se_pm25 = sd(pm25_ug_m3) / sqrt(n()),
mean_pm1 = mean(pm1_ug_m3),
se_pm1 = sd(pm1_ug_m3) / sqrt(n()),
.groups = "drop"
) |>
pivot_longer(c(mean_pm25, mean_pm1),
names_to = "fraction", values_to = "mean") |>
mutate(
se = if_else(fraction == "mean_pm25", se_pm25, se_pm1),
fraction = recode(fraction, mean_pm25 = "PM2.5", mean_pm1 = "PM1.0")
) |>
ggplot(aes(x = hour, y = mean, color = sensor, linetype = fraction)) +
geom_ribbon(aes(ymin = mean - se, ymax = mean + se,
fill = sensor, group = interaction(sensor, fraction)),
alpha = 0.10, color = NA) +
geom_line(linewidth = 0.9) +
facet_wrap(~ fraction) +
scale_color_manual(values = pal) +
scale_fill_manual(values = pal) +
scale_x_continuous(breaks = seq(0, 23, 3),
labels = sprintf("%02d:00", seq(0, 23, 3))) +
labs(title = "Diurnal Profile of PM1.0 and PM2.5",
subtitle = "Mean ± 1 SE by hour of day",
x = "Hour of Day", y = "Concentration (µg/m³)",
color = "Sensor", fill = "Sensor", linetype = NULL) +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "top")
What to look for: Morning peaks (~7–9 AM) or evening peaks (~5–8 PM) would support H2. A midday dip is consistent with stronger atmospheric mixing (higher planetary boundary layer) diluting surface-level emissions.
# Disabled: scale_y_date affected by same ggplot2/R 4.2.2 version conflict
These heatmaps show pairwise Pearson correlations between PM2.5 and all co-measured variables. They help identify which predictors to include in the regression model.
cor_vars <- c("pm25_ug_m3", "pm1_ug_m3", "pm10_ug_m3",
"temperature_C", "humidity_pct", "pressure_hPa",
"light_lux", "oxidised_kO", "reduced_kO", "nh3_kO")
for (s in levels(df$sensor)) {
cat(sprintf("\n\n#### Sensor: %s\n\n", s))
cor_mat <- df |>
filter(sensor == s) |>
select(all_of(cor_vars)) |>
drop_na() |>
cor(method = "pearson")
p <- cor_mat |>
as.data.frame() |>
rownames_to_column("var1") |>
pivot_longer(-var1, names_to = "var2", values_to = "r") |>
mutate(
var1 = factor(var1, levels = cor_vars),
var2 = factor(var2, levels = rev(cor_vars)),
label = sprintf("%.2f", r)
) |>
ggplot(aes(x = var1, y = var2, fill = r)) +
geom_tile(color = "white", linewidth = 0.5) +
geom_text(aes(label = label), size = 3, color = "black") +
scale_fill_gradient2(
low = "#D55E00", mid = "white", high = "#0072B2",
midpoint = 0, limits = c(-1, 1), name = "r"
) +
labs(title = paste("Correlation Matrix —", s), x = NULL, y = NULL) +
theme(axis.text.x = element_text(angle = 45, hjust = 1, size = 9),
axis.text.y = element_text(size = 9),
plot.title = element_text(size = 11))
print(p)
}
set.seed(42)
norm_res <- df |>
group_by(sensor) |>
summarise(
n = n(),
W_pm25 = shapiro.test(sample(pm25_ug_m3, min(n(), 5000)))$statistic,
p_pm25 = shapiro.test(sample(pm25_ug_m3, min(n(), 5000)))$p.value,
W_pm1 = shapiro.test(sample(pm1_ug_m3, min(n(), 5000)))$statistic,
p_pm1 = shapiro.test(sample(pm1_ug_m3, min(n(), 5000)))$p.value,
.groups = "drop"
) |>
mutate(
normal_pm25 = if_else(p_pm25 > 0.05, "Yes", "No"),
normal_pm1 = if_else(p_pm1 > 0.05, "Yes", "No")
)
kable(norm_res, digits = 4,
caption = "Shapiro-Wilk normality test (sampled to ≤ 5000 obs)") |>
kable_styling(full_width = FALSE)
| sensor | n | W_pm25 | p_pm25 | W_pm1 | p_pm1 | normal_pm25 | normal_pm1 |
|---|---|---|---|---|---|---|---|
| PiZ12 | 1954 | 0.7388 | 0 | 0.6417 | 0 | No | No |
| PiZ13 | 1721 | 0.8190 | 0 | 0.7962 | 0 | No | No |
| PiZ14 | 325 | 0.8410 | 0 | 0.7196 | 0 | No | No |
| PiZ15 | 1229 | 0.7092 | 0 | 0.7126 | 0 | No | No |
| PiZ2W1 | 2006 | 0.4865 | 0 | 0.4508 | 0 | No | No |
| PiZ2W2 | 1387 | 0.7058 | 0 | 0.6837 | 0 | No | No |
| PiZ2W3 | 1815 | 0.8801 | 0 | 0.8712 | 0 | No | No |
| PiZ2W5 | 1996 | 0.5030 | 0 | 0.5008 | 0 | No | No |
| PiZ8 | 1857 | 0.9133 | 0 | 0.9068 | 0 | No | No |
| PiZ9 | 1269 | 0.7486 | 0 | 0.7352 | 0 | No | No |
Interpretation: A p-value < 0.05 rejects the null hypothesis of normality. PM data are almost always non-normal (right-skewed, discrete integer values at low concentrations). This means we should use non-parametric tests for inference, not t-tests or ANOVA.
The Wilcoxon rank-sum test (Mann-Whitney U) is the non-parametric equivalent of the two-sample t-test. It tests whether the distributions of two groups have the same location (median), without assuming normality.
Hypotheses: H₀: PM2.5 at sensor A = PM2.5 at sensor B (same distribution); H₁: they differ.
sensors_v <- levels(df$sensor)
combs <- combn(sensors_v, 2, simplify = FALSE)
wilcox_res <- map_dfr(combs, function(pair) {
x <- df |> filter(sensor == pair[1]) |> pull(pm25_ug_m3)
y <- df |> filter(sensor == pair[2]) |> pull(pm25_ug_m3)
wt <- wilcox.test(x, y, conf.int = TRUE, exact = FALSE)
n1 <- length(x); n2 <- length(y)
r <- 1 - 2 * wt$statistic / (n1 * n2) # rank-biserial effect size
tibble(
Comparison = paste(pair[1], "vs", pair[2]),
W = wt$statistic,
p_value = wt$p.value,
Location_shift = wt$estimate,
CI_lower = wt$conf.int[1],
CI_upper = wt$conf.int[2],
Effect_r = r,
Significant = if_else(p_value < 0.05, "✓", "—")
)
})
wilcox_res |>
mutate(Significant = cell_spec(
Significant, bold = TRUE,
color = if_else(Significant == "✓", "forestgreen", "grey60")
)) |>
kable(digits = 4, escape = FALSE,
caption = "Pairwise Wilcoxon tests for PM2.5; effect size r = rank-biserial correlation") |>
kable_styling(full_width = FALSE)
| Comparison | W | p_value | Location_shift | CI_lower | CI_upper | Effect_r | Significant |
|---|---|---|---|---|---|---|---|
| PiZ12 vs PiZ13 | 1761886.0 | 0.0114 | 0.0000 | 0.0001 | 0.0001 | -0.0479 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ12 vs PiZ14 | 615122.0 | 0.0000 | 4.0000 | 4.0000 | 5.0000 | -0.9372 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ12 vs PiZ15 | 1718470.0 | 0.0000 | 3.0001 | 2.9999 | 3.0001 | -0.4312 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ12 vs PiZ2W1 | 2600000.0 | 0.0000 | 1.0000 | 1.0001 | 2.0000 | -0.3266 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ12 vs PiZ2W2 | 2203679.0 | 0.0000 | 5.0000 | 4.0000 | 5.0000 | -0.6262 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ12 vs PiZ2W3 | 1928694.5 | 0.0000 | 0.9999 | 0.0000 | 0.9999 | -0.0877 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ12 vs PiZ2W5 | 3192708.0 | 0.0000 | 3.0000 | 3.0000 | 3.0001 | -0.6372 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ12 vs PiZ8 | 1856869.0 | 0.2065 | 0.0001 | 0.0000 | 0.0000 | -0.0235 | <span style=” font-weight: bold; color: rgba(153, 153, 153, 255) !important;” >—</span> |
| PiZ12 vs PiZ9 | 1328354.0 | 0.0006 | 0.9999 | 0.0001 | 1.0000 | -0.0714 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ13 vs PiZ14 | 531061.0 | 0.0000 | 3.9999 | 3.0000 | 4.0001 | -0.8989 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ13 vs PiZ15 | 1476721.0 | 0.0000 | 2.9999 | 2.0000 | 2.9999 | -0.3964 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ13 vs PiZ2W1 | 2046469.5 | 0.0000 | 1.0000 | 1.0000 | 1.0000 | -0.1856 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ13 vs PiZ2W2 | 1916330.5 | 0.0000 | 4.0000 | 4.0000 | 4.0000 | -0.6056 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ13 vs PiZ2W3 | 1665992.5 | 0.0005 | 0.0001 | 0.0000 | 0.0000 | -0.0667 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ13 vs PiZ2W5 | 2630872.0 | 0.0000 | 2.0000 | 2.0001 | 2.0000 | -0.5318 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ13 vs PiZ8 | 1606565.0 | 0.7782 | 0.0001 | -0.0001 | 0.0000 | -0.0054 | <span style=” font-weight: bold; color: rgba(153, 153, 153, 255) !important;” >—</span> |
| PiZ13 vs PiZ9 | 1118035.0 | 0.2612 | 0.0000 | 0.0000 | 1.0000 | -0.0239 | <span style=” font-weight: bold; color: rgba(153, 153, 153, 255) !important;” >—</span> |
| PiZ14 vs PiZ15 | 148623.0 | 0.0000 | -1.0000 | -1.0000 | -1.0000 | 0.2558 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ14 vs PiZ2W1 | 32083.5 | 0.0000 | -3.0000 | -3.0000 | -2.9999 | 0.9016 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ14 vs PiZ2W2 | 282058.5 | 0.0000 | 0.9999 | 0.9999 | 1.0001 | -0.2514 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ14 vs PiZ2W3 | 53190.0 | 0.0000 | -4.0000 | -6.0000 | -3.0001 | 0.8197 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ14 vs PiZ2W5 | 126871.5 | 0.0000 | -1.0000 | -2.0000 | -1.0000 | 0.6088 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ14 vs PiZ8 | 32940.5 | 0.0000 | -4.9999 | -5.9999 | -4.0000 | 0.8908 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ14 vs PiZ9 | 65527.0 | 0.0000 | -3.0001 | -6.0000 | -3.0000 | 0.6822 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ15 vs PiZ2W1 | 816908.0 | 0.0000 | -2.0000 | -2.0000 | -2.0000 | 0.3373 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ15 vs PiZ2W2 | 1213453.5 | 0.0000 | 1.9999 | 2.0000 | 2.0000 | -0.4237 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ15 vs PiZ2W3 | 752382.0 | 0.0000 | -2.0000 | -2.0000 | -1.9999 | 0.3254 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ15 vs PiZ2W5 | 1057874.5 | 0.0000 | -0.9999 | -1.0000 | -0.0001 | 0.1375 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ15 vs PiZ8 | 713050.5 | 0.0000 | -3.0000 | -3.0000 | -2.0001 | 0.3751 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ15 vs PiZ9 | 485720.5 | 0.0000 | -2.0000 | -2.0000 | -2.0000 | 0.3771 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W1 vs PiZ2W2 | 2069611.0 | 0.0000 | 4.0000 | 4.0000 | 4.0000 | -0.4877 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W1 vs PiZ2W3 | 1643879.5 | 0.0000 | -1.0000 | -1.0001 | 0.0000 | 0.0970 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W1 vs PiZ2W5 | 3021455.0 | 0.0000 | 1.0000 | 1.0001 | 1.0000 | -0.5092 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W1 vs PiZ8 | 1407858.0 | 0.0000 | -1.0000 | -2.0000 | -1.0000 | 0.2441 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W1 vs PiZ9 | 1216570.5 | 0.0308 | 0.0000 | -1.0000 | -0.0001 | 0.0442 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W2 vs PiZ2W3 | 508472.5 | 0.0000 | -4.0000 | -4.0000 | -3.9999 | 0.5960 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W2 vs PiZ2W5 | 823430.0 | 0.0000 | -2.0000 | -2.0000 | -2.0000 | 0.4051 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W2 vs PiZ8 | 493368.5 | 0.0000 | -4.0001 | -4.9999 | -4.0000 | 0.6169 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W2 vs PiZ9 | 326063.0 | 0.0000 | -4.0000 | -4.0000 | -3.0001 | 0.6295 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W3 vs PiZ2W5 | 2532949.5 | 0.0000 | 2.0000 | 2.0000 | 2.0000 | -0.3984 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W3 vs PiZ8 | 1621240.5 | 0.0447 | 0.0000 | 0.0000 | -0.0001 | 0.0380 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W3 vs PiZ9 | 1086877.5 | 0.0074 | -0.0001 | -1.0000 | 0.0000 | 0.0562 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W5 vs PiZ8 | 853066.5 | 0.0000 | -3.0000 | -3.0001 | -3.0000 | 0.5397 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ2W5 vs PiZ9 | 902507.5 | 0.0000 | -2.0000 | -2.0001 | -1.0000 | 0.2874 | <span style=” font-weight: bold; color: forestgreen !important;” >✓</span> |
| PiZ8 vs PiZ9 | 1182625.5 | 0.8597 | 0.0001 | 0.0000 | 0.0000 | -0.0037 | <span style=” font-weight: bold; color: rgba(153, 153, 153, 255) !important;” >—</span> |
Effect size (rank-biserial r): |r| < 0.1 = negligible; 0.1–0.3 = small; 0.3–0.5 = medium; > 0.5 = large. A statistically significant result with a negligible effect size is not practically meaningful.
kw_res <- df |>
group_by(sensor) |>
summarise(
chi_sq = kruskal.test(pm25_ug_m3 ~ tod)$statistic,
df_kw = kruskal.test(pm25_ug_m3 ~ tod)$parameter,
p_value = kruskal.test(pm25_ug_m3 ~ tod)$p.value,
.groups = "drop"
) |>
mutate(significant = if_else(p_value < 0.05, "✓", "—"),
# Epsilon-squared effect size for KW
eta_sq = (chi_sq - df_kw + 1) / (n_distinct(df$sensor) - df_kw))
kable(kw_res, digits = 4,
caption = "Kruskal-Wallis test: does PM2.5 differ across time-of-day groups?") |>
kable_styling(full_width = FALSE)
| sensor | chi_sq | df_kw | p_value | significant | eta_sq |
|---|---|---|---|---|---|
| PiZ12 | 13.8722 | 4 | 0.0077 | ✓ | 1.8120 |
| PiZ13 | 5.4420 | 4 | 0.2449 | — | 0.4070 |
| PiZ14 | 18.8654 | 4 | 0.0008 | ✓ | 2.6442 |
| PiZ15 | 35.4280 | 4 | 0.0000 | ✓ | 5.4047 |
| PiZ2W1 | 35.6048 | 4 | 0.0000 | ✓ | 5.4341 |
| PiZ2W2 | 3.3345 | 4 | 0.5035 | — | 0.0558 |
| PiZ2W3 | 51.0976 | 4 | 0.0000 | ✓ | 8.0163 |
| PiZ2W5 | 21.8770 | 4 | 0.0002 | ✓ | 3.1462 |
| PiZ8 | 9.4023 | 4 | 0.0518 | — | 1.0671 |
| PiZ9 | 32.8772 | 4 | 0.0000 | ✓ | 4.9795 |
df |>
ggplot(aes(x = tod, y = pm25_ug_m3, fill = tod)) +
geom_boxplot(outlier.alpha = 0.15, outlier.size = 0.6, width = 0.6) +
stat_summary(fun = mean, geom = "point",
shape = 23, fill = "white", size = 2) +
facet_wrap(~ sensor, nrow = 1) +
scale_fill_brewer(palette = "RdYlGn", direction = -1) +
labs(title = "PM2.5 by Time of Day",
subtitle = "Kruskal-Wallis test p-values shown above",
x = NULL, y = "PM2.5 (µg/m³)") +
theme(legend.position = "none",
axis.text.x = element_text(angle = 35, hjust = 1))
Even though PM2.5 is not normally distributed, OLS regression can still be used to identify predictors, because the assumptions concern the residuals, not the raw response. We will check residuals below.
Model:
\[\text{PM}_{2.5} = \beta_0 + \beta_1 T +
\beta_2 \text{RH} + \beta_3 P + \beta_4 \text{light} + \beta_5 \text{ox}
+ \beta_6 \text{red} + \beta_7 \text{NH}_3 + \varepsilon\]
lm_list <- df |>
group_by(sensor) |>
group_map(function(d, key) {
mod <- lm(pm25_ug_m3 ~ temperature_C + humidity_pct + pressure_hPa +
light_lux + oxidised_kO + reduced_kO + nh3_kO,
data = d)
list(sensor = key$sensor, model = mod,
tidy = tidy(mod) |> mutate(sensor = key$sensor, .before=1),
glance = glance(mod) |> mutate(sensor = key$sensor, .before=1))
})
# Coefficient table
map_dfr(lm_list, "tidy") |>
mutate(sig = case_when(p.value < 0.001 ~ "***",
p.value < 0.01 ~ "**",
p.value < 0.05 ~ "*",
TRUE ~ "")) |>
kable(digits = 4,
caption = "OLS regression coefficients (*** p<.001, ** p<.01, * p<.05)") |>
kable_styling(full_width = TRUE)
| sensor | term | estimate | std.error | statistic | p.value | sig |
|---|---|---|---|---|---|---|
| PiZ12 | (Intercept) | 54.4022 | 8.3150 | 6.5426 | 0.0000 | *** |
| PiZ12 | temperature_C | 0.2019 | 0.0617 | 3.2700 | 0.0011 | ** |
| PiZ12 | humidity_pct | 0.0747 | 0.0273 | 2.7371 | 0.0063 | ** |
| PiZ12 | pressure_hPa | -0.0692 | 0.0081 | -8.5400 | 0.0000 | *** |
| PiZ12 | light_lux | 0.0428 | 0.0229 | 1.8710 | 0.0615 | |
| PiZ12 | oxidised_kO | -0.0210 | 0.0012 | -16.9043 | 0.0000 | *** |
| PiZ12 | reduced_kO | 0.0002 | 0.0022 | 0.1043 | 0.9169 | |
| PiZ12 | nh3_kO | 0.0615 | 0.0022 | 27.6371 | 0.0000 | *** |
| PiZ13 | (Intercept) | 56.3147 | 7.1341 | 7.8938 | 0.0000 | *** |
| PiZ13 | temperature_C | -0.3393 | 0.0396 | -8.5796 | 0.0000 | *** |
| PiZ13 | humidity_pct | -0.0882 | 0.0228 | -3.8725 | 0.0001 | *** |
| PiZ13 | pressure_hPa | -0.0535 | 0.0069 | -7.7031 | 0.0000 | *** |
| PiZ13 | light_lux | 0.0565 | 0.0684 | 0.8255 | 0.4092 | |
| PiZ13 | oxidised_kO | -0.0006 | 0.0002 | -3.4771 | 0.0005 | *** |
| PiZ13 | reduced_kO | 0.0245 | 0.0020 | 12.4271 | 0.0000 | *** |
| PiZ13 | nh3_kO | 0.0178 | 0.0009 | 19.7509 | 0.0000 | *** |
| PiZ14 | (Intercept) | 32.6424 | 5.0161 | 6.5075 | 0.0000 | *** |
| PiZ14 | temperature_C | -0.1736 | 0.0292 | -5.9535 | 0.0000 | *** |
| PiZ14 | humidity_pct | -0.1279 | 0.0244 | -5.2470 | 0.0000 | *** |
| PiZ14 | pressure_hPa | -0.0245 | 0.0042 | -5.7923 | 0.0000 | *** |
| PiZ14 | light_lux | 0.0000 | 0.0000 | -1.4283 | 0.1542 | |
| PiZ14 | oxidised_kO | -0.0014 | 0.0005 | -2.7215 | 0.0069 | ** |
| PiZ14 | reduced_kO | 0.0078 | 0.0013 | 5.9821 | 0.0000 | *** |
| PiZ14 | nh3_kO | -0.0062 | 0.0016 | -3.9536 | 0.0001 | *** |
| PiZ15 | (Intercept) | -249.0057 | 17.7124 | -14.0583 | 0.0000 | *** |
| PiZ15 | temperature_C | 0.8465 | 0.0904 | 9.3642 | 0.0000 | *** |
| PiZ15 | humidity_pct | 1.1148 | 0.0705 | 15.8035 | 0.0000 | *** |
| PiZ15 | pressure_hPa | 0.1979 | 0.0151 | 13.0769 | 0.0000 | *** |
| PiZ15 | light_lux | 0.0447 | 0.0871 | 0.5127 | 0.6082 | |
| PiZ15 | oxidised_kO | -0.0022 | 0.0008 | -2.7632 | 0.0058 | ** |
| PiZ15 | reduced_kO | -0.0145 | 0.0048 | -3.0518 | 0.0023 | ** |
| PiZ15 | nh3_kO | 0.0607 | 0.0050 | 12.1145 | 0.0000 | *** |
| PiZ2W1 | (Intercept) | 96.5084 | 6.7629 | 14.2702 | 0.0000 | *** |
| PiZ2W1 | temperature_C | -0.1932 | 0.0231 | -8.3483 | 0.0000 | *** |
| PiZ2W1 | humidity_pct | -0.2866 | 0.0178 | -16.0608 | 0.0000 | *** |
| PiZ2W1 | pressure_hPa | -0.0912 | 0.0066 | -13.7345 | 0.0000 | *** |
| PiZ2W1 | light_lux | -0.0328 | 0.0061 | -5.4021 | 0.0000 | *** |
| PiZ2W1 | oxidised_kO | -0.0131 | 0.0007 | -19.5654 | 0.0000 | *** |
| PiZ2W1 | reduced_kO | 0.0731 | 0.0024 | 29.9470 | 0.0000 | *** |
| PiZ2W1 | nh3_kO | -0.0181 | 0.0020 | -9.1504 | 0.0000 | *** |
| PiZ2W2 | (Intercept) | 15.4209 | 8.1719 | 1.8871 | 0.0594 | |
| PiZ2W2 | temperature_C | -0.7180 | 0.1236 | -5.8094 | 0.0000 | *** |
| PiZ2W2 | humidity_pct | 0.1562 | 0.0431 | 3.6286 | 0.0003 | *** |
| PiZ2W2 | pressure_hPa | -0.0060 | 0.0097 | -0.6238 | 0.5329 | |
| PiZ2W2 | light_lux | -0.0418 | 0.0266 | -1.5743 | 0.1157 | |
| PiZ2W2 | oxidised_kO | -0.0877 | 0.0078 | -11.2174 | 0.0000 | *** |
| PiZ2W2 | reduced_kO | 0.0341 | 0.0038 | 8.8832 | 0.0000 | *** |
| PiZ2W2 | nh3_kO | 0.0683 | 0.0071 | 9.5984 | 0.0000 | *** |
| PiZ2W3 | (Intercept) | -20.9775 | 7.9399 | -2.6421 | 0.0083 | ** |
| PiZ2W3 | temperature_C | 0.2089 | 0.0405 | 5.1587 | 0.0000 | *** |
| PiZ2W3 | humidity_pct | 0.2087 | 0.0317 | 6.5852 | 0.0000 | *** |
| PiZ2W3 | pressure_hPa | 0.0085 | 0.0069 | 1.2235 | 0.2213 | |
| PiZ2W3 | light_lux | -0.0203 | 0.0195 | -1.0403 | 0.2984 | |
| PiZ2W3 | oxidised_kO | -0.0006 | 0.0003 | -2.0129 | 0.0443 |
|
| PiZ2W3 | reduced_kO | -0.0197 | 0.0023 | -8.5415 | 0.0000 | *** |
| PiZ2W3 | nh3_kO | 0.0340 | 0.0016 | 21.8333 | 0.0000 | *** |
| PiZ2W5 | (Intercept) | 11.5621 | 7.2535 | 1.5940 | 0.1111 | |
| PiZ2W5 | temperature_C | 0.0680 | 0.0424 | 1.6029 | 0.1091 | |
| PiZ2W5 | humidity_pct | 0.0387 | 0.0210 | 1.8379 | 0.0662 | |
| PiZ2W5 | pressure_hPa | -0.0066 | 0.0068 | -0.9768 | 0.3288 | |
| PiZ2W5 | light_lux | -0.0358 | 0.0164 | -2.1804 | 0.0293 |
|
| PiZ2W5 | oxidised_kO | -0.0210 | 0.0047 | -4.4356 | 0.0000 | *** |
| PiZ2W5 | reduced_kO | -0.0418 | 0.0018 | -23.3604 | 0.0000 | *** |
| PiZ2W5 | nh3_kO | 0.0408 | 0.0015 | 26.7503 | 0.0000 | *** |
| PiZ8 | (Intercept) | -26.3352 | 9.7303 | -2.7065 | 0.0069 | ** |
| PiZ8 | temperature_C | 0.0512 | 0.0412 | 1.2434 | 0.2139 | |
| PiZ8 | humidity_pct | 0.1837 | 0.0339 | 5.4142 | 0.0000 | *** |
| PiZ8 | pressure_hPa | 0.0217 | 0.0088 | 2.4756 | 0.0134 |
|
| PiZ8 | light_lux | -0.0371 | 0.0160 | -2.3222 | 0.0203 |
|
| PiZ8 | oxidised_kO | -0.0043 | 0.0004 | -10.9235 | 0.0000 | *** |
| PiZ8 | reduced_kO | -0.0103 | 0.0015 | -7.0189 | 0.0000 | *** |
| PiZ8 | nh3_kO | 0.0364 | 0.0020 | 18.3381 | 0.0000 | *** |
| PiZ9 | (Intercept) | -13.1906 | 34.8174 | -0.3789 | 0.7049 | |
| PiZ9 | temperature_C | -0.2690 | 0.2085 | -1.2902 | 0.1972 | |
| PiZ9 | humidity_pct | 0.3130 | 0.1536 | 2.0381 | 0.0417 |
|
| PiZ9 | pressure_hPa | 0.0217 | 0.0284 | 0.7654 | 0.4442 | |
| PiZ9 | light_lux | -0.0462 | 0.0309 | -1.4962 | 0.1348 | |
| PiZ9 | oxidised_kO | -0.0388 | 0.0104 | -3.7356 | 0.0002 | *** |
| PiZ9 | reduced_kO | 0.0735 | 0.0091 | 8.0740 | 0.0000 | *** |
| PiZ9 | nh3_kO | -0.0454 | 0.0136 | -3.3359 | 0.0009 | *** |
map_dfr(lm_list, "glance") |>
select(sensor, r.squared, adj.r.squared,
statistic, p.value, nobs) |>
kable(digits = 4, caption = "Model fit statistics") |>
kable_styling(full_width = FALSE)
| sensor | r.squared | adj.r.squared | statistic | p.value | nobs |
|---|---|---|---|---|---|
| PiZ12 | 0.3939 | 0.3918 | 180.7073 | 0 | 1954 |
| PiZ13 | 0.4179 | 0.4156 | 175.7146 | 0 | 1721 |
| PiZ14 | 0.3198 | 0.3047 | 21.2874 | 0 | 325 |
| PiZ15 | 0.3518 | 0.3481 | 94.6795 | 0 | 1229 |
| PiZ2W1 | 0.3467 | 0.3444 | 151.4886 | 0 | 2006 |
| PiZ2W2 | 0.5575 | 0.5552 | 248.1547 | 0 | 1387 |
| PiZ2W3 | 0.4657 | 0.4636 | 225.0073 | 0 | 1815 |
| PiZ2W5 | 0.3455 | 0.3432 | 149.9359 | 0 | 1996 |
| PiZ8 | 0.3575 | 0.3551 | 146.9693 | 0 | 1857 |
| PiZ9 | 0.1561 | 0.1514 | 33.3221 | 0 | 1269 |
Interpreting R²: An R² of 0.35 means the seven predictors together explain 35% of the variance in PM2.5. The remaining 65% is unexplained — likely due to unmeasured factors (wind speed, source proximity, indoor infiltration).
Check the four key OLS assumptions visually:
for (item in lm_list) {
cat(sprintf("\n\n#### Sensor: %s\n\n", item$sensor))
par(mfrow = c(2, 2), mar = c(4, 4, 3, 1))
plot(item$model, main = item$sensor)
par(mfrow = c(1, 1))
}
What to look for:
If assumptions are badly violated, consider: (a) log-transform the response, (b) use a Gamma GLM (natural for positive continuous data), or (c) a robust regression.
PM concentrations are strictly positive and right-skewed — a natural candidate for log-transformation, which often improves residual normality.
lm_log_list <- df |>
filter(pm25_ug_m3 > 0) |> # log(0) = -Inf
group_by(sensor) |>
group_map(function(d, key) {
mod <- lm(log(pm25_ug_m3) ~ temperature_C + humidity_pct + pressure_hPa +
light_lux + oxidised_kO + reduced_kO + nh3_kO,
data = d)
list(sensor = key$sensor, model = mod,
glance = glance(mod) |> mutate(sensor = key$sensor, .before=1))
})
map_dfr(lm_log_list, "glance") |>
select(sensor, r.squared, adj.r.squared, nobs) |>
kable(digits = 4, caption = "Log-model R² per sensor") |>
kable_styling(full_width = FALSE)
| sensor | r.squared | adj.r.squared | nobs |
|---|---|---|---|
| PiZ12 | 0.3879 | 0.3857 | 1954 |
| PiZ13 | 0.4607 | 0.4585 | 1721 |
| PiZ14 | 0.2382 | 0.2204 | 307 |
| PiZ15 | 0.2930 | 0.2886 | 1143 |
| PiZ2W1 | 0.2109 | 0.2081 | 2005 |
| PiZ2W2 | 0.6171 | 0.6132 | 701 |
| PiZ2W3 | 0.4430 | 0.4408 | 1815 |
| PiZ2W5 | 0.3518 | 0.3495 | 1993 |
| PiZ8 | 0.3400 | 0.3375 | 1857 |
| PiZ9 | 0.2216 | 0.2173 | 1262 |
Air quality measurements taken minutes apart are correlated — a high reading now predicts a high reading in 5 minutes. This autocorrelation violates the independence assumption of OLS and inflates false-positive rates in standard tests. The ACF (autocorrelation function) and PACF (partial ACF) quantify this.
for (s in levels(df$sensor)) {
cat(sprintf("\n\n#### Sensor: %s\n\n", s))
pm_vec <- df |> filter(sensor == s) |>
arrange(datetime) |> pull(pm25_ug_m3)
par(mfrow = c(1, 2))
acf(pm_vec, lag.max = 48,
main = paste("ACF — PM2.5 |", s),
col = "#0072B2", lwd = 1.5)
pacf(pm_vec, lag.max = 48,
main = paste("PACF — PM2.5 |", s),
col = "#D55E00", lwd = 1.5)
par(mfrow = c(1, 1))
}
Reading the ACF/PACF:
OLS regression in Section 8.1 fitted each sensor independently, ignoring two structural problems:
Mixed effects models address both by treating sensor as a random effect (pooling information across sensors while acknowledging each is a distinct unit) and optionally adding a correlation structure on the residuals.
# Run once in the Console if not yet installed
install.packages(c("lme4", "nlme", "lmerTest"))
library(lme4)
library(lmerTest) # adds p-values to lmer() via Satterthwaite df
library(nlme) # needed for AR(1) correlation structure
Each sensor gets its own baseline PM2.5 level; predictors are fixed effects shared across all sensors.
\[\text{PM}_{2.5,it} = (\beta_0 + u_i) + \beta_1 T_{it} + \ldots + \beta_7 \text{NH}_{3,it} + \varepsilon_{it}, \quad u_i \sim \mathcal{N}(0, \sigma^2_u)\]
m_ri <- lmer(
pm25_ug_m3 ~ temperature_C + humidity_pct + pressure_hPa +
light_lux + oxidised_kO + reduced_kO + nh3_kO +
(1 | sensor),
data = df,
REML = TRUE
)
summary(m_ri)
## Linear mixed model fit by REML. t-tests use Satterthwaite's method [
## lmerModLmerTest]
## Formula:
## pm25_ug_m3 ~ temperature_C + humidity_pct + pressure_hPa + light_lux +
## oxidised_kO + reduced_kO + nh3_kO + (1 | sensor)
## Data: df
##
## REML criterion at convergence: 98234.3
##
## Scaled residuals:
## Min 1Q Median 3Q Max
## -9.5190 -0.5043 -0.1934 0.2916 15.6956
##
## Random effects:
## Groups Name Variance Std.Dev.
## sensor (Intercept) 9.276 3.046
## Residual 32.046 5.661
## Number of obs: 15559, groups: sensor, 10
##
## Fixed effects:
## Estimate Std. Error df t value Pr(>|t|)
## (Intercept) 2.614e+01 3.556e+00 1.508e+03 7.352 3.20e-13 ***
## temperature_C -1.286e-01 1.748e-02 1.555e+04 -7.356 1.99e-13 ***
## humidity_pct -4.880e-02 1.179e-02 1.555e+04 -4.138 3.52e-05 ***
## pressure_hPa -2.119e-02 3.202e-03 1.555e+04 -6.618 3.75e-11 ***
## light_lux -7.524e-06 6.132e-05 1.555e+04 -0.123 0.9023
## oxidised_kO -2.522e-03 1.773e-04 1.555e+04 -14.229 < 2e-16 ***
## reduced_kO 2.233e-03 8.692e-04 1.549e+04 2.569 0.0102 *
## nh3_kO 1.837e-02 6.923e-04 1.550e+04 26.527 < 2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Correlation of Fixed Effects:
## (Intr) tmpr_C hmdty_ prss_P lght_l oxds_O rdcd_O
## temperatr_C -0.483
## humidty_pct -0.589 0.868
## pressure_hP -0.937 0.304 0.443
## light_lux 0.003 0.000 -0.004 -0.005
## oxidised_kO -0.008 -0.175 -0.050 0.040 0.003
## reduced_kO 0.264 -0.049 -0.253 -0.319 0.014 -0.089
## nh3_kO -0.254 0.388 0.480 0.167 -0.001 -0.112 -0.520
broom.mixed::tidy(m_ri, effects = "fixed", conf.int = TRUE) |>
mutate(sig = case_when(p.value < 0.001 ~ "***",
p.value < 0.01 ~ "**",
p.value < 0.05 ~ "*",
TRUE ~ "")) |>
kable(digits = 4, format = "html",
caption = "Random-intercept model: fixed effects (sensor as random intercept)") |>
kable_styling(full_width = FALSE)
| effect | term | estimate | std.error | statistic | df | p.value | conf.low | conf.high | sig |
|---|---|---|---|---|---|---|---|---|---|
| fixed | (Intercept) | 26.1386 | 3.5555 | 7.3515 | 1507.556 | 0.0000 | 19.1643 | 33.1129 | *** |
| fixed | temperature_C | -0.1286 | 0.0175 | -7.3562 | 15547.811 | 0.0000 | -0.1628 | -0.0943 | *** |
| fixed | humidity_pct | -0.0488 | 0.0118 | -4.1382 | 15548.958 | 0.0000 | -0.0719 | -0.0257 | *** |
| fixed | pressure_hPa | -0.0212 | 0.0032 | -6.6183 | 15548.173 | 0.0000 | -0.0275 | -0.0149 | *** |
| fixed | light_lux | 0.0000 | 0.0001 | -0.1227 | 15546.959 | 0.9023 | -0.0001 | 0.0001 | |
| fixed | oxidised_kO | -0.0025 | 0.0002 | -14.2286 | 15550.000 | 0.0000 | -0.0029 | -0.0022 | *** |
| fixed | reduced_kO | 0.0022 | 0.0009 | 2.5691 | 15492.985 | 0.0102 | 0.0005 | 0.0039 |
|
| fixed | nh3_kO | 0.0184 | 0.0007 | 26.5273 | 15504.586 | 0.0000 | 0.0170 | 0.0197 | *** |
# Intraclass correlation: proportion of variance due to between-sensor differences
vc <- as.data.frame(VarCorr(m_ri))
icc <- vc$vcov[vc$grp == "sensor"] /
sum(vc$vcov)
cat(sprintf("ICC = %.3f — %.1f%% of PM2.5 variance is between-sensor\n",
icc, icc * 100))
## ICC = 0.224 — 22.4% of PM2.5 variance is between-sensor
Interpreting the ICC: An ICC of 0.30 means 30% of the total variance in PM2.5 is attributable to systematic differences between sensor locations, and 70% is within-sensor variation over time. A high ICC confirms that pseudoreplication was a real concern — treating sensors as independent would have badly overstated precision.
Allow each sensor to have its own humidity response (the predictor most often significant in Section 8.1):
m_rs <- lmer(
pm25_ug_m3 ~ temperature_C + humidity_pct + pressure_hPa +
light_lux + oxidised_kO + reduced_kO + nh3_kO +
(1 + humidity_pct | sensor),
data = df,
REML = TRUE,
control = lmerControl(optimizer = "bobyqa")
)
summary(m_rs)
## Linear mixed model fit by REML. t-tests use Satterthwaite's method [
## lmerModLmerTest]
## Formula:
## pm25_ug_m3 ~ temperature_C + humidity_pct + pressure_hPa + light_lux +
## oxidised_kO + reduced_kO + nh3_kO + (1 + humidity_pct | sensor)
## Data: df
## Control: lmerControl(optimizer = "bobyqa")
##
## REML criterion at convergence: 97224.6
##
## Scaled residuals:
## Min 1Q Median 3Q Max
## -8.2445 -0.4913 -0.1983 0.3147 16.2538
##
## Random effects:
## Groups Name Variance Std.Dev. Corr
## sensor (Intercept) 8.88640 2.9810
## humidity_pct 0.03657 0.1912 -0.70
## Residual 29.96047 5.4736
## Number of obs: 15559, groups: sensor, 10
##
## Fixed effects:
## Estimate Std. Error df t value Pr(>|t|)
## (Intercept) 7.964e+00 3.555e+00 1.512e+03 2.240 0.025217 *
## temperature_C -9.806e-02 1.733e-02 1.546e+04 -5.660 1.54e-08 ***
## humidity_pct 4.176e-02 6.176e-02 9.563e+00 0.676 0.514902
## pressure_hPa -7.238e-03 3.199e-03 1.547e+04 -2.263 0.023651 *
## light_lux 5.142e-07 5.931e-05 1.554e+04 0.009 0.993083
## oxidised_kO -2.201e-03 1.733e-04 1.551e+04 -12.703 < 2e-16 ***
## reduced_kO 3.301e-03 8.556e-04 1.485e+04 3.858 0.000115 ***
## nh3_kO 2.275e-02 7.095e-04 1.424e+04 32.069 < 2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Correlation of Fixed Effects:
## (Intr) tmpr_C hmdty_ prss_P lght_l oxds_O rdcd_O
## temperatr_C -0.465
## humidty_pct -0.306 0.163
## pressure_hP -0.939 0.286 0.095
## light_lux 0.004 -0.002 -0.002 -0.006
## oxidised_kO -0.011 -0.178 -0.008 0.042 0.005
## reduced_kO 0.238 -0.025 -0.039 -0.297 0.011 -0.101
## nh3_kO -0.239 0.362 0.090 0.150 0.002 -0.069 -0.491
# Compare random-intercept vs random-slope via likelihood ratio test
# (refit with ML for comparison)
m_ri_ml <- update(m_ri, REML = FALSE)
m_rs_ml <- update(m_rs, REML = FALSE)
anova(m_ri_ml, m_rs_ml) |>
as.data.frame() |>
kable(digits = 4, format = "html",
caption = "LRT: random intercept vs random intercept + slope") |>
kable_styling(full_width = FALSE)
| npar | AIC | BIC | logLik | -2*log(L) | Chisq | Df | Pr(>Chisq) | |
|---|---|---|---|---|---|---|---|---|
| m_ri_ml | 10 | 98172.56 | 98249.08 | -49076.28 | 98152.56 | NA | NA | NA |
| m_rs_ml | 12 | 97171.06 | 97262.89 | -48573.53 | 97147.06 | 1005.501 | 2 | 0 |
Model selection: If the LRT p-value is < 0.05, the random slope improves fit significantly — meaning sensors genuinely differ in their humidity–PM2.5 relationship, not just in their baseline levels.
lme4 cannot model residual autocorrelation directly. We
switch to nlme::lme() which supports a continuous-time
AR(1) correlation structure on the residuals, appropriate for
unevenly-spaced time series.
# nlme requires data sorted by group and time
df_nlme <- df |>
arrange(sensor, datetime) |>
mutate(time_num = as.numeric(datetime)) # seconds since epoch
m_ar1 <- lme(
pm25_ug_m3 ~ temperature_C + humidity_pct + pressure_hPa +
light_lux + oxidised_kO + reduced_kO + nh3_kO,
random = ~ 1 | sensor,
correlation = corCAR1(form = ~ time_num | sensor), # continuous AR(1)
data = df_nlme,
method = "ML"
)
summary(m_ar1)
## Linear mixed-effects model fit by maximum likelihood
## Data: df_nlme
## AIC BIC logLik
## 98174.56 98258.74 -49076.28
##
## Random effects:
## Formula: ~1 | sensor
## (Intercept) Residual
## StdDev: 2.887226 5.659679
##
## Correlation Structure: Continuous AR(1)
## Formula: ~time_num | sensor
## Parameter estimate(s):
## Phi
## 0.2
## Fixed effects: pm25_ug_m3 ~ temperature_C + humidity_pct + pressure_hPa + light_lux + oxidised_kO + reduced_kO + nh3_kO
## Value Std.Error DF t-value p-value
## (Intercept) 26.133132 3.542421 15542 7.377196 0.0000
## temperature_C -0.128587 0.017476 15542 -7.357976 0.0000
## humidity_pct -0.048800 0.011792 15542 -4.138326 0.0000
## pressure_hPa -0.021186 0.003202 15542 -6.615340 0.0000
## light_lux -0.000008 0.000061 15542 -0.125470 0.9002
## oxidised_kO -0.002522 0.000177 15542 -14.228192 0.0000
## reduced_kO 0.002227 0.000869 15542 2.561746 0.0104
## nh3_kO 0.018366 0.000692 15542 26.528493 0.0000
## Correlation:
## (Intr) tmpr_C hmdty_ prss_P lght_l oxds_O rdcd_O
## temperature_C -0.485
## humidity_pct -0.591 0.868
## pressure_hPa -0.941 0.304 0.443
## light_lux 0.003 0.000 -0.004 -0.005
## oxidised_kO -0.008 -0.175 -0.050 0.040 0.003
## reduced_kO 0.265 -0.049 -0.253 -0.319 0.014 -0.089
## nh3_kO -0.255 0.388 0.480 0.167 -0.001 -0.112 -0.520
##
## Standardized Within-Group Residuals:
## Min Q1 Med Q3 Max
## -9.5209525 -0.5044998 -0.1934267 0.2915889 15.6991402
##
## Number of Observations: 15559
## Number of Groups: 10
# Extract the estimated AR(1) parameter (phi)
phi <- coef(m_ar1$modelStruct$corStruct, unconstrained = FALSE)
cat(sprintf("Estimated AR(1) phi = %.3f\n", phi))
## Estimated AR(1) phi = 0.200
cat("(phi near 1 = strong persistence; near 0 = little autocorrelation)\n")
## (phi near 1 = strong persistence; near 0 = little autocorrelation)
intervals(m_ar1, which = "fixed")$fixed |>
as.data.frame() |>
rownames_to_column("term") |>
kable(digits = 4, format = "html",
caption = "AR(1) mixed model: fixed effects with 95% CI") |>
kable_styling(full_width = FALSE)
| term | lower | est. | upper |
|---|---|---|---|
| (Intercept) | 19.1914 | 26.1331 | 33.0749 |
| temperature_C | -0.1628 | -0.1286 | -0.0943 |
| humidity_pct | -0.0719 | -0.0488 | -0.0257 |
| pressure_hPa | -0.0275 | -0.0212 | -0.0149 |
| light_lux | -0.0001 | 0.0000 | 0.0001 |
| oxidised_kO | -0.0029 | -0.0025 | -0.0022 |
| reduced_kO | 0.0005 | 0.0022 | 0.0039 |
| nh3_kO | 0.0170 | 0.0184 | 0.0197 |
# AIC comparison across all three models
# (refit m_ri and m_rs with ML to compare fairly with m_ar1)
tibble(
Model = c("OLS (per sensor, no RE)",
"Random intercept (lmer)",
"Random intercept + slope (lmer)",
"Random intercept + AR(1) (nlme)"),
AIC = c(
mean(map_dbl(lm_list, ~ AIC(.x$model))), # mean per-sensor OLS AIC
AIC(m_ri_ml),
AIC(m_rs_ml),
AIC(m_ar1)
),
Notes = c(
"Ignores pseudoreplication and autocorrelation",
"Accounts for between-sensor variance",
"Also allows sensor-specific humidity slope",
"Accounts for temporal autocorrelation"
)
) |>
kable(digits = 1, format = "html", caption = "AIC comparison across model families") |>
kable_styling(full_width = FALSE)
| Model | AIC | Notes |
|---|---|---|
| OLS (per sensor, no RE) | 8748.2 | Ignores pseudoreplication and autocorrelation |
| Random intercept (lmer) | 98172.6 | Accounts for between-sensor variance |
| Random intercept + slope (lmer) | 97171.1 | Also allows sensor-specific humidity slope |
| Random intercept + AR(1) (nlme) | 98174.6 | Accounts for temporal autocorrelation |
Caution on inference: Even the AR(1) model assumes a single phi across all sensors and all time lags. For publication-quality analysis you would also want to: (a) check residual ACF after fitting AR(1) to confirm it absorbed the autocorrelation, (b) consider a random slope for the AR(1) parameter, and (c) evaluate whether a log-transformed response improves residual normality.
# Check whether the random-intercept model reduced autocorrelation
# relative to OLS (compare ACF of OLS residuals vs lmer residuals)
ols_resid <- resid(lm_list[[1]]$model)
lmer_resid <- resid(m_ri)[ df$sensor == levels(df$sensor)[1] ]
par(mfrow = c(1, 2))
acf(ols_resid, lag.max = 48, main = "ACF: OLS residuals (sensor 1)",
col = "#D55E00", lwd = 1.5)
acf(lmer_resid, lag.max = 48, main = "ACF: lmer residuals (sensor 1)",
col = "#0072B2", lwd = 1.5)
par(mfrow = c(1, 1))
What to look for: If the lmer ACF is meaningfully smaller than the OLS ACF at short lags, the random effect absorbed some temporal structure. If both ACFs are similarly high, the AR(1) model in nlme is the appropriate next step.
Using the regression results, we plot the two strongest predictors (by |t-statistic|) for each sensor.
top_preds <- map_dfr(lm_list, "tidy") |>
filter(term != "(Intercept)") |>
group_by(sensor) |>
slice_max(abs(statistic), n = 2) |>
ungroup()
# One figure per sensor — two side-by-side scatter plots
for (s in levels(df$sensor)) {
cat(sprintf("\n\n#### Sensor: %s\n\n", s))
d_top <- top_preds |> filter(sensor == s)
preds <- d_top$term
plots <- map(preds, function(pred) {
df |>
filter(sensor == s) |>
ggplot(aes(x = .data[[pred]], y = pm25_ug_m3)) +
geom_point(alpha = 0.08, size = 0.8, color = pal[[s]]) +
geom_smooth(method = "lm", color = "black",
linewidth = 0.8, se = TRUE) +
labs(title = pred,
subtitle = sprintf("t = %.2f, p = %.4f",
d_top$statistic[d_top$term == pred],
d_top$p.value[d_top$term == pred]),
x = pred, y = "PM2.5 (µg/m³)") +
theme(plot.title = element_text(size = 11),
plot.subtitle = element_text(size = 9, color = "grey40"))
})
print(wrap_plots(plots, ncol = 2))
}
# Combine key statistics into one final summary
df |>
group_by(sensor) |>
summarise(
Location = first(loc_label),
`N obs` = n(),
`Median PM2.5`= median(pm25_ug_m3),
`Mean PM2.5` = mean(pm25_ug_m3),
`Median PM1` = median(pm1_ug_m3),
`% EPA Good` = mean(pm25_ug_m3 <= 12) * 100,
.groups = "drop"
) |>
left_join(
map_dfr(lm_list, "glance") |> select(sensor, adj.r.squared),
by = "sensor"
) |>
rename(`Regression adj-R²` = adj.r.squared) |>
kable(digits = 2, caption = "Final cross-sensor summary") |>
kable_styling(full_width = TRUE)
| sensor | Location | N obs | Median PM2.5 | Mean PM2.5 | Median PM1 | % EPA Good | Regression adj-R² |
|---|---|---|---|---|---|---|---|
| PiZ12 | Mudd-Blaisdell Courtyard | 1954 | 6 | 8.15 | 4 | 88.54 | 0.39 |
| PiZ13 | Outside Harwood by the bicycle r… | 1721 | 5 | 7.97 | 4 | 87.51 | 0.42 |
| PiZ14 | PiZ14 | 325 | 2 | 2.06 | 1 | 100.00 | 0.30 |
| PiZ15 | Outside Seaver Theater, second f… | 1229 | 3 | 6.86 | 1 | 76.97 | 0.35 |
| PiZ2W1 | Outside 2F Oldenborg window faci… | 2006 | 5 | 5.84 | 3 | 93.27 | 0.34 |
| PiZ2W2 | Outside bathroom window, first f… | 1387 | 1 | 2.87 | 0 | 93.08 | 0.56 |
| PiZ2W3 | Outside ground floor, Mudd-Honno… | 1815 | 6 | 7.19 | 4 | 90.41 | 0.46 |
| PiZ2W5 | Pomona Organic Farm (near dome) | 1996 | 3 | 4.58 | 2 | 93.14 | 0.34 |
| PiZ8 | Outside Wig Lounge patio, side f… | 1857 | 6 | 7.40 | 4 | 91.98 | 0.36 |
| PiZ9 | PiZ9 | 1269 | 5 | 11.85 | 3 | 62.17 | 0.15 |
Return to hypotheses:
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] nlme_3.1-168 lmerTest_3.2-1 lme4_1.1-38 Matrix_1.7-4
## [5] kableExtra_1.4.0 knitr_1.51 broom_1.0.11 moments_0.14.1
## [9] ggridges_0.5.7 slider_0.3.3 scales_1.4.0 patchwork_1.3.2
## [13] lubridate_1.9.4 forcats_1.0.1 stringr_1.6.0 dplyr_1.1.4
## [17] purrr_1.2.1 readr_2.1.6 tidyr_1.3.2 tibble_3.3.1
## [21] ggplot2_4.0.1 tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] gtable_0.3.6 xfun_0.56 bslib_0.10.0
## [4] lattice_0.22-7 numDeriv_2016.8-1.1 tzdb_0.5.0
## [7] Rdpack_2.6.5 vctrs_0.7.1 tools_4.5.2
## [10] generics_0.1.4 parallel_4.5.2 pkgconfig_2.0.3
## [13] RColorBrewer_1.1-3 S7_0.2.1 lifecycle_1.0.5
## [16] compiler_4.5.2 farver_2.1.2 textshaping_1.0.4
## [19] codetools_0.2-20 htmltools_0.5.9 sass_0.4.10
## [22] yaml_2.3.12 furrr_0.3.1 nloptr_2.2.1
## [25] pillar_1.11.1 crayon_1.5.3 jquerylib_0.1.4
## [28] MASS_7.3-65 broom.mixed_0.2.9.7 cachem_1.1.0
## [31] reformulas_0.4.3.1 boot_1.3-32 parallelly_1.46.1
## [34] tidyselect_1.2.1 digest_0.6.39 future_1.69.0
## [37] stringi_1.8.7 listenv_0.10.0 labeling_0.4.3
## [40] splines_4.5.2 fastmap_1.2.0 grid_4.5.2
## [43] cli_3.6.5 magrittr_2.0.4 withr_3.0.2
## [46] backports_1.5.1 warp_0.2.3 bit64_4.6.0-1
## [49] timechange_0.3.0 rmarkdown_2.30 globals_0.18.0
## [52] bit_4.6.0 otel_0.2.0 hms_1.1.4
## [55] evaluate_1.0.5 rbibutils_2.4.1 viridisLite_0.4.2
## [58] mgcv_1.9-3 rlang_1.1.7 Rcpp_1.1.1
## [61] glue_1.8.0 xml2_1.5.2 minqa_1.2.8
## [64] svglite_2.2.2 rstudioapi_0.18.0 vroom_1.7.0
## [67] jsonlite_2.0.0 R6_2.6.1 systemfonts_1.3.1