Psychrometric Chart for HVAC — Seaborn

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 Seaborn

Python source (Seaborn)

""" anyplot.ai
psychrometric-basic: Psychrometric Chart for HVAC
Library: seaborn 0.13.2 | Python 3.13.14
Quality: 90/100 | Updated: 2026-06-16
"""

import os

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib.lines import Line2D


# 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 — first family is brand green, then canonical order; line styles
# reinforce colour for colourblind safety.
RH_COLOR = "#009E73"  # brand green — relative-humidity curves (most prominent family)
WB_COLOR = "#4467A3"  # blue — wet-bulb isotherms
ENTH_COLOR = "#AE3030"  # matte red — constant enthalpy (energy)
SV_COLOR = "#BD8233"  # ochre — constant specific volume
COMFORT_COLOR = "#2ABCCD"  # cyan — comfort-zone region
PROCESS_COLOR = "#C475FD"  # lavender — HVAC process path

# Data — moist-air properties at standard sea-level pressure (101.325 kPa)
np.random.seed(42)
P_ATM = 101325  # Pa
T_GRID = np.linspace(-10, 50, 600)

# Saturation vapour pressure (Pa) and saturation humidity ratio (g/kg) over water
# (ASHRAE) — a single continuous relation across the whole range keeps the curves
# smooth (no kink at 0 °C).
P_SAT = np.exp(23.196 - 3816.44 / (T_GRID + 227.02))
W_SAT = 0.62198 * P_SAT / (P_ATM - P_SAT) * 1000

# Constant relative-humidity curves (10% – 100%)
rh_rows = []
for rh in np.arange(10, 101, 10):
    p_v = (rh / 100) * P_SAT
    w = 0.62198 * p_v / (P_ATM - p_v) * 1000
    keep = (w >= 0) & (w <= 30)
    for t, wv in zip(T_GRID[keep], w[keep], strict=True):
        rh_rows.append({"t": t, "w": wv, "rh": f"{rh}%"})
rh_df = pd.DataFrame(rh_rows)

# Constant wet-bulb isotherms (line slope ≈ -0.402 g/kg per °C)
wb_rows = []
for t_wb in np.arange(2, 31, 4):
    w0 = float(np.interp(t_wb, T_GRID, W_SAT))
    t_line = np.linspace(t_wb, 50, 160)
    w_line = w0 - 0.402 * (t_line - t_wb)
    cap = np.minimum(np.interp(t_line, T_GRID, W_SAT), 30)
    keep = (w_line >= 0) & (w_line <= cap)
    for t, wv in zip(t_line[keep], w_line[keep], strict=True):
        wb_rows.append({"t": t, "w": wv, "twb": f"{t_wb}"})
wb_df = pd.DataFrame(wb_rows)

# Constant enthalpy lines (kJ/kg dry air)
enth_rows = []
for h in np.arange(20, 101, 20):
    w_line = (h - 1.006 * T_GRID) / (2.501 + 0.00186 * T_GRID)
    keep = (w_line >= 0) & (w_line <= np.minimum(W_SAT, 30))
    for t, wv in zip(T_GRID[keep], w_line[keep], strict=True):
        enth_rows.append({"t": t, "w": wv, "h": f"{h}"})
enth_df = pd.DataFrame(enth_rows)

# Constant specific-volume lines (m³/kg dry air)
sv_rows = []
for v in np.arange(0.80, 0.93, 0.04):
    w_line = ((v * P_ATM / 1000) / (0.287042 * (T_GRID + 273.15)) - 1) / 1.6078 * 1000
    keep = (w_line >= 0) & (w_line <= np.minimum(W_SAT, 30))
    for t, wv in zip(T_GRID[keep], w_line[keep], strict=True):
        sv_rows.append({"t": t, "w": wv, "v": f"{v:.2f}"})
sv_df = pd.DataFrame(sv_rows)

# Comfort zone (≈20–26 °C, 30–60% RH) and a cooling + dehumidification process path
comfort_t = np.array([20, 26, 26, 20])
comfort_w = np.array(
    [
        0.62198 * (0.30 * np.interp(20, T_GRID, P_SAT)) / (P_ATM - 0.30 * np.interp(20, T_GRID, P_SAT)) * 1000,
        0.62198 * (0.30 * np.interp(26, T_GRID, P_SAT)) / (P_ATM - 0.30 * np.interp(26, T_GRID, P_SAT)) * 1000,
        0.62198 * (0.60 * np.interp(26, T_GRID, P_SAT)) / (P_ATM - 0.60 * np.interp(26, T_GRID, P_SAT)) * 1000,
        0.62198 * (0.60 * np.interp(20, T_GRID, P_SAT)) / (P_ATM - 0.60 * np.interp(20, T_GRID, P_SAT)) * 1000,
    ]
)

state_points = pd.DataFrame(
    {
        "t": [34.0, 13.0, 24.0],
        "w": [
            0.62198 * (0.45 * np.interp(34, T_GRID, P_SAT)) / (P_ATM - 0.45 * np.interp(34, T_GRID, P_SAT)) * 1000,
            float(np.interp(13, T_GRID, W_SAT)),
            0.62198 * (0.50 * np.interp(24, T_GRID, P_SAT)) / (P_ATM - 0.50 * np.interp(24, T_GRID, P_SAT)) * 1000,
        ],
        "label": ["A · supply 34°C/45%", "B · cooled 13°C/100%", "C · room 24°C/50%"],
    }
)

# Plot — 16:9 canvas (8 × 4.5 in @ dpi=400 → 3200 × 1800 px)
sns.set_theme(
    style="whitegrid",
    rc={
        "figure.facecolor": PAGE_BG,
        "axes.facecolor": PAGE_BG,
        "axes.edgecolor": INK_SOFT,
        "axes.labelcolor": INK,
        "text.color": INK,
        "xtick.color": INK_SOFT,
        "ytick.color": INK_SOFT,
        "grid.color": INK,
        "grid.alpha": 0.12,
        "grid.linewidth": 0.6,
        "font.family": "sans-serif",
    },
)
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400)

# Comfort zone sits behind the property lines
ax.fill(comfort_t, comfort_w, color=COMFORT_COLOR, alpha=0.16, zorder=1)
ax.plot(
    np.append(comfort_t, comfort_t[0]),
    np.append(comfort_w, comfort_w[0]),
    color=COMFORT_COLOR,
    linewidth=1.2,
    alpha=0.7,
    zorder=1,
)

# Property-line families, each drawn as one seaborn lineplot (units → one line per level)
sns.lineplot(
    data=sv_df,
    x="t",
    y="w",
    units="v",
    estimator=None,
    color=SV_COLOR,
    linewidth=0.8,
    linestyle=":",
    alpha=0.7,
    ax=ax,
    legend=False,
)
sns.lineplot(
    data=enth_df,
    x="t",
    y="w",
    units="h",
    estimator=None,
    color=ENTH_COLOR,
    linewidth=0.9,
    linestyle="-.",
    alpha=0.65,
    ax=ax,
    legend=False,
)
sns.lineplot(
    data=wb_df,
    x="t",
    y="w",
    units="twb",
    estimator=None,
    color=WB_COLOR,
    linewidth=0.9,
    linestyle="--",
    alpha=0.7,
    ax=ax,
    legend=False,
)
sns.lineplot(
    data=rh_df, x="t", y="w", units="rh", estimator=None, color=RH_COLOR, linewidth=1.2, alpha=0.85, ax=ax, legend=False
)

# Saturation curve (100% RH) — prominent upper boundary
sat = rh_df[rh_df["rh"] == "100%"]
ax.plot(sat["t"], sat["w"], color=RH_COLOR, linewidth=2.6, zorder=4)

# HVAC process path: cooling + dehumidification (A→B), then sensible reheat (B→C)
for i in range(2):
    ax.annotate(
        "",
        xy=(state_points["t"][i + 1], state_points["w"][i + 1]),
        xytext=(state_points["t"][i], state_points["w"][i]),
        arrowprops={"arrowstyle": "-|>", "color": PROCESS_COLOR, "lw": 2.6},
        zorder=5,
    )
sns.scatterplot(
    data=state_points,
    x="t",
    y="w",
    color=PROCESS_COLOR,
    s=130,
    edgecolor=PAGE_BG,
    linewidth=1.6,
    zorder=6,
    legend=False,
    ax=ax,
)

# Direct labels (spec: label property lines on the chart) — spread to edges to avoid crowding
for rh in rh_df["rh"].unique():
    seg = rh_df[rh_df["rh"] == rh]
    ax.text(
        float(seg["t"].iloc[-1]) + 0.3,
        float(seg["w"].iloc[-1]),
        rh,
        fontsize=7,
        color=RH_COLOR,
        ha="left",
        va="center",
        fontweight="bold",
    )
for twb in wb_df["twb"].unique():
    seg = wb_df[wb_df["twb"] == twb]
    ax.text(
        float(seg["t"].iloc[-1]) + 0.3,
        float(seg["w"].iloc[-1]) - 0.2,
        f"{twb}°",
        fontsize=6.5,
        color=WB_COLOR,
        ha="left",
        va="top",
    )
for h in enth_df["h"].unique():
    seg = enth_df[enth_df["h"] == h]
    ax.text(
        float(seg["t"].iloc[0]) - 0.3,
        float(seg["w"].iloc[0]) + 0.2,
        h,
        fontsize=6.5,
        color=ENTH_COLOR,
        ha="right",
        va="bottom",
        rotation=-38,
    )
for v in sv_df["v"].unique():
    seg = sv_df[sv_df["v"] == v]
    ax.text(
        float(seg["t"].iloc[0]) + 0.2,
        float(seg["w"].iloc[0]) - 0.2,
        v,
        fontsize=6.5,
        color=SV_COLOR,
        ha="left",
        va="top",
    )

ax.text(
    23,
    float(comfort_w.mean()),
    "Comfort\nzone",
    fontsize=8,
    color=COMFORT_COLOR,
    ha="center",
    va="center",
    fontweight="bold",
)
for _, row in state_points.iterrows():
    dx = -0.8 if row["label"].startswith("B") else 0.8
    ha = "right" if row["label"].startswith("B") else "left"
    ax.text(
        row["t"] + dx,
        row["w"] + 0.7,
        row["label"],
        fontsize=7.5,
        color=PROCESS_COLOR,
        ha=ha,
        va="bottom",
        fontweight="bold",
    )

# Legend — sits in the empty upper-left zone (cold + humid is physically unreachable)
legend_handles = [
    Line2D([0], [0], color=RH_COLOR, lw=2.2, label="Relative humidity"),
    Line2D([0], [0], color=WB_COLOR, lw=1.4, ls="--", label="Wet-bulb temp (°C)"),
    Line2D([0], [0], color=ENTH_COLOR, lw=1.4, ls="-.", label="Enthalpy (kJ/kg)"),
    Line2D([0], [0], color=SV_COLOR, lw=1.4, ls=":", label="Specific volume (m³/kg)"),
    Line2D(
        [0],
        [0],
        color=PROCESS_COLOR,
        lw=2.4,
        marker="o",
        markersize=6,
        markeredgecolor=PAGE_BG,
        label="HVAC process path",
    ),
]
legend = ax.legend(
    handles=legend_handles, loc="upper left", fontsize=8, framealpha=0.95, facecolor=ELEVATED_BG, edgecolor=INK_SOFT
)
legend.get_title().set_color(INK)
for text in legend.get_texts():
    text.set_color(INK_SOFT)

# Style
ax.set_xlim(-10, 50)
ax.set_ylim(0, 30)
ax.set_xlabel("Dry-bulb temperature (°C)", fontsize=11, color=INK)
ax.set_ylabel("Humidity ratio (g/kg dry air)", fontsize=11, color=INK)
ax.set_title("psychrometric-basic · python · seaborn · anyplot.ai", fontsize=13, fontweight="medium", color=INK)
ax.tick_params(axis="both", labelsize=9, colors=INK_SOFT)
sns.despine(ax=ax)

# Save
fig.subplots_adjust(left=0.07, right=0.97, top=0.93, bottom=0.1)
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

Part of Psychrometric Chart for HVAC on anyplot.ai.

Other implementations