Psychrometric Chart for HVAC — lets-plot

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 lets-plot

Python source (lets-plot)

""" anyplot.ai
psychrometric-basic: Psychrometric Chart for HVAC
Library: letsplot 4.10.1 | Python 3.13.13
Quality: 89/100 | Updated: 2026-06-16
"""

import os

import numpy as np
import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    arrow,
    element_blank,
    element_line,
    element_rect,
    element_text,
    geom_line,
    geom_path,
    geom_point,
    geom_polygon,
    geom_segment,
    geom_text,
    ggplot,
    ggsize,
    labs,
    scale_x_continuous,
    scale_y_continuous,
    theme,
    theme_minimal,
)
from lets_plot.export import ggsave


LetsPlot.setup_html()

# Theme-adaptive chrome (only data colors stay constant across themes)
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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
GRID = "#E3E0D6" if THEME == "light" else "#33332E"

# Imprint palette — one hue per psychrometric property family
SATURATION = "#009E73"  # brand green — saturation curve & RH family (★ first series)
WET_BULB = "#4467A3"  # blue
ENTHALPY = "#BD8233"  # ochre
VOLUME = "#C475FD"  # lavender
COMFORT = "#2ABCCD"  # cyan — comfort-zone region
PROCESS = "#AE3030"  # matte red — HVAC process path (focal point)

# Constants
P_ATM = 101325.0  # Pa, standard sea-level atmospheric pressure (101.325 kPa)


# Psychrometric equations (ASHRAE Fundamentals)
def saturation_pressure(t):
    t_k = t + 273.15
    if t >= 0:
        ln_ps = (
            -5.8002206e3 / t_k
            + 1.3914993
            - 4.8640239e-2 * t_k
            + 4.1764768e-5 * t_k**2
            - 1.4452093e-8 * t_k**3
            + 6.5459673 * np.log(t_k)
        )
    else:
        ln_ps = (
            -5.6745359e3 / t_k
            + 6.3925247
            - 9.677843e-3 * t_k
            + 6.2215701e-7 * t_k**2
            + 2.0747825e-9 * t_k**3
            - 9.484024e-13 * t_k**4
            + 4.1635019 * np.log(t_k)
        )
    return np.exp(ln_ps)


def humidity_ratio(t_db, rh):
    p_s = saturation_pressure(t_db)
    p_w = rh * p_s
    return 0.621945 * p_w / (P_ATM - p_w)


def wet_bulb_line(t_wb, t_db_range):
    w_sat = humidity_ratio(t_wb, 1.0)
    h_fg, cp_a, cp_w = 2501.0, 1.006, 1.86
    w_values = [
        max((h_fg * w_sat - cp_a * (t_db - t_wb)) / (h_fg + cp_w * t_db - cp_a * t_wb + (cp_w - cp_a) * t_wb), 0)
        for t_db in t_db_range
    ]
    return np.array(w_values)


# Data — relative-humidity curves (10% to 100%)
t_db_fine = np.linspace(-10, 50, 300)
rh_frames = []
for rh_val in np.round(np.arange(0.1, 1.05, 0.1), 1):
    w_vals, t_valid = [], []
    for t in t_db_fine:
        w = humidity_ratio(t, rh_val) * 1000  # g/kg
        if 0 <= w <= 30:
            w_vals.append(w)
            t_valid.append(t)
    rh_frames.append(pd.DataFrame({"t_db": t_valid, "w": w_vals, "group": f"rh{rh_val}"}))
df_rh = pd.concat(rh_frames, ignore_index=True)
# Saturation curve (100% RH) — the prominent upper boundary
df_sat = df_rh[df_rh["group"] == "rh1.0"]
df_rh_inner = df_rh[df_rh["group"] != "rh1.0"]

# Wet-bulb temperature lines
wb_temps = [0, 5, 10, 15, 20, 25, 30, 35]
wb_frames = []
for t_wb in wb_temps:
    t_range = np.linspace(t_wb, min(t_wb + 30, 50), 100)
    w_vals = wet_bulb_line(t_wb, t_range) * 1000
    mask = (w_vals >= 0) & (w_vals <= 30)
    wb_frames.append(pd.DataFrame({"t_db": t_range[mask], "w": w_vals[mask], "group": f"wb{t_wb}"}))
df_wb = pd.concat(wb_frames, ignore_index=True)

# Constant-enthalpy lines (kJ/kg)
enthalpy_values = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110]
enth_frames = []
for h_target in enthalpy_values:
    t_points, w_points = [], []
    for t in np.linspace(-10, 50, 200):
        w_gkg = (h_target - 1.006 * t) / (2501.0 + 1.86 * t) * 1000
        if 0 <= w_gkg <= 30:
            t_points.append(t)
            w_points.append(w_gkg)
    if len(t_points) > 1:
        enth_frames.append(pd.DataFrame({"t_db": t_points, "w": w_points, "group": f"h{h_target}"}))
df_enth = pd.concat(enth_frames, ignore_index=True)

# Constant specific-volume lines (m3/kg)
vol_values = [0.78, 0.80, 0.82, 0.84, 0.86, 0.88, 0.90, 0.92, 0.94]
vol_frames = []
for v_target in vol_values:
    t_points, w_points = [], []
    for t in np.linspace(-10, 50, 200):
        w_gkg = (v_target * (P_ATM / 1000) / (0.287042 * (t + 273.15)) - 1) / 1.6078 * 1000
        if 0 <= w_gkg <= 30:
            t_points.append(t)
            w_points.append(w_gkg)
    if len(t_points) > 1:
        vol_frames.append(pd.DataFrame({"t_db": t_points, "w": w_points, "group": f"v{v_target}"}))
df_vol = pd.concat(vol_frames, ignore_index=True)

# Comfort zone polygon (20-26 C, 30-60% RH)
df_comfort = pd.DataFrame(
    {
        "t_db": [20, 26, 26, 20],
        "w": [
            humidity_ratio(20, 0.3) * 1000,
            humidity_ratio(26, 0.3) * 1000,
            humidity_ratio(26, 0.6) * 1000,
            humidity_ratio(20, 0.6) * 1000,
        ],
    }
)

# HVAC process path: cooling + dehumidification (32 C / 50% RH -> 24 C / 50% RH)
s1_t, s2_t = 32, 24
df_process = pd.DataFrame(
    {"t_db": [s1_t, s2_t], "w": [humidity_ratio(s1_t, 0.50) * 1000, humidity_ratio(s2_t, 0.50) * 1000]}
)

# Direct labels (all property lines labelled on-chart, not in a legend)
# RH labels — staggered along their curves to avoid the crowded upper-left
rh_label_t = {0.1: 44, 0.2: 44, 0.3: 40, 0.4: 35, 0.5: 31, 0.6: 28, 0.7: 24, 0.8: 21, 0.9: 17, 1.0: 12}
rh_labels = []
for rh_val, t_label in rh_label_t.items():
    w_label = humidity_ratio(t_label, rh_val) * 1000
    if w_label <= 28:
        rh_labels.append({"t_db": t_label, "w": w_label, "label": f"{int(rh_val * 100)}%"})
df_rh_labels = pd.DataFrame(rh_labels)

# Wet-bulb labels — nudged down each diagonal (off the saturation curve) so they
# don't crowd the RH labels in the dense upper-left convergence zone
wb_label_offset = 7
df_wb_labels = pd.DataFrame(
    [
        {"t_db": t_wb + wb_label_offset, "w": w_lbl, "label": f"{t_wb}°C wb"}
        for t_wb in wb_temps
        if t_wb % 10 == 0
        for w_lbl in [wet_bulb_line(t_wb, [t_wb + wb_label_offset])[0] * 1000]
        if 0.5 < w_lbl <= 27
    ]
)

# Enthalpy labels — on the left edge
df_enth_labels = pd.DataFrame(
    [
        {"t_db": -8, "w": w_gkg, "label": f"{h} kJ/kg"}
        for h in enthalpy_values
        for w_gkg in [(h - 1.006 * (-5)) / (2501.0 + 1.86 * (-5)) * 1000]
        if 0 < w_gkg <= 28
    ]
)

# Specific-volume labels — on the right edge
df_vol_labels = pd.DataFrame(
    [
        {"t_db": 46, "w": w_gkg, "label": f"{v} m³/kg"}
        for v in vol_values
        for w_gkg in [(v * (P_ATM / 1000) / (0.287042 * (45 + 273.15)) - 1) / 1.6078 * 1000]
        if 0 < w_gkg <= 28
    ]
)

# Plot
plot = (
    ggplot()
    # Comfort zone region
    + geom_polygon(data=df_comfort, mapping=aes(x="t_db", y="w"), fill=COMFORT, alpha=0.16, color=COMFORT, size=1.0)
    # Specific-volume lines (secondary family)
    + geom_line(data=df_vol, mapping=aes(x="t_db", y="w", group="group"), color=VOLUME, size=0.6, alpha=0.7)
    # Enthalpy lines (secondary family)
    + geom_line(data=df_enth, mapping=aes(x="t_db", y="w", group="group"), color=ENTHALPY, size=0.6, alpha=0.7)
    # Wet-bulb lines
    + geom_line(data=df_wb, mapping=aes(x="t_db", y="w", group="group"), color=WET_BULB, size=0.7, alpha=0.7)
    # Inner RH curves (10-90%)
    + geom_line(data=df_rh_inner, mapping=aes(x="t_db", y="w", group="group"), color=INK_MUTED, size=0.7, alpha=0.7)
    # Saturation curve (100% RH) — prominent upper boundary
    + geom_line(data=df_sat, mapping=aes(x="t_db", y="w"), color=SATURATION, size=2.4)
    # HVAC process path with arrowhead (focal point)
    + geom_path(
        data=df_process,
        mapping=aes(x="t_db", y="w"),
        color=PROCESS,
        size=2.6,
        arrow=arrow(type="closed", length=14, angle=20),
    )
    + geom_point(data=df_process, mapping=aes(x="t_db", y="w"), color=PROCESS, size=5.5, shape=21, fill=PAGE_BG)
    # Direct labels
    + geom_text(data=df_rh_labels, mapping=aes(x="t_db", y="w", label="label"), size=4.6, color=INK_MUTED)
    + geom_text(data=df_wb_labels, mapping=aes(x="t_db", y="w", label="label"), size=4.2, color=WET_BULB)
    + geom_text(data=df_enth_labels, mapping=aes(x="t_db", y="w", label="label"), size=4.2, color=ENTHALPY, hjust=1)
    + geom_text(data=df_vol_labels, mapping=aes(x="t_db", y="w", label="label"), size=4.2, color=VOLUME, hjust=0)
    + geom_text(
        data=pd.DataFrame({"t_db": [23], "w": [7.6], "label": ["Comfort\nzone"]}),
        mapping=aes(x="t_db", y="w", label="label"),
        size=6.4,
        color=COMFORT,
        fontface="bold",
    )
    + geom_text(
        data=pd.DataFrame({"t_db": [40.5], "w": [16.5], "label": ["Cooling &\ndehumidification"]}),
        mapping=aes(x="t_db", y="w", label="label"),
        size=5.4,
        color=PROCESS,
        fontface="bold",
    )
    + geom_segment(
        data=pd.DataFrame({"x": [38.5], "xend": [33.0], "y": [16.0], "yend": [14.2]}),
        mapping=aes(x="x", y="y", xend="xend", yend="yend"),
        color=PROCESS,
        size=0.6,
        alpha=0.7,
    )
    + scale_x_continuous(limits=[-10, 51], breaks=list(range(-10, 55, 5)))
    + scale_y_continuous(limits=[0, 30], breaks=list(range(0, 35, 5)))
    + labs(
        x="Dry-bulb temperature (°C)",
        y="Humidity ratio (g/kg)",
        title="psychrometric-basic · python · letsplot · anyplot.ai",
    )
    + theme_minimal()
    + theme(
        plot_title=element_text(size=16, face="bold", color=INK),
        axis_title=element_text(size=12, color=INK),
        axis_text=element_text(size=10, color=INK_SOFT),
        legend_position="none",
        panel_grid_major=element_line(color=GRID, size=0.4),
        panel_grid_minor=element_blank(),
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
    )
    + ggsize(800, 450)
)

# Save (3200 × 1800 px at scale=4) + interactive HTML
ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, f"plot-{THEME}.html", path=".")

Part of Psychrometric Chart for HVAC on anyplot.ai.

Other implementations