Psychrometric comfort scatter

Timeseries data
Simulation
R
Occupied-hour scatter of operative temperature versus relative humidity, coloured by PMV comfort category, with an ASHRAE 55 adaptive comfort region overlay.
Code
library(tidyverse)
library(scales)

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

# ------------------------------------------------------------
# DATA REQUIREMENTS
# ------------------------------------------------------------
# Your data should be a dataframe with:
#   - living_oat:  numeric, zone operative temperature [°C]
#   - living_rh:   numeric, zone relative humidity [%]
#   - pmv:         numeric, Predicted Mean Vote (e.g. Pierce model)
#   - 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)
ep_synth <- tibble(
  doy      = rep(1:365, each = 24),
  hour_day = rep(0:23, 365)
) %>%
  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),
    living_oat  = 21 + 5.5 * annual + 2.5 * diurnal + rnorm(n(), 0, 0.6),
    living_rh   = 55 - 15 * annual - 3 * diurnal + rnorm(n(), 0, 2),
    run_avg_oat = 15 + 7 * annual,
    pmv         = (living_oat - 23) / 4 + rnorm(n(), 0, 0.2),
    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"))
  )

# ASHRAE 55 adaptive comfort polygon (one rectangle spanning all seasons)
ashrae_bands <- ep_synth %>%
  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
  )

temp_low_bound <- min(ashrae_bands$ashrae_lower)
temp_up_bound  <- max(ashrae_bands$ashrae_upper)

ashrae_poly <- tibble(
  x = c(temp_low_bound, temp_up_bound, temp_up_bound, temp_low_bound),
  y = c(40, 40, 70, 70)
)

# PMV comfort categories for occupied hours
psychro_data <- ep_synth %>%
  filter(occupied > 0) %>%
  mutate(
    pmv_cat = factor(case_when(
      pmv < -0.5 ~ "Cool discomfort",
      pmv >  0.5 ~ "Warm discomfort",
      TRUE        ~ "Comfortable"
    ), levels = names(comfort_colours))
  )

# ------------------------------------------------------------
# PLOT
# ------------------------------------------------------------
ggplot(psychro_data, aes(x = living_oat, y = living_rh, colour = pmv_cat)) +
  geom_point(alpha = 0.35, size = 0.8) +
  geom_polygon(
    data = ashrae_poly, aes(x = x, y = y),
    inherit.aes = FALSE, fill = "#4CAF7D", alpha = 0.08,
    colour = "#4CAF7D", linetype = "dashed", linewidth = 0.5
  ) +
  annotate("text",
           x = temp_low_bound + 0.5, y = 68,
           label = "ASHRAE 55 indicative region",
           size = 2.6, colour = "#4CAF7D", hjust = 0) +
  scale_colour_manual(values = comfort_colours, name = "Comfort category (PMV)") +
  guides(colour = guide_legend(override.aes = list(size = 3, alpha = 1))) +
  labs(
    title    = "Operative temperature vs relative humidity — Living zone",
    subtitle = "Each point = 1 occupied hour  |  Coloured by PMV comfort category",
    x = "Operative temperature (°C)",
    y = "Relative humidity (%)"
  ) +
  theme_ep()

Note

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