Urban Air Quality Monitoring with Low-Cost Sensors

Marc Los Huertos

EA030 · Compiled 2026-04-09

Background Setup Clock Correction Data Loading Descriptive Stats Visualization Inference Advanced Conclusions


1 · Background and Research Goals

1.1 What Are We Measuring?

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

1.2 Sensor Network

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:

1.3 Research Questions

Primary questions:

  1. Do PM2.5 and PM1.0 concentrations differ significantly across campus locations?
  2. Is there a consistent diurnal (time-of-day) pattern in PM concentrations?
  3. Which co-measured variables (temperature, humidity, gas sensors) best predict PM2.5?

1.4 Hypotheses

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


2 · Setup

2.1 Packages

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

3 · The Clock-Correction Problem

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.

3.1 Correction Strategy

At deployment, each team member recorded:

  1. The actual wall-clock time (PST) when they powered the unit on.
  2. The time displayed on the Pi’s LCD at that same moment.

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.

3.2 Calibration Table

# 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")
Deployment metadata and clock offsets (reboot 1 only)
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.


4 · Data Loading and Cleaning

4.1 Reading the CSVs

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…

4.2 Quality Control

We apply three QC filters:

  1. Timestamp present — rows with no parseable datetime are removed.
  2. PM2.5 present — rows with NA in the target variable are removed.
  3. Minimum observations — sensors with fewer than 10 valid PM2.5 readings are excluded from analysis (e.g. a unit that only logged one row).
# 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))

4.3 Coverage Summary

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)
Data coverage per sensor after QC
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

5 · Descriptive Statistics

5.1 Summary Table

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))
PM2.5 descriptive statistics (µg/m³) with EPA AQI breakdown
EPA AQI Category (%)
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.

5.2 PM1.0 vs PM2.5 — Are They Telling the Same Story?

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)
PM1.0 vs PM2.5 summary — a ratio near 1 means nearly all fine particles are in the sub-1µm range (combustion dominated)
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

6 · Visualization

6.1 Time Series with Rolling Mean

# 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

6.2 PM1.0 and PM2.5 Together

# Disabled: same ggplot2/R 4.2.2 version conflict as timeseries chunk

6.3 Distributions

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.

6.4 Diurnal Pattern (Time-of-Day)

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.

6.5 Date × Hour Heatmap

# Disabled: scale_y_date affected by same ggplot2/R 4.2.2 version conflict

6.6 Correlation Matrix

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

Sensor: PiZ12

Sensor: PiZ13

Sensor: PiZ14

Sensor: PiZ15

Sensor: PiZ2W1

Sensor: PiZ2W2

Sensor: PiZ2W3

Sensor: PiZ2W5

Sensor: PiZ8

Sensor: PiZ9


7 · Basic Statistical Analysis

7.1 Are the Data Normally Distributed?

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)
Shapiro-Wilk normality test (sampled to ≤ 5000 obs)
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.

7.2 Do Sensors Differ? — Wilcoxon Rank-Sum Test

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)
Pairwise Wilcoxon tests for PM2.5; effect size r = rank-biserial correlation
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.

7.3 Does PM2.5 Vary by Time of Day? — Kruskal-Wallis Test

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)
Kruskal-Wallis test: does PM2.5 differ across time-of-day groups?
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))


8 · Advanced Analysis

8.1 What Predicts PM2.5? — Multiple Linear Regression

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)
</.001>
OLS regression coefficients (*** p<.001 p>
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)
Model fit statistics
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).

8.2 Residual Diagnostics

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

Sensor: PiZ12

Sensor: PiZ13

Sensor: PiZ14

Sensor: PiZ15

Sensor: PiZ2W1

Sensor: PiZ2W2

Sensor: PiZ2W3

Sensor: PiZ2W5

Sensor: PiZ8

Sensor: PiZ9

What to look for:

  • Residuals vs Fitted — should show no pattern (random scatter). A funnel shape means heteroscedasticity; a curve means a non-linear relationship.
  • Q-Q Plot — points should follow the diagonal. Heavy tails suggest non-normality of residuals (log-transforming PM2.5 often helps).
  • Scale-Location — similar to plot 1 but easier to see variance changes.
  • Cook’s Distance — points > 0.5 are influential and worth investigating.

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.

8.3 Log-Transformed Response

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)
Log-model R² per sensor
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

8.4 Temporal Autocorrelation — ACF / PACF

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

Sensor: PiZ12

Sensor: PiZ13

Sensor: PiZ14

Sensor: PiZ15

Sensor: PiZ2W1

Sensor: PiZ2W2

Sensor: PiZ2W3

Sensor: PiZ2W5

Sensor: PiZ8

Sensor: PiZ9

Reading the ACF/PACF:

  • ACF shows total correlation at each lag. A slowly decaying ACF indicates strong persistence (common in PM data).
  • PACF shows direct correlation at each lag, controlling for shorter lags. If the PACF cuts off sharply after lag p, that suggests an AR(p) process would be an appropriate time-series model.
  • Significant bars at lags 12 and 24 would suggest diurnal periodicity (if sampling is hourly) or 1-hour / 2-hour cycles (if sampling is every 5 min).

8.5 Mixed Effects Models

Why Mixed Effects?

OLS regression in Section 8.1 fitted each sensor independently, ignoring two structural problems:

  • Pseudoreplication: Each sensor contributes thousands of readings from a single physical location. Treating all rows as independent inflates the effective sample size and produces overconfident p-values.
  • Temporal autocorrelation: Readings minutes apart are not independent (confirmed by the ACF plots in 8.4). OLS standard errors assume independence and are therefore too small.

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

Model 1 — Random Intercept (sensor as random effect)

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)
Random-intercept model: fixed effects (sensor as random intercept)
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.

Model 2 — Random Intercept + Random Slope

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)
LRT: random intercept vs random intercept + slope
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.

Model 3 — nlme with AR(1) Residuals

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)
AR(1) mixed model: fixed effects with 95% CI
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

Model Comparison Summary

# 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)
AIC comparison across model families
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.

8.6 Strongest Relationships: PM2.5 vs Top Predictors

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

Sensor: PiZ12

Sensor: PiZ13

Sensor: PiZ14

Sensor: PiZ15

Sensor: PiZ2W1

Sensor: PiZ2W2

Sensor: PiZ2W3

Sensor: PiZ2W5

Sensor: PiZ8

Sensor: PiZ9


9 · Summary and Conclusions

# 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)
Final cross-sensor summary
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:


10 · Session Info

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