Psychrometric Chart for HVAC — Bokeh

A psychrometric chart plots dry-bulb temperature against humidity ratio, overlaid with curves for relative humidity, wet-bulb temperature, enthalpy, and specific volume. It is the fundamental tool for HVAC system design and air conditioning process analysis. The chart reveals the thermodynamic properties of moist air at a glance, enabling engineers to trace heating, cooling, humidification, and dehumidification processes as paths on the diagram.

Psychrometric Chart for HVAC rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
psychrometric-basic: Psychrometric Chart for HVAC
Library: bokeh 3.9.1 | Python 3.13.13
Quality: 92/100 | Updated: 2026-06-16
"""

import base64
import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import Arrow, Band, ColumnDataSource, Label, Title, VeeHead
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# Theme-adaptive chrome (see prompts/default-style-guide.md "Theme-adaptive Chrome")
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Imprint palette — each psychrometric property family gets one hue.
RH_COLOR = "#009E73"  # brand green — relative humidity (first series, always #009E73)
WB_COLOR = "#4467A3"  # blue — wet-bulb temperature (cool/temperature cue)
ENTH_COLOR = "#C475FD"  # lavender — enthalpy
SV_COLOR = "#BD8233"  # ochre — specific volume
COMFORT_COLOR = "#2ABCCD"  # cyan — comfort-zone region
PROCESS_COLOR = "#AE3030"  # matte red — highlighted HVAC process path

# Constants — standard sea-level atmosphere
P_ATM = 101325.0  # Pa
# ASHRAE saturation-pressure coefficients (over liquid water, t >= 0 °C)
C8, C9, C10, C11, C12, C13 = (-5.8002206e3, 1.3914993, -4.8640239e-2, 4.1764768e-5, -1.4452093e-8, 6.5459673)
# Over ice (t < 0 °C)
C1, C2, C3, C4, C5, C6, C7 = (
    -5.6745359e3,
    6.3925247,
    -9.677843e-3,
    6.2215701e-7,
    2.0747825e-9,
    -9.484024e-13,
    4.1635019,
)

# Data — saturation vapor pressure across the dry-bulb range
t_db = np.linspace(-10, 45, 400)
tk = t_db + 273.15
psat = np.where(
    tk >= 273.15,
    np.exp(C8 / tk + C9 + C10 * tk + C11 * tk**2 + C12 * tk**3 + C13 * np.log(tk)),
    np.exp(C1 / tk + C2 + C3 * tk + C4 * tk**2 + C5 * tk**3 + C6 * tk**4 + C7 * np.log(tk)),
)

# Plot
title = "psychrometric-basic · python · bokeh · anyplot.ai"
p = figure(
    width=3200,
    height=1800,
    title=title,
    x_axis_label="Dry-Bulb Temperature (°C)",
    y_axis_label="Humidity Ratio (g water / kg dry air)",
    toolbar_location=None,
    x_range=(-10, 45),
    y_range=(0, 30),
    min_border_bottom=160,
    min_border_left=180,
    min_border_top=130,
    min_border_right=70,
)

# --- Relative-humidity curves (10 % to 100 %) ---
rh_label_x = {10: 44, 20: 42, 30: 40, 40: 38, 50: 36, 60: 34, 70: 31, 80: 28, 90: 25, 100: 21}
for rh_pct in range(10, 101, 10):
    pw = (rh_pct / 100.0) * psat
    w = 0.621945 * pw / (P_ATM - pw) * 1000.0
    mask = (w >= 0) & (w <= 30)
    t_plot, w_plot = t_db[mask], w[mask]
    is_sat = rh_pct == 100
    source = ColumnDataSource(data={"t": t_plot, "w": w_plot})
    p.line(
        "t",
        "w",
        source=source,
        line_color=RH_COLOR,
        line_width=6.0 if is_sat else 2.6,
        line_alpha=1.0 if is_sat else 0.55,
    )
    target = rh_label_x[rh_pct]
    idx = int(np.argmin(np.abs(t_plot - target)))
    if w_plot[idx] > 28.5:
        idx = int(np.argmin(np.abs(w_plot - 27.5)))
    p.add_layout(
        Label(
            x=t_plot[idx],
            y=w_plot[idx],
            text=f"{rh_pct}%",
            text_font_size="26pt" if is_sat else "22pt",
            text_color=RH_COLOR,
            text_font_style="bold" if is_sat else "normal",
            x_offset=8,
            y_offset=-2,
        )
    )

# --- Wet-bulb temperature lines (diagonal, upper-left to lower-right) ---
for twb in [5, 10, 15, 20, 25, 30]:
    tk_wb = twb + 273.15
    ps_wb = np.exp(C8 / tk_wb + C9 + C10 * tk_wb + C11 * tk_wb**2 + C12 * tk_wb**3 + C13 * np.log(tk_wb))
    ws_wb = 0.621945 * ps_wb / (P_ATM - ps_wb) * 1000.0
    t_range = np.linspace(twb, 45, 200)
    w = ws_wb - 1.006 * (t_range - twb) / (2501.0 - 2.326 * twb) * 1000.0
    mask = (w >= 0) & (w <= 30)
    t_plot, w_plot = t_range[mask], w[mask]
    if t_plot.size < 2:
        continue
    source = ColumnDataSource(data={"t": t_plot, "w": w_plot})
    p.line("t", "w", source=source, line_color=WB_COLOR, line_width=2.2, line_alpha=0.7, line_dash="dashed")
    # Label at the saturation-curve end (spreads naturally along the boundary)
    p.add_layout(
        Label(
            x=t_plot[0],
            y=w_plot[0],
            text=f"{twb}°C wb",
            text_font_size="18pt",
            text_color=WB_COLOR,
            text_alpha=0.9,
            x_offset=-2,
            y_offset=12,
        )
    )

# --- Enthalpy lines (oblique, kJ/kg dry air) ---
for h in [20, 40, 60, 80]:
    t_range = np.linspace(-10, 45, 200)
    w = (h - 1.006 * t_range) / (2501.0 + 1.86 * t_range) * 1000.0
    mask = (w >= 0) & (w <= 30)
    t_plot, w_plot = t_range[mask], w[mask]
    if t_plot.size < 2:
        continue
    source = ColumnDataSource(data={"t": t_plot, "w": w_plot})
    p.line("t", "w", source=source, line_color=ENTH_COLOR, line_width=2.2, line_alpha=0.7, line_dash="dotted")
    # Label at the lower-right (low-humidity) end — keeps enthalpy text out of the
    # crowded saturation corner where wet-bulb labels already sit.
    p.add_layout(
        Label(
            x=t_plot[-1],
            y=w_plot[-1],
            text=f"{h} kJ/kg",
            text_font_size="18pt",
            text_color=ENTH_COLOR,
            text_alpha=0.95,
            x_offset=10,
            y_offset=6,
        )
    )

# --- Specific-volume lines (m³/kg dry air) ---
for v in [0.80, 0.84, 0.88, 0.92]:
    t_range = np.linspace(-10, 45, 200)
    w = (v * 101.325 / (0.287055 * (t_range + 273.15)) - 1.0) / 1.6078 * 1000.0
    mask = (w >= 0) & (w <= 30)
    t_plot, w_plot = t_range[mask], w[mask]
    if t_plot.size < 2:
        continue
    source = ColumnDataSource(data={"t": t_plot, "w": w_plot})
    p.line("t", "w", source=source, line_color=SV_COLOR, line_width=2.2, line_alpha=0.7, line_dash="dashdot")
    # Label at the high-humidity (top) end — the four lines fan out across the top.
    idx = int(np.argmax(w_plot))
    p.add_layout(
        Label(
            x=t_plot[idx],
            y=w_plot[idx],
            text=f"{v:.2f} m³/kg",
            text_font_size="17pt",
            text_color=SV_COLOR,
            text_alpha=0.95,
            x_offset=8,
            y_offset=-6,
        )
    )

# --- Comfort zone (20-26 °C, 30-60 % RH) as a filled Band ---
comfort_t = np.linspace(20, 26, 40)
comfort_tk = comfort_t + 273.15
comfort_psat = np.exp(
    C8 / comfort_tk + C9 + C10 * comfort_tk + C11 * comfort_tk**2 + C12 * comfort_tk**3 + C13 * np.log(comfort_tk)
)
pw_lo, pw_hi = 0.30 * comfort_psat, 0.60 * comfort_psat
w_lo = 0.621945 * pw_lo / (P_ATM - pw_lo) * 1000.0
w_hi = 0.621945 * pw_hi / (P_ATM - pw_hi) * 1000.0
comfort_source = ColumnDataSource(data={"t": comfort_t, "w_lo": w_lo, "w_hi": w_hi})
p.add_layout(
    Band(
        base="t",
        lower="w_lo",
        upper="w_hi",
        source=comfort_source,
        fill_color=COMFORT_COLOR,
        fill_alpha=0.18,
        line_color=COMFORT_COLOR,
        line_width=2.5,
        line_alpha=0.7,
    )
)
p.add_layout(
    Label(
        x=23,
        y=(w_lo[20] + w_hi[20]) / 2,
        text="Comfort Zone",
        text_font_size="22pt",
        text_color=COMFORT_COLOR,
        text_font_style="bold",
        text_align="center",
    )
)

# --- HVAC process path: cooling & dehumidification (state 1 -> state 2) ---
state1_t, state1_rh = 33, 0.55
state2_t, state2_rh = 14, 0.90
tk1 = state1_t + 273.15
ps1 = np.exp(C8 / tk1 + C9 + C10 * tk1 + C11 * tk1**2 + C12 * tk1**3 + C13 * np.log(tk1))
state1_w = 0.621945 * (state1_rh * ps1) / (P_ATM - state1_rh * ps1) * 1000.0
tk2 = state2_t + 273.15
ps2 = np.exp(C8 / tk2 + C9 + C10 * tk2 + C11 * tk2**2 + C12 * tk2**3 + C13 * np.log(tk2))
state2_w = 0.621945 * (state2_rh * ps2) / (P_ATM - state2_rh * ps2) * 1000.0

h1 = 1.006 * state1_t + (state1_w / 1000.0) * (2501.0 + 1.86 * state1_t)
h2 = 1.006 * state2_t + (state2_w / 1000.0) * (2501.0 + 1.86 * state2_t)
delta_h = h1 - h2

p.add_layout(
    Arrow(
        end=VeeHead(size=45, fill_color=PROCESS_COLOR, line_color=PROCESS_COLOR),
        x_start=state1_t,
        y_start=state1_w,
        x_end=state2_t,
        y_end=state2_w,
        line_color=PROCESS_COLOR,
        line_width=6.0,
    )
)
state_source = ColumnDataSource(data={"t": [state1_t, state2_t], "w": [state1_w, state2_w]})
p.scatter("t", "w", source=state_source, size=22, fill_color=PROCESS_COLOR, line_color=PAGE_BG, line_width=4)

p.add_layout(
    Label(
        x=state1_t,
        y=state1_w,
        text=f"Outdoor Air ({state1_t}°C, {int(state1_rh * 100)}% RH)",
        text_font_size="19pt",
        text_color=PROCESS_COLOR,
        text_font_style="bold",
        x_offset=14,
        y_offset=8,
    )
)
p.add_layout(
    Label(
        x=state2_t,
        y=state2_w,
        text=f"Supply Air ({state2_t}°C, {int(state2_rh * 100)}% RH)",
        text_font_size="19pt",
        text_color=PROCESS_COLOR,
        text_font_style="bold",
        x_offset=-380,
        y_offset=-6,
    )
)
p.add_layout(
    Label(
        x=(state1_t + state2_t) / 2,
        y=(state1_w + state2_w) / 2,
        text=f"Cooling & dehumidification  Δh = {delta_h:.1f} kJ/kg",
        text_font_size="18pt",
        text_color=PROCESS_COLOR,
        text_font_style="italic",
        x_offset=-90,
        y_offset=22,
        background_fill_color=ELEVATED_BG,
        background_fill_alpha=0.85,
    )
)

# --- Style (theme-adaptive chrome + native-pixel sizing) ---
p.title.text_font_size = "50pt"
p.title.text_color = INK
p.title.text_font_style = "bold"
p.title.offset = 6

p.add_layout(
    Title(
        text="Standard atmosphere (101.325 kPa) · ASHRAE psychrometric properties",
        text_font_size="26pt",
        text_color=INK_SOFT,
        text_font_style="italic",
    ),
    "above",
)

p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK
p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT
p.xaxis.axis_line_color = INK_SOFT
p.yaxis.axis_line_color = INK_SOFT
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = INK_SOFT
p.xaxis.minor_tick_line_color = None
p.yaxis.minor_tick_line_color = None

p.xgrid.grid_line_color = INK
p.ygrid.grid_line_color = INK
p.xgrid.grid_line_alpha = 0.12
p.ygrid.grid_line_alpha = 0.12

p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = INK_SOFT

# Save — HTML artifact + headless-Chrome screenshot at the exact canvas size
output_file(f"plot-{THEME}.html", title=title)
save(p)

W, H = 3200, 1800
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
# CDP screenshot with an explicit clip: `driver.save_screenshot` only captures the
# visible viewport, which is ~140px shorter than the window in headless Chrome and
# would crop the canvas to 3200x1657. `captureBeyondViewport` grabs the full clip.
shot = driver.execute_cdp_cmd(
    "Page.captureScreenshot",
    {"clip": {"x": 0, "y": 0, "width": W, "height": H, "scale": 1}, "captureBeyondViewport": True},
)
Path(f"plot-{THEME}.png").write_bytes(base64.b64decode(shot["data"]))
driver.quit()

Part of Psychrometric Chart for HVAC on anyplot.ai.

Other implementations