Operative temperature distribution by season

Timeseries data
Simulation
R
Violin-boxplot of operative temperature during occupied hours by season and thermal zone, with ASHRAE 55 adaptive comfort band overlay.
Code
library(tidyverse)
library(scales)

source(here::here("src", "R", "x_theme.R"))

# ------------------------------------------------------------
# DATA REQUIREMENTS
# ------------------------------------------------------------
# Your data should be a long-format dataframe with:
#   - zone:        character/factor, zone label (for faceting)
#   - op_temp:     numeric, operative temperature [°C]
#   - occupied:    integer, 0/1 occupancy indicator
#   - season:      factor, "Winter" / "Spring" / "Summer" / "Autumn"
#   - run_avg_oat: numeric, prevailing mean outdoor air temperature [°C]
# ------------------------------------------------------------

# SAMPLE DATA (replace with your own hourly timeseries)
set.seed(42)
make_zone <- function(base, amp_annual, amp_diurnal, sd_noise, label) {
  expand.grid(doy = 1:365, hour_day = 0:23) %>%
    as_tibble() %>%
    mutate(
      month_num   = as.integer(format(as.Date(doy - 1, origin = "2025-01-01"), "%m")),
      annual      = -cos(2 * pi * doy / 365),
      diurnal     = sin(pi * hour_day / 12 - pi / 6),
      op_temp     = base + amp_annual * annual + amp_diurnal * diurnal + rnorm(n(), 0, sd_noise),
      run_avg_oat = 15 + 7 * annual,
      occupied    = as.integer(hour_day >= 7 & hour_day <= 22),
      season      = factor(case_when(
        month_num %in% c(12, 1, 2) ~ "Winter",
        month_num %in% c(3, 4, 5)  ~ "Spring",
        month_num %in% c(6, 7, 8)  ~ "Summer",
        TRUE                        ~ "Autumn"
      ), levels = c("Winter", "Spring", "Summer", "Autumn")),
      zone = label
    )
}

op_temp_long <- bind_rows(
  make_zone(21, 5.5, 2.5, 0.6, "Living zone"),
  make_zone(20, 8.0, 3.5, 1.0, "Garage zone"),
  make_zone(22, 11.0, 5.0, 1.5, "Attic zone")
) %>%
  mutate(zone = factor(zone, levels = c("Living zone", "Garage zone", "Attic zone")))

op_occ <- op_temp_long %>% filter(occupied > 0)

# ASHRAE 55 adaptive comfort bands per season
ashrae_bands <- op_temp_long %>%
  group_by(season) %>%
  summarise(mean_pma = mean(run_avg_oat, na.rm = TRUE), .groups = "drop") %>%
  mutate(
    ashrae_upper = 0.31 * mean_pma + 21.3,
    ashrae_lower = 0.31 * mean_pma + 14.3,
    x    = as.integer(season),
    xmin = x - 0.45,
    xmax = x + 0.45
  )

# ------------------------------------------------------------
# PLOT
# ------------------------------------------------------------
ggplot(op_occ, aes(x = season, y = op_temp, fill = season)) +
  geom_rect(
    data = ashrae_bands,
    aes(xmin = xmin, xmax = xmax, ymin = ashrae_lower, ymax = ashrae_upper),
    inherit.aes = FALSE, alpha = 0.12, fill = "#4CAF7D"
  ) +
  geom_violin(alpha = 0.35, colour = NA, trim = FALSE) +
  geom_boxplot(width = 0.18, outlier.size = 0.4, alpha = 0.8) +
  annotate("text",
           x = 0.55, y = max(ashrae_bands$ashrae_upper) + 0.4,
           label = "ASHRAE 55 comfort band",
           size = 2.8, colour = "#4CAF7D", hjust = 0) +
  scale_y_continuous(
    name     = "Operative temperature (°C)",
    sec.axis = sec_axis(~ . * 9 / 5 + 32, name = "Operative temperature (°F)")
  ) +
  scale_fill_manual(values = c(
    Winter = "#3A85C6", Spring = "#4CAF7D",
    Summer = "#E05C4B", Autumn = "#F5A623"
  )) +
  facet_wrap(~zone, nrow = 1) +
  labs(
    title    = "Operative temperature distribution by season and zone",
    subtitle = "Occupied hours only  |  Shaded band = ASHRAE 55 80 % acceptability",
    x = NULL
  ) +
  theme_ep() +
  theme(legend.position = "none")

Note

Code: View R code on GitHub · Added by C. Nambiar, T. Kramer