Urban Canopy and Temperature

Marc Los Huertos

EA030 · Compiled 2026-10-24

Background P1 · Descriptive P1 · Visualization P1 · Statistics P2 · Descriptive P2 · Visualization P2 · Statistics Synthesis

Study overview. This analysis examines how urban tree canopy cover affects heat exposure across five urban land-use settings and three socioeconomic groups in the Inland Empire. Two complementary instrument types were deployed: Part I uses 24-hour infield temperature loggers (continuous air temperature) and Part II uses handheld IR thermometers (point-in-time surface temperature). Together they reveal both chronic heat burden and instantaneous thermal conditions.


Background and Research Context

Urban heat islands (UHIs) emerge when impervious surfaces, reduced vegetation, and waste heat from buildings and vehicles elevate temperatures above surrounding rural areas. Tree canopy provides one of the most cost-effective mitigation strategies — shade reduces incoming radiation while transpiration cools surfaces through latent heat flux.

Environmental justice concerns arise because canopy cover is unevenly distributed: wealthier neighborhoods historically receive more urban greening investment. If canopy reduces heat exposure and low-income communities have less canopy, those communities bear a disproportionate thermal burden.

# ── Set paths — update if your CSVs live elsewhere ───────────────────────────
log_path <- "/home/mwl04747/RTricks/A13_Heat_Islands/Data/infield_logger_canopy_urban.csv"
ir_path  <- "/home/mwl04747/RTricks/A13_Heat_Islands/Data/ir_spot_temperature_canopy_urban.csv"

# ── Part I: infield loggers ───────────────────────────────────────────────────
log_raw <- read_csv(log_path, show_col_types = FALSE) |>
  mutate(
    urban_setting    = factor(urban_setting,    levels = setting_lvl),
    demographic_group = factor(demographic_group, levels = income_lvl)
  )

# ── Part II: IR spot temperatures ────────────────────────────────────────────
ir_raw <- read_csv(ir_path, show_col_types = FALSE) |>
  mutate(
    urban_setting    = factor(urban_setting,    levels = setting_lvl),
    demographic_group = factor(demographic_group, levels = income_lvl),
    time_of_day = cut(measurement_hour,
                      breaks = c(8, 11, 14, 17),
                      labels = c("Morning (9–11)", "Midday (12–14)",
                                 "Afternoon (15–17)"),
                      include.lowest = TRUE)
  )

cat("Logger dataset:  ", nrow(log_raw), "rows ×", ncol(log_raw), "columns\n")
## Logger dataset:   180 rows × 6 columns
cat("IR spot dataset: ", nrow(ir_raw),  "rows ×", ncol(ir_raw),  "columns\n")
## IR spot dataset:  600 rows × 7 columns

Two research questions guide this analysis:

  1. Does mean 24-hour air temperature differ by urban setting, income group, and canopy cover? (Part I — loggers)
  2. Does instantaneous surface temperature vary by canopy cover, time of day, urban setting, and income group? (Part II — IR spot)

Part I — Dataset 1

Infield Temperature Loggers

Cold-chain food logistics loggers deployed for 24 hours · 180 observations · 5 settings × 3 income groups

1. Descriptive Statistics — Logger Data

1.1 Overall summary

log_raw |>
  summarise(
    N          = n(),
    Mean_temp  = round(mean(mean_air_temperature_c), 2),
    SD_temp    = round(sd(mean_air_temperature_c),   2),
    Median_temp = round(median(mean_air_temperature_c), 2),
    IQR_temp   = round(IQR(mean_air_temperature_c),  2),
    Min_temp   = round(min(mean_air_temperature_c),  2),
    Max_temp   = round(max(mean_air_temperature_c),  2),
    Skewness   = round(moments::skewness(mean_air_temperature_c), 3),
    Mean_canopy = round(mean(canopy_cover_percent), 1),
    SD_canopy   = round(sd(canopy_cover_percent),   1)
  ) |>
  kable(caption = "Overall descriptive statistics — logger air temperature and canopy cover") |>
  kable_styling(full_width = FALSE)
Overall descriptive statistics — logger air temperature and canopy cover
N Mean_temp SD_temp Median_temp IQR_temp Min_temp Max_temp Skewness Mean_canopy SD_canopy
180 34.1 1.57 34 2.02 29.8 38.5 0.005 46.3 14.3

1.2 By urban setting and income group

log_raw |>
  group_by(urban_setting, demographic_group) |>
  summarise(
    N    = n(),
    Mean = round(mean(mean_air_temperature_c), 2),
    SD   = round(sd(mean_air_temperature_c),   2),
    Med  = round(median(mean_air_temperature_c), 2),
    Canopy_mean = round(mean(canopy_cover_percent), 1),
    .groups = "drop"
  ) |>
  rename(`Urban setting` = urban_setting,
         `Income group`  = demographic_group,
         `Mean temp (°C)` = Mean, `SD` = SD, `Median` = Med,
         `Mean canopy (%)` = Canopy_mean) |>
  kable(caption = "Mean 24-hour air temperature (°C) by urban setting and income group") |>
  kable_styling(full_width = TRUE) |>
  column_spec(1, bold = TRUE)
Mean 24-hour air temperature (°C) by urban setting and income group
Urban setting Income group N Mean temp (°C) SD Median Mean canopy (%)
School Low-income 12 34.37 1.55 34.15 42.4
School Middle-income 12 33.75 1.70 33.15 47.6
School High-income 12 34.05 0.96 33.75 44.8
Park Low-income 12 33.81 1.12 33.90 50.2
Park Middle-income 12 35.00 1.81 34.15 43.5
Park High-income 12 33.19 1.83 33.85 48.0
Commercial Low-income 12 33.84 1.54 34.15 45.1
Commercial Middle-income 12 34.45 2.27 34.15 40.4
Commercial High-income 12 34.49 1.46 34.45 49.1
Parking Lot Low-income 12 33.78 1.38 33.65 52.7
Parking Lot Middle-income 12 34.48 1.61 34.85 51.4
Parking Lot High-income 12 34.39 1.43 34.65 42.6
Playground Low-income 12 33.87 1.48 34.20 50.6
Playground Middle-income 12 34.24 1.37 33.95 39.6
Playground High-income 12 33.80 1.61 33.55 46.5

1.3 Normality check

set.seed(42)
norm_tbl <- log_raw |>
  group_by(urban_setting) |>
  summarise(
    n  = n(),
    W  = round(shapiro.test(mean_air_temperature_c)$statistic, 4),
    p  = round(shapiro.test(mean_air_temperature_c)$p.value,   4),
    Skew = round(moments::skewness(mean_air_temperature_c), 3),
    .groups = "drop"
  ) |>
  mutate(Normal = ifelse(p > 0.05, "Yes", "No"))

kable(norm_tbl,
      caption = "Shapiro-Wilk normality test per urban setting (logger data)",
      col.names = c("Setting","N","W","p-value","Skewness","Normal?")) |>
  kable_styling(full_width = FALSE)
Shapiro-Wilk normality test per urban setting (logger data)
Setting N W p-value Skewness Normal?
School 36 0.9578 0.1843 0.239 Yes
Park 36 0.9666 0.3401 0.144 Yes
Commercial 36 0.9695 0.4132 0.310 Yes
Parking Lot 36 0.9567 0.1698 -0.664 Yes
Playground 36 0.9667 0.3427 -0.381 Yes

💡 Why normality matters here One-way ANOVA assumes approximately normal residuals within each group. With n = 36 per setting, the Central Limit Theorem provides reasonable robustness — but we will verify residual normality after fitting and use Kruskal-Wallis as a non-parametric check.


2. Visualization — Logger Data

2.1 Temperature distributions by urban setting

ggplot(log_raw, aes(x = urban_setting, y = mean_air_temperature_c,
                    fill = urban_setting)) +
  geom_boxplot(outlier.alpha = 0.4, outlier.size = 1.5, width = 0.55) +
  geom_jitter(aes(color = demographic_group), width = 0.12,
              alpha = 0.6, size = 2) +
  stat_summary(fun = mean, geom = "point",
               shape = 23, size = 3, fill = "white", color = "#1e3a2f") +
  scale_fill_manual(values  = setting_pal, guide = "none") +
  scale_color_manual(values = income_pal, name = "Income group") +
  labs(title    = "24-Hour Mean Air Temperature by Urban Setting",
       subtitle = "Points colored by income group · ◇ = group mean",
       x = NULL, y = "Mean air temperature (°C)") +
  theme(axis.text.x = element_text(angle = 20, hjust = 1))

2.2 Temperature by income group across settings

ggplot(log_raw, aes(x = demographic_group, y = mean_air_temperature_c,
                    fill = demographic_group)) +
  geom_boxplot(outlier.alpha = 0.35, width = 0.5) +
  stat_summary(fun = mean, geom = "point",
               shape = 23, size = 2.5, fill = "white") +
  facet_wrap(~ urban_setting, nrow = 1) +
  scale_fill_manual(values = income_pal, guide = "none") +
  labs(title    = "Air Temperature by Income Group within Each Urban Setting",
       subtitle = "◇ = mean",
       x = NULL, y = "Mean air temperature (°C)") +
  theme(axis.text.x = element_text(angle = 30, hjust = 1, size = 9))

2.3 Canopy cover vs air temperature

ggplot(log_raw, aes(x = canopy_cover_percent, y = mean_air_temperature_c,
                    color = demographic_group, shape = urban_setting)) +
  geom_point(alpha = 0.75, size = 2.5) +
  geom_smooth(aes(group = 1), method = "lm", se = TRUE,
              color = "#1e3a2f", linewidth = 1, linetype = "dashed") +
  scale_color_manual(values = income_pal, name = "Income group") +
  scale_shape_manual(values = c(16, 17, 15, 18, 8), name = "Urban setting") +
  labs(title    = "Canopy Cover vs Mean Air Temperature (24-hour loggers)",
       subtitle = "Dashed line = overall OLS trend",
       x = "Canopy cover (%)", y = "Mean air temperature (°C)") +
  guides(color = guide_legend(nrow = 1),
         shape = guide_legend(nrow = 1))

2.4 Canopy cover distribution by income group

ggplot(log_raw, aes(x = canopy_cover_percent, fill = demographic_group,
                    color = demographic_group)) +
  geom_density(alpha = 0.30, linewidth = 0.8) +
  scale_fill_manual(values  = income_pal, name = "Income group") +
  scale_color_manual(values = income_pal, name = "Income group") +
  labs(title    = "Canopy Cover Distribution by Income Group — Logger Sites",
       subtitle = "Are low-income sites systematically under-canopied?",
       x = "Canopy cover (%)", y = "Density")

📊 What to look for in these plots If environmental justice patterns are present, you expect: (a) low-income sites cluster at lower canopy cover and higher temperatures, (b) parking lots and commercial areas show the highest temperatures regardless of income group, and (c) the scatter plot shows a negative slope (more canopy → cooler air).


3. Statistical Analysis — Logger Data

3.1 One-way ANOVA: does temperature differ by urban setting?

H₀: Mean 24-hour air temperature is equal across all five urban settings. H₁: At least one setting has a different mean temperature.

aov_setting <- aov(mean_air_temperature_c ~ urban_setting, data = log_raw)
summary(aov_setting)
##                Df Sum Sq Mean Sq F value Pr(>F)
## urban_setting   4    2.5  0.6173   0.247  0.911
## Residuals     175  436.6  2.4951
tidy(aov_setting) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(caption = "ANOVA table: air temperature ~ urban setting") |>
  kable_styling(full_width = FALSE)
ANOVA table: air temperature ~ urban setting
term df sumsq meansq statistic p.value
urban_setting 4 2.4691 0.6173 0.2474 0.9109
Residuals 175 436.6408 2.4951 NA NA

3.2 Post-hoc comparisons (Tukey HSD)

em_setting <- emmeans(aov_setting, ~ urban_setting)
pairs_setting <- pairs(em_setting, adjust = "tukey")
summary(pairs_setting) |>
  as.data.frame() |>
  mutate(across(where(is.numeric), ~ round(.x, 4)),
         sig = case_when(p.value < 0.001 ~ "***",
                         p.value < 0.01  ~ "**",
                         p.value < 0.05  ~ "*",
                         TRUE            ~ "")) |>
  kable(caption = "Tukey HSD pairwise comparisons — urban setting") |>
  kable_styling(full_width = FALSE)
Tukey HSD pairwise comparisons — urban setting
contrast estimate SE df t.ratio p.value sig
School - Park 0.0556 0.3723 175 0.1492 0.9999
School - Commercial -0.2056 0.3723 175 -0.5521 0.9816
School - Parking Lot -0.1611 0.3723 175 -0.4327 0.9926
School - Playground 0.0861 0.3723 175 0.2313 0.9994
Park - Commercial -0.2611 0.3723 175 -0.7013 0.9560
Park - Parking Lot -0.2167 0.3723 175 -0.5819 0.9776
Park - Playground 0.0306 0.3723 175 0.0821 1.0000
Commercial - Parking Lot 0.0444 0.3723 175 0.1194 1.0000
Commercial - Playground 0.2917 0.3723 175 0.7834 0.9351
Parking Lot - Playground 0.2472 0.3723 175 0.6640 0.9638
as.data.frame(em_setting) |>
  ggplot(aes(x = reorder(urban_setting, emmean), y = emmean,
             ymin = lower.CL, ymax = upper.CL, color = urban_setting)) +
  geom_pointrange(size = 0.9, linewidth = 1) +
  scale_color_manual(values = setting_pal, guide = "none") +
  coord_flip() +
  labs(title    = "Estimated Marginal Means ± 95% CI",
       subtitle = "Urban setting effect on mean air temperature",
       x = NULL, y = "Estimated mean temperature (°C)")

3.3 Two-way ANOVA: setting × income group interaction

aov_2way <- aov(mean_air_temperature_c ~ urban_setting * demographic_group,
                data = log_raw)
tidy(aov_2way) |>
  mutate(across(where(is.numeric), ~ round(.x, 4)),
         sig = case_when(p.value < 0.001 ~ "***",
                         p.value < 0.01  ~ "**",
                         p.value < 0.05  ~ "*",
                         TRUE            ~ "")) |>
  kable(caption = "Two-way ANOVA: temperature ~ setting * income group") |>
  kable_styling(full_width = FALSE)
Two-way ANOVA: temperature ~ setting * income group
term df sumsq meansq statistic p.value sig
urban_setting 4 2.4691 0.6173 0.2508 0.9089
demographic_group 2 7.2768 3.6384 1.4782 0.2310
urban_setting:demographic_group 8 23.2466 2.9058 1.1806 0.3137
Residuals 165 406.1175 2.4613 NA NA

3.4 ANOVA assumptions

par(mfrow = c(2, 2), mar = c(4, 4, 3, 1))
plot(aov_2way)

par(mfrow = c(1, 1))
leveneTest(mean_air_temperature_c ~ urban_setting * demographic_group,
           data = log_raw) |>
  tidy() |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(caption = "Levene's test for homogeneity of variance") |>
  kable_styling(full_width = FALSE)
Levene’s test for homogeneity of variance
statistic p.value df df.residual
0.8432 0.6215 14 165

3.5 Simple linear regression: canopy cover predicts temperature

lm_canopy <- lm(mean_air_temperature_c ~ canopy_cover_percent, data = log_raw)

tidy(lm_canopy) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(caption = "OLS regression: air temperature ~ canopy cover (%)") |>
  kable_styling(full_width = FALSE)
OLS regression: air temperature ~ canopy cover (%)
term estimate std.error statistic p.value
(Intercept) 36.9139 0.3313 111.4096 0
canopy_cover_percent -0.0608 0.0068 -8.8839 0
glance(lm_canopy) |>
  select(r.squared, adj.r.squared, statistic, p.value, nobs) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(caption = "Model fit statistics") |>
  kable_styling(full_width = FALSE)
Model fit statistics
r.squared adj.r.squared statistic p.value nobs
0.3072 0.3033 78.9228 0 180
ggplot(log_raw, aes(x = canopy_cover_percent, y = mean_air_temperature_c)) +
  geom_point(aes(color = urban_setting, shape = demographic_group),
             alpha = 0.7, size = 2.5) +
  geom_smooth(method = "lm", se = TRUE,
              color = "#1e3a2f", linewidth = 1) +
  scale_color_manual(values = setting_pal, name = "Setting") +
  scale_shape_manual(values = c(16, 17, 8), name = "Income") +
  annotate("text",
           x = max(log_raw$canopy_cover_percent) * 0.85,
           y = max(log_raw$mean_air_temperature_c) * 0.99,
           label = paste0("R² = ", round(glance(lm_canopy)$r.squared, 3)),
           color = "#1e3a2f", fontface = "bold", size = 4) +
  labs(title    = "OLS Regression: Canopy Cover Predicts Air Temperature",
       x = "Canopy cover (%)", y = "Mean air temperature (°C)")

3.6 Multiple regression: canopy + setting + income group

lm_multi <- lm(mean_air_temperature_c ~
                 canopy_cover_percent + urban_setting + demographic_group,
               data = log_raw)

tidy(lm_multi) |>
  mutate(
    sig = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
                    p.value < 0.05  ~ "*",   TRUE ~ ""),
    across(where(is.numeric), ~ round(.x, 4))
  ) |>
  kable(caption = "Multiple regression: temperature ~ canopy + setting + income") |>
  kable_styling(full_width = FALSE)
Multiple regression: temperature ~ canopy + setting + income
term estimate std.error statistic p.value sig
(Intercept) 36.7431 0.4160 88.3211 0.0000 ***
canopy_cover_percent -0.0610 0.0070 -8.7689 0.0000 ***
urban_settingPark 0.0852 0.3100 0.2748 0.7838
urban_settingCommercial 0.2022 0.3096 0.6530 0.5146
urban_settingParking Lot 0.4023 0.3108 1.2943 0.1973
urban_settingPlayground -0.0458 0.3096 -0.1479 0.8826
demographic_groupMiddle-income 0.2248 0.2412 0.9322 0.3525
demographic_groupHigh-income -0.0708 0.2402 -0.2947 0.7686
glance(lm_multi) |>
  select(r.squared, adj.r.squared, AIC, BIC, nobs) |>
  mutate(across(where(is.numeric), ~ round(.x, 3))) |>
  kable(caption = "Multiple regression model fit") |>
  kable_styling(full_width = FALSE)
Multiple regression model fit
r.squared adj.r.squared AIC BIC nobs
0.324 0.297 618.785 647.521 180

3.7 Non-parametric check: Kruskal-Wallis test

kw_setting <- kruskal.test(mean_air_temperature_c ~ urban_setting, data = log_raw)
kw_income  <- kruskal.test(mean_air_temperature_c ~ demographic_group, data = log_raw)

tibble(
  Test      = c("Urban setting", "Income group"),
  Chi_sq    = round(c(kw_setting$statistic, kw_income$statistic), 3),
  df        = c(kw_setting$parameter, kw_income$parameter),
  p_value   = round(c(kw_setting$p.value, kw_income$p.value), 4),
  Sig       = ifelse(c(kw_setting$p.value, kw_income$p.value) < 0.05, "✓", "—")
) |>
  kable(caption = "Kruskal-Wallis tests (non-parametric alternative to ANOVA)") |>
  kable_styling(full_width = FALSE)
Kruskal-Wallis tests (non-parametric alternative to ANOVA)
Test Chi_sq df p_value Sig
Urban setting 1.132 4 0.8892 —
Income group 1.856 2 0.3953 —

📊 Interpreting the logger results A significant ANOVA F-test tells you that at least one setting has a different mean temperature. The Tukey HSD post-hoc narrows down which pairs differ. The regression slope on canopy cover quantifies how much each additional percent of canopy is associated with cooler (or warmer) air. If the income group coefficient is significant in the multiple regression, it suggests that income group predicts temperature even after accounting for canopy and setting — consistent with an environmental justice pattern.


Part II — Dataset 2

IR Spot Surface Temperature

Handheld IR thermometer · 600 observations · 5 settings × 3 income groups × 40 measurements · Diurnal variation (9 AM – 5 PM)

4. Descriptive Statistics — IR Spot Data

4.1 Overall summary

ir_raw |>
  summarise(
    N            = n(),
    Mean_surf    = round(mean(ir_surface_temperature_c),   2),
    SD_surf      = round(sd(ir_surface_temperature_c),     2),
    Median_surf  = round(median(ir_surface_temperature_c), 2),
    IQR_surf     = round(IQR(ir_surface_temperature_c),    2),
    Min_surf     = round(min(ir_surface_temperature_c),    2),
    Max_surf     = round(max(ir_surface_temperature_c),    2),
    Skewness     = round(moments::skewness(ir_surface_temperature_c), 3),
    Mean_canopy  = round(mean(canopy_cover_percent), 1),
    Hour_range   = paste(min(measurement_hour), "–", max(measurement_hour))
  ) |>
  kable(caption = "Overall descriptive statistics — IR surface temperature") |>
  kable_styling(full_width = FALSE)
Overall descriptive statistics — IR surface temperature
N Mean_surf SD_surf Median_surf IQR_surf Min_surf Max_surf Skewness Mean_canopy Hour_range
600 42.09 5.26 42.15 6.93 22.7 55.3 -0.135 40.7 9 – 17

4.2 By urban setting and income group

ir_raw |>
  group_by(urban_setting, demographic_group) |>
  summarise(
    N    = n(),
    Mean = round(mean(ir_surface_temperature_c),   2),
    SD   = round(sd(ir_surface_temperature_c),     2),
    Med  = round(median(ir_surface_temperature_c), 2),
    Max  = round(max(ir_surface_temperature_c),    2),
    .groups = "drop"
  ) |>
  rename(`Urban setting` = urban_setting, `Income group` = demographic_group,
         `Mean (°C)` = Mean, `Max (°C)` = Max) |>
  kable(caption = "IR surface temperature summary by urban setting and income group") |>
  kable_styling(full_width = TRUE) |>
  column_spec(1, bold = TRUE)
IR surface temperature summary by urban setting and income group
Urban setting Income group N Mean (°C) SD Med Max (°C)
School Low-income 40 41.47 5.44 41.00 53.7
School Middle-income 40 41.42 7.06 42.10 52.9
School High-income 40 41.45 5.35 42.45 54.1
Park Low-income 40 41.52 5.27 41.35 52.3
Park Middle-income 40 42.97 4.18 43.45 49.7
Park High-income 40 41.88 4.49 41.60 51.2
Commercial Low-income 40 43.16 4.85 42.40 50.9
Commercial Middle-income 40 42.65 4.01 42.55 50.6
Commercial High-income 40 41.59 5.32 41.00 51.6
Parking Lot Low-income 40 42.79 5.53 43.40 53.8
Parking Lot Middle-income 40 42.18 6.07 42.70 54.8
Parking Lot High-income 40 42.34 5.32 41.35 54.3
Playground Low-income 40 41.95 5.46 42.30 55.3
Playground Middle-income 40 41.17 5.66 41.20 54.0
Playground High-income 40 42.83 4.42 42.60 53.4

4.3 By time of day

ir_raw |>
  group_by(time_of_day, urban_setting) |>
  summarise(
    N    = n(),
    Mean = round(mean(ir_surface_temperature_c), 2),
    SD   = round(sd(ir_surface_temperature_c),   2),
    .groups = "drop"
  ) |>
  pivot_wider(names_from = time_of_day,
              values_from = c(Mean, SD),
              names_glue  = "{time_of_day}_{.value}") |>
  kable(caption = "Mean IR surface temperature (°C) by time of day and urban setting") |>
  kable_styling(full_width = TRUE)
Mean IR surface temperature (°C) by time of day and urban setting
urban_setting N Morning (9–11)_Mean Midday (12–14)_Mean Afternoon (15–17)_Mean Morning (9–11)_SD Midday (12–14)_SD Afternoon (15–17)_SD
School 42 42.12 41.11 NA 4.81 5.83 NA
Park 35 42.98 NA NA 5.15 NA NA
Commercial 42 42.37 NA NA 4.95 NA NA
Parking Lot 40 43.04 NA NA 6.04 NA NA
Playground 37 41.15 NA NA 5.12 NA NA
Park 51 NA 42.43 NA NA 4.45 NA
Commercial 37 NA 43.27 NA NA 5.01 NA
Parking Lot 35 NA 42.28 NA NA 5.69 NA
Playground 49 NA 42.02 NA NA 5.42 NA
School 36 NA NA 41.05 NA NA 7.26
Park 34 NA NA 40.79 NA NA 4.31
Commercial 41 NA NA 41.85 NA NA 4.35
Parking Lot 45 NA NA 42.03 NA NA 5.22
Playground 34 NA NA 42.83 NA NA 5.02

4.4 Normality check

ir_raw |>
  group_by(urban_setting) |>
  summarise(
    n    = n(),
    W    = round(shapiro.test(sample(ir_surface_temperature_c,
                                     min(n(), 5000)))$statistic, 4),
    p    = round(shapiro.test(sample(ir_surface_temperature_c,
                                     min(n(), 5000)))$p.value, 4),
    Skew = round(moments::skewness(ir_surface_temperature_c), 3),
    .groups = "drop"
  ) |>
  mutate(Normal = ifelse(p > 0.05, "Yes", "No")) |>
  kable(caption = "Shapiro-Wilk normality test per urban setting (IR data)") |>
  kable_styling(full_width = FALSE)
Shapiro-Wilk normality test per urban setting (IR data)
urban_setting n W p Skew Normal
School 120 0.9917 0.6960 -0.176 Yes
Park 120 0.9889 0.4434 -0.155 Yes
Commercial 120 0.9808 0.0852 -0.285 Yes
Parking Lot 120 0.9860 0.2527 0.194 Yes
Playground 120 0.9857 0.2383 -0.230 Yes

5. Visualization — IR Spot Data

5.1 Surface temperature distributions

p_box2 <- ggplot(ir_raw,
                 aes(x = urban_setting, y = ir_surface_temperature_c,
                     fill = urban_setting)) +
  geom_boxplot(outlier.alpha = 0.2, outlier.size = 0.8, width = 0.55) +
  geom_jitter(aes(color = demographic_group), width = 0.1,
              alpha = 0.25, size = 1) +
  stat_summary(fun = mean, geom = "point",
               shape = 23, size = 2.5, fill = "white", color = "#1e3a2f") +
  scale_fill_manual(values  = setting_pal, guide = "none") +
  scale_color_manual(values = income_pal,  name  = "Income group") +
  labs(title = "IR Surface Temperature by Urban Setting",
       subtitle = "◇ = mean; points colored by income group",
       x = NULL, y = "Surface temperature (°C)") +
  theme(axis.text.x = element_text(angle = 20, hjust = 1))

p_dens2 <- ggplot(ir_raw,
                  aes(x = ir_surface_temperature_c, fill = urban_setting,
                      color = urban_setting)) +
  geom_density(alpha = 0.25, linewidth = 0.7) +
  geom_vline(xintercept = 35, linetype = "dashed",
             color = "firebrick", linewidth = 0.8) +
  scale_fill_manual(values  = setting_pal, name = "Setting") +
  scale_color_manual(values = setting_pal, name = "Setting") +
  labs(title    = "Density of Surface Temperatures",
       subtitle = "Dashed = 35°C heat stress threshold",
       x = "Surface temperature (°C)", y = "Density")

p_box2 + p_dens2

5.2 Diurnal pattern by urban setting

ir_raw |>
  group_by(urban_setting, measurement_hour) |>
  summarise(
    mean_temp = mean(ir_surface_temperature_c),
    se_temp   = sd(ir_surface_temperature_c) / sqrt(n()),
    .groups   = "drop"
  ) |>
  ggplot(aes(x = measurement_hour, y = mean_temp,
             color = urban_setting, fill = urban_setting)) +
  geom_ribbon(aes(ymin = mean_temp - se_temp, ymax = mean_temp + se_temp),
              alpha = 0.15, color = NA) +
  geom_line(linewidth = 1.1) +
  geom_point(size = 2.5) +
  scale_color_manual(values = setting_pal, name = "Urban setting") +
  scale_fill_manual(values  = setting_pal, guide = "none") +
  scale_x_continuous(breaks = 9:17,
                     labels = paste0(9:17, ":00")) +
  labs(title    = "Diurnal Profile of IR Surface Temperature",
       subtitle = "Mean ± 1 SE by hour of day",
       x = "Hour of day", y = "Surface temperature (°C)") +
  theme(axis.text.x = element_text(angle = 40, hjust = 1))

5.3 Diurnal pattern by income group

ir_raw |>
  group_by(demographic_group, measurement_hour) |>
  summarise(
    mean_temp = mean(ir_surface_temperature_c),
    se_temp   = sd(ir_surface_temperature_c) / sqrt(n()),
    .groups   = "drop"
  ) |>
  ggplot(aes(x = measurement_hour, y = mean_temp,
             color = demographic_group, fill = demographic_group)) +
  geom_ribbon(aes(ymin = mean_temp - se_temp, ymax = mean_temp + se_temp),
              alpha = 0.18, color = NA) +
  geom_line(linewidth = 1.1) +
  geom_point(size = 2.5) +
  scale_color_manual(values = income_pal, name = "Income group") +
  scale_fill_manual(values  = income_pal, guide = "none") +
  scale_x_continuous(breaks = 9:17, labels = paste0(9:17, ":00")) +
  labs(title    = "Diurnal Profile by Income Group",
       subtitle = "Do low-income sites stay hotter throughout the day?",
       x = "Hour of day", y = "Surface temperature (°C)") +
  theme(axis.text.x = element_text(angle = 40, hjust = 1))

5.4 Canopy cover vs surface temperature by time of day

ggplot(ir_raw,
       aes(x = canopy_cover_percent, y = ir_surface_temperature_c,
           color = demographic_group)) +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 1.1) +
  facet_wrap(~ time_of_day) +
  scale_color_manual(values = income_pal, name = "Income group") +
  labs(title    = "Canopy Cover vs Surface Temperature by Time of Day",
       subtitle = "Separate regression lines per income group",
       x = "Canopy cover (%)", y = "Surface temperature (°C)") +
  guides(color = guide_legend(nrow = 1))

5.5 Income group × urban setting heatmap

ir_raw |>
  group_by(urban_setting, demographic_group) |>
  summarise(mean_temp = mean(ir_surface_temperature_c), .groups = "drop") |>
  ggplot(aes(x = demographic_group, y = fct_rev(urban_setting),
             fill = mean_temp)) +
  geom_tile(color = "white", linewidth = 0.8) +
  geom_text(aes(label = round(mean_temp, 1)),
            color = "white", fontface = "bold", size = 4) +
  scale_fill_gradient2(low = "#2e86ab", mid = "#f6ae2d", high = "#f26419",
                       midpoint = 42, name = "°C") +
  labs(title = "Mean IR Surface Temperature (°C)",
       subtitle = "Urban setting × income group",
       x = NULL, y = NULL) +
  theme(legend.position = "right")


6. Statistical Analysis — IR Spot Data

6.1 One-way ANOVA: surface temperature by urban setting

aov2_setting <- aov(ir_surface_temperature_c ~ urban_setting, data = ir_raw)
tidy(aov2_setting) |>
  mutate(across(where(is.numeric), ~ round(.x, 4)),
         sig = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
                         p.value < 0.05 ~ "*", TRUE ~ "")) |>
  kable(caption = "ANOVA: IR surface temperature ~ urban setting") |>
  kable_styling(full_width = FALSE)
ANOVA: IR surface temperature ~ urban setting
term df sumsq meansq statistic p.value sig
urban_setting 4 83.1704 20.7926 0.75 0.5583
Residuals 595 16496.1428 27.7246 NA NA

6.2 One-way ANOVA: surface temperature by income group

aov2_income <- aov(ir_surface_temperature_c ~ demographic_group, data = ir_raw)
tidy(aov2_income) |>
  mutate(across(where(is.numeric), ~ round(.x, 4)),
         sig = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
                         p.value < 0.05 ~ "*", TRUE ~ "")) |>
  kable(caption = "ANOVA: IR surface temperature ~ income group") |>
  kable_styling(full_width = FALSE)
ANOVA: IR surface temperature ~ income group
term df sumsq meansq statistic p.value sig
demographic_group 2 2.6889 1.3445 0.0484 0.9527
Residuals 597 16576.6243 27.7665 NA NA

6.3 Tukey HSD post-hoc: urban setting

em2 <- emmeans(aov2_setting, ~ urban_setting)
pairs(em2, adjust = "tukey") |>
  as.data.frame() |>
  mutate(across(where(is.numeric), ~ round(.x, 4)),
         sig = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
                         p.value < 0.05 ~ "*", TRUE ~ "")) |>
  kable(caption = "Tukey HSD: urban setting pairwise comparisons (IR data)") |>
  kable_styling(full_width = FALSE)
Tukey HSD: urban setting pairwise comparisons (IR data)
contrast estimate SE df t.ratio p.value sig
School - Park -0.6808 0.6798 595 -1.0016 0.8546
School - Commercial -1.0225 0.6798 595 -1.5042 0.5601
School - Parking Lot -0.9942 0.6798 595 -1.4625 0.5873
School - Playground -0.5383 0.6798 595 -0.7919 0.9329
Park - Commercial -0.3417 0.6798 595 -0.5026 0.9871
Park - Parking Lot -0.3133 0.6798 595 -0.4609 0.9907
Park - Playground 0.1425 0.6798 595 0.2096 0.9996
Commercial - Parking Lot 0.0283 0.6798 595 0.0417 1.0000
Commercial - Playground 0.4842 0.6798 595 0.7123 0.9537
Parking Lot - Playground 0.4558 0.6798 595 0.6706 0.9627

6.4 Kruskal-Wallis: time of day effect

kw_tod <- kruskal.test(ir_surface_temperature_c ~ time_of_day, data = ir_raw)

tibble(
  Test    = "Time of day (Morning / Midday / Afternoon)",
  Chi_sq  = round(kw_tod$statistic, 3),
  df      = kw_tod$parameter,
  p_value = round(kw_tod$p.value,   4),
  Sig     = ifelse(kw_tod$p.value < 0.05, "✓", "—")
) |>
  kable(caption = "Kruskal-Wallis: does surface temperature vary by time of day?") |>
  kable_styling(full_width = FALSE)
Kruskal-Wallis: does surface temperature vary by time of day?
Test Chi_sq df p_value Sig
Time of day (Morning / Midday / Afternoon) 0.872 2 0.6465 —

6.5 Simple regression: canopy cover predicts surface temperature

lm2_canopy <- lm(ir_surface_temperature_c ~ canopy_cover_percent, data = ir_raw)

tidy(lm2_canopy) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(caption = "OLS regression: surface temperature ~ canopy cover (%)") |>
  kable_styling(full_width = FALSE)
OLS regression: surface temperature ~ canopy cover (%)
term estimate std.error statistic p.value
(Intercept) 46.4157 0.4913 94.4739 0
canopy_cover_percent -0.1061 0.0110 -9.6344 0
glance(lm2_canopy) |>
  select(r.squared, adj.r.squared, statistic, p.value, nobs) |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(caption = "Model fit (simple regression)") |>
  kable_styling(full_width = FALSE)
Model fit (simple regression)
r.squared adj.r.squared statistic p.value nobs
0.1344 0.1329 92.8213 0 600

6.6 Multiple regression: canopy + hour + setting + income

lm2_multi <- lm(ir_surface_temperature_c ~
                  canopy_cover_percent +
                  measurement_hour     +
                  urban_setting        +
                  demographic_group,
                data = ir_raw)

tidy(lm2_multi) |>
  mutate(
    sig = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
                    p.value < 0.05  ~ "*",   TRUE ~ ""),
    across(where(is.numeric), ~ round(.x, 4))
  ) |>
  kable(caption = "Multiple regression: surface temp ~ canopy + hour + setting + income") |>
  kable_styling(full_width = FALSE)
Multiple regression: surface temp ~ canopy + hour + setting + income
term estimate std.error statistic p.value sig
(Intercept) 46.9505 1.1991 39.1562 0.0000 ***
canopy_cover_percent -0.1060 0.0111 -9.5849 0.0000 ***
measurement_hour -0.0881 0.0780 -1.1291 0.2593
urban_settingPark 0.4759 0.6344 0.7501 0.4535
urban_settingCommercial 0.9032 0.6339 1.4247 0.1548
urban_settingParking Lot 1.0048 0.6339 1.5850 0.1135
urban_settingPlayground 0.3380 0.6341 0.5330 0.5943
demographic_groupMiddle-income 0.1390 0.4921 0.2826 0.7776
demographic_groupHigh-income 0.0277 0.4913 0.0563 0.9551
glance(lm2_multi) |>
  select(r.squared, adj.r.squared, AIC, BIC, nobs) |>
  mutate(across(where(is.numeric), ~ round(.x, 3))) |>
  kable(caption = "Multiple regression model fit (IR data)") |>
  kable_styling(full_width = FALSE)
Multiple regression model fit (IR data)
r.squared adj.r.squared AIC BIC nobs
0.141 0.129 3622.847 3666.816 600

6.7 Model comparison: does adding hour improve fit?

lm2_base   <- lm(ir_surface_temperature_c ~
                   canopy_cover_percent + urban_setting + demographic_group,
                 data = ir_raw)
lm2_withhour <- lm2_multi   # already includes measurement_hour

anova(lm2_base, lm2_withhour) |>
  tidy() |>
  mutate(across(where(is.numeric), ~ round(.x, 4))) |>
  kable(caption = "Likelihood ratio test: does measurement hour improve model fit?") |>
  kable_styling(full_width = FALSE)
Likelihood ratio test: does measurement hour improve model fit?
term df.residual rss df sumsq statistic p.value
ir_surface_temperature_c ~ canopy_cover_percent + urban_setting + demographic_group 592 14270.53 NA NA NA NA
ir_surface_temperature_c ~ canopy_cover_percent + measurement_hour + urban_setting + demographic_group 591 14239.82 1 30.716 1.2748 0.2593
tibble(
  Model   = c("Base (canopy + setting + income)",
              "Full (+ measurement hour)"),
  AIC     = round(c(AIC(lm2_base), AIC(lm2_multi)), 2),
  adj_R2  = round(c(summary(lm2_base)$adj.r.squared,
                    summary(lm2_multi)$adj.r.squared), 4)
) |>
  kable(caption = "AIC and adjusted R² comparison") |>
  kable_styling(full_width = FALSE)
AIC and adjusted R² comparison
Model AIC adj_R2
Base (canopy + setting + income) 3622.14 0.1291
Full (+ measurement hour) 3622.85 0.1295

6.8 Interaction: does canopy effect vary by income group?

lm2_interact <- lm(ir_surface_temperature_c ~
                     canopy_cover_percent * demographic_group +
                     measurement_hour + urban_setting,
                   data = ir_raw)

tidy(lm2_interact) |>
  filter(str_detect(term, "canopy|demographic|:")) |>
  mutate(
    sig = case_when(p.value < 0.001 ~ "***", p.value < 0.01 ~ "**",
                    p.value < 0.05  ~ "*",   TRUE ~ ""),
    across(where(is.numeric), ~ round(.x, 4))
  ) |>
  kable(caption = "Interaction model: canopy × income group coefficients (selected terms)") |>
  kable_styling(full_width = FALSE)
Interaction model: canopy × income group coefficients (selected terms)
term estimate std.error statistic p.value sig
canopy_cover_percent -0.1005 0.0199 -5.0441 0.0000 ***
demographic_groupMiddle-income 0.9503 1.2138 0.7829 0.4340
demographic_groupHigh-income -0.1478 1.2140 -0.1217 0.9031
canopy_cover_percent:demographic_groupMiddle-income -0.0199 0.0275 -0.7226 0.4702
canopy_cover_percent:demographic_groupHigh-income 0.0040 0.0275 0.1458 0.8841
newdata <- expand.grid(
  canopy_cover_percent = seq(0, 95, by = 5),
  demographic_group    = income_lvl,
  measurement_hour     = 13,          # midday
  urban_setting        = "Park"       # reference setting
) |>
  mutate(demographic_group = factor(demographic_group, levels = income_lvl),
         urban_setting     = factor(urban_setting,     levels = setting_lvl))

newdata$pred_temp <- predict(lm2_interact, newdata = newdata)

ggplot(newdata, aes(x = canopy_cover_percent, y = pred_temp,
                   color = demographic_group)) +
  geom_line(linewidth = 1.2) +
  scale_color_manual(values = income_pal, name = "Income group") +
  labs(title    = "Predicted Surface Temperature by Canopy Cover and Income Group",
       subtitle = "From interaction model · Midday · Park setting · 95% CI not shown",
       x = "Canopy cover (%)", y = "Predicted surface temperature (°C)")

6.9 Residual diagnostics

par(mfrow = c(2, 2), mar = c(4, 4, 3, 1))
plot(lm2_multi, main = "IR Multiple Regression Diagnostics")

par(mfrow = c(1, 1))

📊 Key interpretations — IR surface temperature The multiple regression coefficient on canopy_cover_percent gives the average surface temperature reduction (°C) per additional percent of canopy, holding hour, setting, and income constant. A significant interaction between canopy and income group would mean the canopy benefit is distributed unequally — e.g., the cooling effect per percent canopy differs between high- and low-income sites. Compare model AIC to confirm whether adding measurement hour materially improves fit.


Synthesis

# Side-by-side: income group means for both instruments
p_log_inc <- log_raw |>
  group_by(demographic_group) |>
  summarise(mean = mean(mean_air_temperature_c),
            se   = sd(mean_air_temperature_c) / sqrt(n()), .groups = "drop") |>
  ggplot(aes(x = demographic_group, y = mean, ymin = mean - se, ymax = mean + se,
             color = demographic_group)) +
  geom_pointrange(size = 1) +
  scale_color_manual(values = income_pal, guide = "none") +
  labs(title = "Logger: air temp by income", x = NULL, y = "Mean temp (°C)") +
  theme(axis.text.x = element_text(angle = 20, hjust = 1))

p_ir_inc <- ir_raw |>
  group_by(demographic_group) |>
  summarise(mean = mean(ir_surface_temperature_c),
            se   = sd(ir_surface_temperature_c) / sqrt(n()), .groups = "drop") |>
  ggplot(aes(x = demographic_group, y = mean, ymin = mean - se, ymax = mean + se,
             color = demographic_group)) +
  geom_pointrange(size = 1) +
  scale_color_manual(values = income_pal, guide = "none") +
  labs(title = "IR spot: surface temp by income", x = NULL, y = "Mean temp (°C)") +
  theme(axis.text.x = element_text(angle = 20, hjust = 1))

p_log_inc + p_ir_inc

# Canopy slopes comparison
p_slope_log <- ggplot(log_raw,
                      aes(x = canopy_cover_percent,
                          y = mean_air_temperature_c,
                          color = demographic_group)) +
  geom_point(alpha = 0.4, size = 1.5) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 1) +
  scale_color_manual(values = income_pal, name = "Income") +
  labs(title = "Logger: canopy vs air temp",
       x = "Canopy (%)", y = "Air temp (°C)")

p_slope_ir <- ggplot(ir_raw,
                     aes(x = canopy_cover_percent,
                         y = ir_surface_temperature_c,
                         color = demographic_group)) +
  geom_point(alpha = 0.25, size = 1.2) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 1) +
  scale_color_manual(values = income_pal, name = "Income") +
  labs(title = "IR: canopy vs surface temp",
       x = "Canopy (%)", y = "Surface temp (°C)")

p_slope_log + p_slope_ir +
  plot_layout(guides = "collect") &
  theme(legend.position = "bottom")

🔑 Key takeaways