{"spec_id":"psychrometric-basic","library":"ggplot2","language":"r","code":"#' anyplot.ai\n#' psychrometric-basic: Psychrometric Chart for HVAC\n#' Library: ggplot2 3.5.1 | R 4.4.1\n#' Quality: 90/100 | Created: 2026-06-16\n\nlibrary(ggplot2)\nlibrary(dplyr)\nlibrary(ragg)\n\n# --- Theme tokens -----------------------------------------------------------\nTHEME       <- Sys.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG     <- if (THEME == \"light\") \"#FAF8F1\" else \"#1A1A17\"\nELEVATED_BG <- if (THEME == \"light\") \"#FFFDF6\" else \"#242420\"\nINK         <- if (THEME == \"light\") \"#1A1A17\" else \"#F0EFE8\"\nINK_SOFT    <- if (THEME == \"light\") \"#4A4A44\" else \"#B8B7B0\"\nINK_MUTED   <- if (THEME == \"light\") \"#6B6A63\" else \"#A8A79F\"\n\n# Imprint palette — one hue per property family, semantically chosen\nGREEN <- \"#009E73\"  # 1 — relative-humidity curves (saturation = hero series)\nCYAN  <- \"#2ABCCD\"  # 6 — wet-bulb temperature (cool / wet association)\nLAV   <- \"#C475FD\"  # 2 — constant-enthalpy lines\nBLUE  <- \"#4467A3\"  # 3 — constant specific-volume lines\nRED   <- \"#AE3030\"  # 5 — example HVAC process path (emphasis)\n\n# --- Psychrometric model (ASHRAE, sea-level 101.325 kPa) --------------------\nP_ATM <- 101.325                                          # kPa\np_ws  <- function(t) 0.61078 * exp(17.27 * t / (t + 237.3))   # sat. vapour pressure, kPa\nW_of  <- function(t, rh) {                                # humidity ratio, g/kg dry air\n  pw <- rh * p_ws(t)\n  1000 * 0.62198 * pw / (P_ATM - pw)\n}\nW_sat <- function(t) W_of(t, 1.0)                         # saturation curve, g/kg\n\nT_MIN <- -10; T_MAX <- 50\nW_MIN <- 0;   W_MAX <- 30\ntemps <- seq(T_MIN, T_MAX, by = 0.2)\n\n# keep only the physical region (at or below the saturation curve, inside the box)\nclip_region <- function(df) {\n  df |>\n    filter(w >= W_MIN, w <= W_MAX, t >= T_MIN, t <= T_MAX,\n           w <= W_sat(t) + 1e-6)\n}\n\n# --- Relative-humidity curves (10 % .. 100 %) -------------------------------\nrh_levels <- seq(10, 100, by = 10)\nrh_df <- bind_rows(lapply(rh_levels, function(rh) {\n  data.frame(rh = rh, t = temps, w = W_of(temps, rh / 100))\n})) |> clip_region()\n\nsat_df <- filter(rh_df, rh == 100)            # 100 % RH — saturation boundary\nrh_minor <- filter(rh_df, rh < 100)\n# stagger label offsets so converging high-RH curves near the top don't crowd\nrh_lab <- rh_minor |> group_by(rh) |> slice_max(t, n = 1) |> ungroup() |>\n  mutate(vj = ifelse(rh %% 20 == 0, -0.4, 1.3))\n\n# --- Constant wet-bulb temperature lines ------------------------------------\nwb_levels <- seq(0, 30, by = 5)\nwb_df <- bind_rows(lapply(wb_levels, function(twb) {\n  ws_wb <- W_of(twb, 1.0) / 1000                          # kg/kg at wet-bulb temp\n  tt <- seq(twb, T_MAX, by = 0.2)\n  w  <- ((2501 - 2.326 * twb) * ws_wb - 1.006 * (tt - twb)) /\n        (2501 + 1.86 * tt - 4.186 * twb)\n  data.frame(twb = twb, t = tt, w = w * 1000)\n})) |> clip_region()\nwb_lab <- wb_df |> group_by(twb) |> slice_min(t, n = 1) |> ungroup()\n\n# --- Constant-enthalpy lines (kJ/kg dry air) --------------------------------\nh_levels <- seq(20, 110, by = 15)\nh_df <- bind_rows(lapply(h_levels, function(h) {\n  w <- (h - 1.006 * temps) / (2501 + 1.86 * temps)        # kg/kg\n  data.frame(h = h, t = temps, w = w * 1000)\n})) |> clip_region()\nh_lab <- h_df |> group_by(h) |> slice_min(t, n = 1) |> ungroup()\n\n# --- Constant specific-volume lines (m3/kg dry air) -------------------------\nv_levels <- seq(0.78, 0.94, by = 0.02)\nv_df <- bind_rows(lapply(v_levels, function(v) {\n  w <- (v * P_ATM / (0.287042 * (temps + 273.15)) - 1) / 1.6078  # kg/kg\n  data.frame(v = v, t = temps, w = w * 1000)\n})) |> clip_region()\nv_lab <- v_df |> group_by(v) |> slice_max(t, n = 1) |> ungroup()\n\n# --- Thermal comfort zone (~20-26 C, 30-60 % RH) ----------------------------\ncomfort <- bind_rows(\n  data.frame(t = seq(20, 26, 0.25), rh = 30),\n  data.frame(t = 26, rh = seq(30, 60, 2)),\n  data.frame(t = seq(26, 20, -0.25), rh = 60),\n  data.frame(t = 20, rh = seq(60, 30, -2))\n) |> mutate(w = W_of(t, rh / 100))\n\n# --- Example HVAC process: cooling & dehumidification -----------------------\nstate_a <- data.frame(t = 30, w = W_of(30, 0.50))         # warm humid return air\nstate_b <- data.frame(t = 13, w = W_sat(13))              # cooled, near-saturated supply\nprocess <- bind_rows(cbind(state_a, lab = \"A\"), cbind(state_b, lab = \"B\"))\n\n# --- Plot -------------------------------------------------------------------\np <- ggplot() +\n  # comfort zone\n  geom_polygon(data = comfort, aes(t, w),\n               fill = INK_MUTED, alpha = 0.16, color = NA) +\n  # specific-volume lines (steep, dotted)\n  geom_line(data = v_df, aes(t, w, group = v),\n            color = BLUE, linewidth = 0.5, linetype = \"dotted\") +\n  # enthalpy lines (oblique, dashed)\n  geom_line(data = h_df, aes(t, w, group = h),\n            color = LAV, linewidth = 0.5, linetype = \"22\") +\n  # wet-bulb lines (diagonal, dashed)\n  geom_line(data = wb_df, aes(t, w, group = twb),\n            color = CYAN, linewidth = 0.5, linetype = \"longdash\") +\n  # relative-humidity curves\n  geom_line(data = rh_minor, aes(t, w, group = rh),\n            color = GREEN, linewidth = 0.55, alpha = 0.65) +\n  # saturation curve (100 % RH) — the hero boundary\n  geom_line(data = sat_df, aes(t, w),\n            color = GREEN, linewidth = 1.5) +\n  # comfort outline + label\n  geom_path(data = comfort, aes(t, w),\n            color = INK_SOFT, linewidth = 0.5, linetype = \"dashed\") +\n  annotate(\"text\", x = 23, y = W_of(23, 0.45), label = \"Comfort\\nzone\",\n           color = INK, size = 2.7, lineheight = 0.9, fontface = \"bold\") +\n  # process path A -> B\n  geom_segment(data = data.frame(x = state_a$t, y = state_a$w,\n                                 xe = state_b$t, ye = state_b$w),\n               aes(x = x, y = y, xend = xe, yend = ye),\n               color = RED, linewidth = 1.1,\n               arrow = arrow(length = unit(0.16, \"in\"), type = \"closed\")) +\n  geom_point(data = process, aes(t, w), color = RED, size = 2.6) +\n  geom_text(data = process, aes(t, w, label = lab),\n            color = RED, size = 3.1, fontface = \"bold\",\n            hjust = -0.5, vjust = -0.3) +\n  annotate(\"text\", x = 30.5, y = W_of(30, 0.50) + 1.2,\n           label = \"Cooling &\\ndehumidification\", color = RED,\n           size = 2.6, hjust = 0, lineheight = 0.9, fontface = \"bold\")\n\n# --- Direct line labels -----------------------------------------------------\np <- p +\n  geom_text(data = rh_lab, aes(t, w, label = paste0(rh, \"%\")),\n            color = GREEN, size = 2.5, hjust = 1.15, vjust = rh_lab$vj,\n            fontface = \"bold\") +\n  annotate(\"text\", x = 28.5, y = W_sat(28.5), label = \"Saturation (100% RH)\",\n           color = GREEN, size = 2.8, hjust = 1.05, vjust = -0.6,\n           fontface = \"bold\", angle = 52) +\n  geom_text(data = wb_lab, aes(t, w, label = twb),\n            color = CYAN, size = 2.7, hjust = 1.25, vjust = 1.7) +\n  geom_text(data = h_lab, aes(t, w, label = h),\n            color = LAV, size = 2.7, hjust = 1.3, vjust = -1.0) +\n  geom_text(data = v_lab, aes(t, w, label = sprintf(\"%.2f\", v)),\n            color = BLUE, size = 2.7, hjust = -0.1, vjust = 1.2) +\n  # family descriptors (direct, colour-keyed)\n  annotate(\"text\", x = -9, y = 28.3, label = \"Relative humidity\",\n           color = GREEN, size = 2.9, hjust = 0, fontface = \"bold\") +\n  annotate(\"text\", x = -9, y = 26.6, label = \"Wet-bulb temperature (°C)\",\n           color = CYAN, size = 2.9, hjust = 0, fontface = \"bold\") +\n  annotate(\"text\", x = -9, y = 24.9, label = \"Enthalpy (kJ/kg)\",\n           color = LAV, size = 2.9, hjust = 0, fontface = \"bold\") +\n  annotate(\"text\", x = -9, y = 23.2, label = \"Specific volume (m³/kg)\",\n           color = BLUE, size = 2.9, hjust = 0, fontface = \"bold\")\n\n# --- Scales, labels, theme --------------------------------------------------\np <- p +\n  scale_x_continuous(breaks = seq(-10, 50, 5),\n                     expand = expansion(mult = c(0.01, 0.04))) +\n  scale_y_continuous(position = \"right\", breaks = seq(0, 30, 5),\n                     expand = expansion(mult = c(0.0, 0.02))) +\n  coord_cartesian(xlim = c(T_MIN, T_MAX), ylim = c(W_MIN, W_MAX)) +\n  labs(\n    title = \"psychrometric-basic · r · ggplot2 · anyplot.ai\",\n    subtitle = \"Moist-air properties at sea level (101.325 kPa)\",\n    x = \"Dry-bulb temperature (°C)\",\n    y = \"Humidity ratio (g water / kg dry air)\"\n  ) +\n  theme_minimal(base_size = 8) +\n  theme(\n    plot.background  = element_rect(fill = PAGE_BG, color = PAGE_BG),\n    panel.background = element_rect(fill = PAGE_BG, color = NA),\n    panel.grid.major = element_line(color = INK, linewidth = 0.18),\n    panel.grid.minor = element_blank(),\n    panel.border     = element_rect(color = INK_SOFT, fill = NA, linewidth = 0.4),\n    axis.title       = element_text(color = INK, size = 10),\n    axis.title.x     = element_text(margin = margin(t = 4)),\n    axis.title.y.right = element_text(margin = margin(l = 6), angle = 90),\n    axis.text        = element_text(color = INK_SOFT, size = 8),\n    axis.ticks       = element_line(color = INK_SOFT, linewidth = 0.3),\n    plot.title       = element_text(color = INK, size = 12, face = \"bold\"),\n    plot.subtitle    = element_text(color = INK_SOFT, size = 9),\n    plot.margin      = margin(10, 12, 8, 12)\n  )\n\n# --- Save -------------------------------------------------------------------\nggsave(\n  filename = sprintf(\"plot-%s.png\", THEME),\n  plot     = p,\n  device   = ragg::agg_png,\n  width    = 8,\n  height   = 4.5,\n  units    = \"in\",\n  dpi      = 400\n)\n"}