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:
- Does mean 24-hour air temperature differ by urban setting, income
group, and canopy cover? (Part I — loggers)
- 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
-
Logger data (air temperature) captures the chronic
24-hour thermal burden. ANOVA tests whether urban settings differ in
mean air temperature; regression quantifies how much canopy cover
predicts that temperature.
-
IR spot data (surface temperature) captures
instantaneous peak heat exposure and diurnal dynamics. Surface
temperatures are substantially higher than air temperatures and are more
sensitive to canopy cover, time of day, and impervious surface type.
-
Environmental justice signal: if low-income sites
systematically have lower canopy cover and higher temperatures — and if
the income group coefficient remains significant after controlling for
canopy and setting — that is evidence of disproportionate heat burden.
-
Canopy effect: a negative coefficient on
canopy_cover_percent in both regressions confirms cooling.
Compare the coefficient magnitude between the two instruments and across
income groups to assess equity in the cooling benefit.
-
Interaction terms: a significant canopy × income
interaction means the cooling benefit per percent canopy differs by
income group — the most direct test of whether greening investments
would have equitable outcomes.