Chris Parmer — home

Psychrometric Chart

HVAC — humidity ratio vs. dry-bulb temperature

Example from the compendium of canonical charts

HVAC — Psychrometric Chart (humidity ratio vs. dry-bulb temperature)

Python Code

"""HVAC — Psychrometric Chart (humidity ratio vs. dry-bulb temperature)."""
from pathlib import Path

# ── Palette + theme (matching the Plotly Studio gallery these charts ship in) ──
VIOLET, TEAL, GREEN, PINK, ORANGE = "#845EEE", "#52B3D0", "#55B685", "#DA5597", "#E9A23B"
PRIMARY, SECONDARY = VIOLET, TEAL
COLORWAY = [VIOLET, TEAL, GREEN, PINK, ORANGE]
BG, TEXT, GRID, MUTED = "#ffffff", "#1c2024", "#d9d9e0", "#60646c"
FONT = "Inter, -apple-system, BlinkMacSystemFont, sans-serif"
COLORSCALE = [[0, "rgba(132, 94, 238, 0.05)"], [1, "rgba(132, 94, 238, 0.9)"]]


def apply_theme(fig):
    """Light gallery theme: white background, Inter font, soft gridlines."""
    fig.update_layout(
        paper_bgcolor=BG, plot_bgcolor=BG, colorway=COLORWAY,
        font=dict(family=FONT, color=TEXT, size=12),
        legend=dict(font=dict(color=TEXT)),
        hoverlabel=dict(bgcolor="#f0f0f3", font=dict(color=TEXT, family=FONT), bordercolor=GRID),
    )
    fig.update_xaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
    fig.update_yaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)


def fetch_csv(url, **kwargs):
    import io
    import pandas as pd
    import requests
    r = requests.get(url, timeout=60)
    r.raise_for_status()
    return pd.read_csv(io.StringIO(r.text), **kwargs)


def fetch_json(url):
    import requests
    r = requests.get(url, timeout=60)
    r.raise_for_status()
    return r.json()

import numpy as np
import plotly.graph_objects as go


P = 101325.0  # atmospheric pressure (Pa)

# Relative humidity levels to plot
RH_LEVELS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]

# Cycle through palette
COLORS = [
    "#52B3D0",  # teal  — 10%
    "#55B685",  # green — 20%
    "#845EEE",  # violet— 30%
    "#52B3D0",  # teal  — 40%
    "#DA5597",  # pink  — 50%
    "#52B3D0",
    "#55B685",
    "#845EEE",
    "#52B3D0",
    "#845EEE",  # 100% saturation — same as violet but bolder
]


def saturation_pressure(T_C):
    """Arden-Buck equation for saturation vapor pressure (Pa)."""
    return 611.21 * np.exp((18.678 - T_C / 234.5) * (T_C / (257.14 + T_C)))


def humidity_ratio(phi_frac, T_C):
    """Humidity ratio W (kg_w/kg_da) at relative humidity phi (0-1)."""
    pv = phi_frac * saturation_pressure(T_C)
    return 0.621945 * pv / (P - pv)


def generate():
    T = np.linspace(0, 50, 300)

    fig = go.Figure()

    # Constant-RH lines
    for i, rh in enumerate(RH_LEVELS):
        W = humidity_ratio(rh / 100.0, T)
        is_sat = rh == 100
        color = COLORS[i]
        fig.add_trace(go.Scatter(
            x=T,
            y=W * 1000,   # convert to g/kg for readability
            mode="lines",
            line=dict(
                color=color,
                width=2.5 if is_sat else 1.5,
                dash="solid" if is_sat else "solid",
            ),
            name=f"{rh}% RH",
            hovertemplate=f"{rh}% RH<br>T=%{{x:.1f}} °C<br>W=%{{y:.2f}} g/kg<extra></extra>",
        ))
        # Label at T=48°C
        label_T = 47.0
        label_W = humidity_ratio(rh / 100.0, label_T) * 1000
        fig.add_annotation(
            x=label_T,
            y=label_W,
            text=f"{rh}%",
            showarrow=False,
            font=dict(size=9, color=color),
            xanchor="left",
            yanchor="middle",
            xshift=4,
        )

    # Shade comfort zone: 20-26°C, 40-60% RH
    T_comfort = np.concatenate([
        np.linspace(20, 26, 50),   # lower RH=40% edge
        np.linspace(26, 20, 50),   # upper RH=60% edge
    ])
    W_comfort = np.concatenate([
        humidity_ratio(0.40, np.linspace(20, 26, 50)) * 1000,
        humidity_ratio(0.60, np.linspace(26, 20, 50)) * 1000,
    ])
    fig.add_trace(go.Scatter(
        x=T_comfort,
        y=W_comfort,
        fill="toself",
        fillcolor="rgba(85, 182, 133, 0.15)",
        line=dict(color="rgba(85, 182, 133, 0.5)", width=1),
        name="Comfort zone",
        hoverinfo="skip",
    ))
    fig.add_annotation(
        x=23, y=humidity_ratio(0.50, 23) * 1000,
        text="Comfort<br>zone",
        showarrow=False,
        font=dict(size=11, color=GREEN),
        xanchor="center",
    )

    fig.update_layout(
        xaxis=dict(title="Dry-Bulb Temperature (°C)", range=[0, 52]),
        yaxis=dict(title="Humidity Ratio W (g/kg dry air)", rangemode="tozero"),
        legend=dict(
            x=0.01, y=0.99,
            xanchor="left", yanchor="top",
            font=dict(size=10),
            tracegroupgap=0,
        ),
        margin=dict(t=50, b=60, l=70, r=80),
    )

    apply_theme(fig)
    return fig


fig = generate()
fig.show()

Made with Plotly