Psychrometric Chart for HVAC — Plotly

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 Plotly

Python source (Plotly)

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

import os

import numpy as np
import plotly.graph_objects as go


# 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"
GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"
LABEL_BG = "rgba(255,253,246,0.85)" if THEME == "light" else "rgba(36,36,32,0.85)"

# Imprint palette — one hue family per psychrometric property (theme-independent)
SAT = "#009E73"  # brand green — saturation curve (ALWAYS first series)
RH = (68, 103, 163)  # #4467A3 blue — relative-humidity curves
# RH *labels* get a brighter blue in dark theme so they read as crisply as the
# cyan/ochre labels against the near-black surface (curve color stays identical).
RH_LABEL = RH if THEME == "light" else (143, 168, 218)  # #8FA8DA
WB = (42, 188, 205)  # #2ABCCD cyan — wet-bulb lines
ENTH = (189, 130, 51)  # #BD8233 ochre — enthalpy lines
VOL = (196, 117, 253)  # #C475FD lavender — specific-volume lines
PROC = "#AE3030"  # matte red — highlighted HVAC process (semantic emphasis)
COMFORT = "#6B6A63" if THEME == "light" else "#A8A79F"  # muted neutral — context region

# Standard sea-level atmosphere
P_ATM = 101325  # Pa

# Saturation pressure over a fine dry-bulb grid (ASHRAE 2017 Ch.1) — computed ONCE,
# then np.interp reuses it everywhere instead of re-deriving the formula per loop.
t_fine = np.linspace(-10, 50, 1200)
t_k = t_fine + 273.15
p_ws_fine = np.where(
    t_fine >= 0,
    np.exp(
        -5800.2206 / t_k
        + 1.3914993
        - 0.048640239 * t_k
        + 0.000041764768 * t_k**2
        - 0.000000014452093 * t_k**3
        + 6.5459673 * np.log(t_k)
    ),
    np.exp(
        -5674.5359 / t_k
        + 6.3925247
        - 0.009677843 * t_k
        + 0.00000062215701 * t_k**2
        + 2.0747825e-09 * t_k**3
        - 9.484024e-13 * t_k**4
        + 4.1635019 * np.log(t_k)
    ),
)
w_sat_fine = 0.621945 * p_ws_fine / (P_ATM - p_ws_fine) * 1000  # g/kg

W_MAX = 30.0  # y-axis ceiling
fig = go.Figure()

# --- Relative-humidity curves (10%-90%) — graduated opacity, drawn under saturation ---
# Anchor fractions chosen so RH labels fan into open pockets, clear of the
# central process path / comfort-zone / specific-volume labels (see VQ-02).
rh_label_frac = {20: 0.80, 40: 0.68, 60: 0.52, 80: 0.48}
for rh_pct in range(10, 100, 10):
    p_w = (rh_pct / 100.0) * p_ws_fine
    w = 0.621945 * p_w / (P_ATM - p_w) * 1000
    mask = (w >= 0) & (w <= W_MAX)
    t_plot, w_plot = t_fine[mask], w[mask]
    h_vals = 1.006 * t_plot + (w_plot / 1000) * (2501 + 1.86 * t_plot)
    alpha = 0.30 + rh_pct * 0.0035
    fig.add_trace(
        go.Scatter(
            x=t_plot,
            y=w_plot,
            mode="lines",
            line={"color": f"rgba({RH[0]},{RH[1]},{RH[2]},{alpha:.2f})", "width": 2.0},
            showlegend=False,
            customdata=np.column_stack([h_vals, np.full_like(t_plot, rh_pct)]),
            hovertemplate=(
                "<b>%{customdata[1]:.0f}% RH</b><br>"
                "Dry-Bulb: %{x:.1f} °C<br>"
                "Humidity Ratio: %{y:.2f} g/kg<br>"
                "Enthalpy: %{customdata[0]:.1f} kJ/kg<extra></extra>"
            ),
        )
    )
    if rh_pct in rh_label_frac and len(t_plot) > 20:
        idx = int(len(t_plot) * rh_label_frac[rh_pct])
        fig.add_annotation(
            x=float(t_plot[idx]),
            y=float(w_plot[idx]),
            text=f"<b>{rh_pct}%</b>",
            showarrow=False,
            font={"size": 12, "color": f"rgb({RH_LABEL[0]},{RH_LABEL[1]},{RH_LABEL[2]})"},
            xshift=14,
            yshift=2,
            bgcolor=LABEL_BG,
        )

# --- Wet-bulb temperature lines (constant t_wb, labeled near the saturation end) ---
for t_wb in range(0, 35, 5):
    t_arr = np.linspace(t_wb, 50, 240)
    p_ws_wb = float(np.interp(t_wb, t_fine, p_ws_fine))
    w_s_wb = 0.621945 * p_ws_wb / (P_ATM - p_ws_wb)
    w = (w_s_wb - 1.006 * (t_arr - t_wb) / (2501 + 1.86 * t_wb)) * 1000
    w_lid = np.interp(t_arr, t_fine, w_sat_fine)
    mask = (w >= 0) & (w <= W_MAX) & (w <= w_lid + 0.3)
    t_plot, w_plot = t_arr[mask], w[mask]
    if len(t_plot) > 5:
        fig.add_trace(
            go.Scatter(
                x=t_plot,
                y=w_plot,
                mode="lines",
                line={"color": f"rgba({WB[0]},{WB[1]},{WB[2]},0.75)", "width": 1.8, "dash": "dash"},
                showlegend=False,
                hovertemplate=(
                    f"<b>Wet-Bulb: {t_wb} °C</b><br>"
                    "Dry-Bulb: %{x:.1f} °C<br>"
                    "Humidity Ratio: %{y:.2f} g/kg<extra></extra>"
                ),
            )
        )
        # Wet-bulb labels nudge DOWN-RIGHT (into the chart, below the saturation
        # curve) while enthalpy labels go UP-LEFT — the two families converge on
        # the curve, so opposite offsets keep their labels from stacking (VQ-02).
        fig.add_annotation(
            x=float(t_plot[0]),
            y=float(w_plot[0]),
            text=f"<b>{t_wb}°</b>",
            showarrow=False,
            font={"size": 11, "color": f"rgb({WB[0]},{WB[1]},{WB[2]})"},
            xshift=18,
            yshift=-16,
            bgcolor=LABEL_BG,
        )

# --- Constant-enthalpy lines (kJ/kg), labeled at the upper-left saturation end ---
for h in range(20, 120, 10):
    t_arr = np.linspace(-10, 50, 300)
    w = (h - 1.006 * t_arr) / (2501 + 1.86 * t_arr) * 1000
    w_lid = np.interp(t_arr, t_fine, w_sat_fine)
    mask = (w >= 0) & (w <= W_MAX) & (w <= w_lid + 0.3)
    t_plot, w_plot = t_arr[mask], w[mask]
    if len(t_plot) > 5:
        fig.add_trace(
            go.Scatter(
                x=t_plot,
                y=w_plot,
                mode="lines",
                line={"color": f"rgba({ENTH[0]},{ENTH[1]},{ENTH[2]},0.65)", "width": 1.5, "dash": "dot"},
                showlegend=False,
                hovertemplate=(
                    f"<b>Enthalpy: {h} kJ/kg</b><br>"
                    "Dry-Bulb: %{x:.1f} °C<br>"
                    "Humidity Ratio: %{y:.2f} g/kg<extra></extra>"
                ),
            )
        )
        if h % 20 == 0:
            fig.add_annotation(
                x=float(t_plot[0]),
                y=float(w_plot[0]),
                text=f"<i>{h}</i>",
                showarrow=False,
                font={"size": 11, "color": f"rgb({ENTH[0]},{ENTH[1]},{ENTH[2]})"},
                xshift=-26,
                yshift=20,
                bgcolor=LABEL_BG,
                textangle=-38,
            )

# --- Constant specific-volume lines (m³/kg dry air) ---
for v_100 in range(80, 96, 2):
    v = v_100 / 100.0
    t_arr = np.linspace(-10, 50, 300)
    w = (P_ATM * v / (287.042 * (t_arr + 273.15)) - 1) / (1 + 287.042 / 461.524) * 1000
    w_lid = np.interp(t_arr, t_fine, w_sat_fine)
    mask = (w >= 0) & (w <= W_MAX) & (w <= w_lid + 0.3)
    t_plot, w_plot = t_arr[mask], w[mask]
    if len(t_plot) > 5:
        fig.add_trace(
            go.Scatter(
                x=t_plot,
                y=w_plot,
                mode="lines",
                line={"color": f"rgba({VOL[0]},{VOL[1]},{VOL[2]},0.60)", "width": 1.5, "dash": "dashdot"},
                showlegend=False,
                hovertemplate=(
                    f"<b>Specific Volume: {v:.2f} m³/kg</b><br>"
                    "Dry-Bulb: %{x:.1f} °C<br>"
                    "Humidity Ratio: %{y:.2f} g/kg<extra></extra>"
                ),
            )
        )
        if v_100 % 4 == 0:
            # Label lower-right along each line so '0.84' clears the central
            # comfort-zone / process annotations rather than sitting on them.
            idx = int(len(t_plot) * 0.68)
            fig.add_annotation(
                x=float(t_plot[idx]),
                y=float(w_plot[idx]),
                text=f"{v:.2f}",
                showarrow=False,
                font={"size": 11, "color": f"rgb({VOL[0]},{VOL[1]},{VOL[2]})"},
                xshift=-10,
                yshift=10,
                bgcolor=LABEL_BG,
                textangle=-68,
            )

# --- Saturation curve (100% RH) — prominent upper boundary, drawn on top ---
mask_sat = w_sat_fine <= W_MAX
t_sat, w_sat = t_fine[mask_sat], w_sat_fine[mask_sat]
h_sat = 1.006 * t_sat + (w_sat / 1000) * (2501 + 1.86 * t_sat)
fig.add_trace(
    go.Scatter(
        x=t_sat,
        y=w_sat,
        mode="lines",
        line={"color": SAT, "width": 5},
        name="Saturation (100% RH)",
        customdata=np.column_stack([h_sat]),
        hovertemplate=(
            "<b>Saturation Curve</b><br>"
            "Dry-Bulb: %{x:.1f} °C<br>"
            "Humidity Ratio: %{y:.2f} g/kg<br>"
            "Enthalpy: %{customdata[0]:.1f} kJ/kg<extra></extra>"
        ),
    )
)

# --- Comfort zone (ASHRAE: 20-26 °C, 30-60% RH) ---
ct = np.array([20.0, 26.0, 26.0, 20.0])
crh = np.array([0.30, 0.30, 0.60, 0.60])
p_ws_c = np.interp(ct, t_fine, p_ws_fine)
cw = 0.621945 * (crh * p_ws_c) / (P_ATM - crh * p_ws_c) * 1000
fig.add_trace(
    go.Scatter(
        x=np.append(ct, ct[0]),
        y=np.append(cw, cw[0]),
        fill="toself",
        fillcolor="rgba(107,106,99,0.12)" if THEME == "light" else "rgba(168,167,159,0.14)",
        line={"color": COMFORT, "width": 2.5, "dash": "dash"},
        name="Comfort Zone",
        hovertemplate="<b>ASHRAE Comfort Zone</b><br>20–26 °C, 30–60% RH<extra></extra>",
    )
)
fig.add_annotation(
    x=23,
    y=float((cw[1] + cw[2]) / 2),
    text="<b>Comfort<br>Zone</b>",
    showarrow=False,
    font={"size": 14, "color": INK},
    bgcolor=LABEL_BG,
)

# --- HVAC process path: cooling & dehumidification (32 °C/60% RH → 24 °C/50% RH) ---
st_t = np.array([32.0, 24.0])
st_rh = np.array([0.60, 0.50])
p_ws_s = np.interp(st_t, t_fine, p_ws_fine)
st_w = 0.621945 * (st_rh * p_ws_s) / (P_ATM - st_rh * p_ws_s) * 1000
st_h = 1.006 * st_t + (st_w / 1000) * (2501 + 1.86 * st_t)
fig.add_trace(
    go.Scatter(
        x=st_t,
        y=st_w,
        mode="lines+markers",
        line={"color": PROC, "width": 4.5},
        marker={"size": 15, "color": PROC, "line": {"color": PAGE_BG, "width": 2.5}},
        name="Cooling & Dehumidification",
        customdata=np.column_stack([st_rh * 100, st_h]),
        hovertemplate=(
            "<b>State Point</b><br>"
            "Dry-Bulb: %{x:.0f} °C<br>"
            "RH: %{customdata[0]:.0f}%<br>"
            "Humidity Ratio: %{y:.1f} g/kg<br>"
            "Enthalpy: %{customdata[1]:.1f} kJ/kg<extra></extra>"
        ),
    )
)
fig.add_annotation(
    x=float(st_t[1] + 1.2),
    y=float((st_w[0] + st_w[1]) / 2),
    ax=float(st_t[0] - 1.2),
    ay=float((st_w[0] + st_w[1]) / 2),
    xref="x",
    yref="y",
    axref="x",
    ayref="y",
    showarrow=True,
    arrowhead=3,
    arrowsize=1.6,
    arrowwidth=3,
    arrowcolor=PROC,
)
fig.add_annotation(
    x=float(st_t[0]),
    y=float(st_w[0]),
    text="<b>32 °C, 60% RH</b>",
    showarrow=True,
    arrowhead=0,
    arrowwidth=1.5,
    arrowcolor=PROC,
    ax=48,
    ay=-34,
    font={"size": 13, "color": PROC},
    bgcolor=LABEL_BG,
    borderpad=4,
)
fig.add_annotation(
    x=float(st_t[1]),
    y=float(st_w[1]),
    text="<b>24 °C, 50% RH</b>",
    showarrow=True,
    arrowhead=0,
    arrowwidth=1.5,
    arrowcolor=PROC,
    ax=-48,
    ay=34,
    font={"size": 13, "color": PROC},
    bgcolor=LABEL_BG,
    borderpad=4,
)

# --- Legend proxies for the unlabeled property families ---
for lname, lrgb, ldash in [
    ("Relative Humidity (%)", RH, "solid"),
    ("Wet-Bulb Temp (°C)", WB, "dash"),
    ("Enthalpy (kJ/kg)", ENTH, "dot"),
    ("Specific Volume (m³/kg)", VOL, "dashdot"),
]:
    fig.add_trace(
        go.Scatter(
            x=[None],
            y=[None],
            mode="lines",
            line={"color": f"rgb({lrgb[0]},{lrgb[1]},{lrgb[2]})", "width": 2.5, "dash": ldash},
            name=lname,
        )
    )

# --- Layout ---
axis_common = {
    "tickfont": {"size": 10, "color": INK_SOFT},
    "dtick": 5,
    "gridcolor": GRID,
    "gridwidth": 1,
    "zeroline": False,
    "showline": True,
    "linewidth": 1.5,
    "linecolor": INK_SOFT,
}
fig.update_layout(
    autosize=False,
    title={
        "text": "psychrometric-basic · python · plotly · anyplot.ai",
        "font": {"size": 16, "color": INK},
        "x": 0.5,
        "xanchor": "center",
    },
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    font={"color": INK},
    xaxis={
        "title": {"text": "Dry-Bulb Temperature (°C)", "font": {"size": 12, "color": INK}},
        "range": [-10, 50],
        **axis_common,
    },
    yaxis={
        "title": {"text": "Humidity Ratio (g/kg dry air)", "font": {"size": 12, "color": INK}},
        "range": [0, W_MAX],
        **axis_common,
    },
    legend={
        "font": {"size": 10, "color": INK_SOFT},
        "x": 0.01,
        "y": 0.99,
        "xanchor": "left",
        "yanchor": "top",
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 1,
        "itemsizing": "constant",
    },
    margin={"l": 80, "r": 40, "t": 80, "b": 60},
    hoverlabel={"bgcolor": ELEVATED_BG, "font": {"size": 13, "color": INK}, "bordercolor": INK_SOFT},
    hovermode="closest",
)

# Save
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Psychrometric Chart for HVAC on anyplot.ai.

Other implementations