{"spec_id":"psychrometric-basic","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\npsychrometric-basic: Psychrometric Chart for HVAC\nLibrary: plotly 6.8.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-06-16\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme-adaptive chrome (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nGRID = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\nLABEL_BG = \"rgba(255,253,246,0.85)\" if THEME == \"light\" else \"rgba(36,36,32,0.85)\"\n\n# Imprint palette — one hue family per psychrometric property (theme-independent)\nSAT = \"#009E73\"  # brand green — saturation curve (ALWAYS first series)\nRH = (68, 103, 163)  # #4467A3 blue — relative-humidity curves\n# RH *labels* get a brighter blue in dark theme so they read as crisply as the\n# cyan/ochre labels against the near-black surface (curve color stays identical).\nRH_LABEL = RH if THEME == \"light\" else (143, 168, 218)  # #8FA8DA\nWB = (42, 188, 205)  # #2ABCCD cyan — wet-bulb lines\nENTH = (189, 130, 51)  # #BD8233 ochre — enthalpy lines\nVOL = (196, 117, 253)  # #C475FD lavender — specific-volume lines\nPROC = \"#AE3030\"  # matte red — highlighted HVAC process (semantic emphasis)\nCOMFORT = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"  # muted neutral — context region\n\n# Standard sea-level atmosphere\nP_ATM = 101325  # Pa\n\n# Saturation pressure over a fine dry-bulb grid (ASHRAE 2017 Ch.1) — computed ONCE,\n# then np.interp reuses it everywhere instead of re-deriving the formula per loop.\nt_fine = np.linspace(-10, 50, 1200)\nt_k = t_fine + 273.15\np_ws_fine = np.where(\n    t_fine >= 0,\n    np.exp(\n        -5800.2206 / t_k\n        + 1.3914993\n        - 0.048640239 * t_k\n        + 0.000041764768 * t_k**2\n        - 0.000000014452093 * t_k**3\n        + 6.5459673 * np.log(t_k)\n    ),\n    np.exp(\n        -5674.5359 / t_k\n        + 6.3925247\n        - 0.009677843 * t_k\n        + 0.00000062215701 * t_k**2\n        + 2.0747825e-09 * t_k**3\n        - 9.484024e-13 * t_k**4\n        + 4.1635019 * np.log(t_k)\n    ),\n)\nw_sat_fine = 0.621945 * p_ws_fine / (P_ATM - p_ws_fine) * 1000  # g/kg\n\nW_MAX = 30.0  # y-axis ceiling\nfig = go.Figure()\n\n# --- Relative-humidity curves (10%-90%) — graduated opacity, drawn under saturation ---\n# Anchor fractions chosen so RH labels fan into open pockets, clear of the\n# central process path / comfort-zone / specific-volume labels (see VQ-02).\nrh_label_frac = {20: 0.80, 40: 0.68, 60: 0.52, 80: 0.48}\nfor rh_pct in range(10, 100, 10):\n    p_w = (rh_pct / 100.0) * p_ws_fine\n    w = 0.621945 * p_w / (P_ATM - p_w) * 1000\n    mask = (w >= 0) & (w <= W_MAX)\n    t_plot, w_plot = t_fine[mask], w[mask]\n    h_vals = 1.006 * t_plot + (w_plot / 1000) * (2501 + 1.86 * t_plot)\n    alpha = 0.30 + rh_pct * 0.0035\n    fig.add_trace(\n        go.Scatter(\n            x=t_plot,\n            y=w_plot,\n            mode=\"lines\",\n            line={\"color\": f\"rgba({RH[0]},{RH[1]},{RH[2]},{alpha:.2f})\", \"width\": 2.0},\n            showlegend=False,\n            customdata=np.column_stack([h_vals, np.full_like(t_plot, rh_pct)]),\n            hovertemplate=(\n                \"<b>%{customdata[1]:.0f}% RH</b><br>\"\n                \"Dry-Bulb: %{x:.1f} °C<br>\"\n                \"Humidity Ratio: %{y:.2f} g/kg<br>\"\n                \"Enthalpy: %{customdata[0]:.1f} kJ/kg<extra></extra>\"\n            ),\n        )\n    )\n    if rh_pct in rh_label_frac and len(t_plot) > 20:\n        idx = int(len(t_plot) * rh_label_frac[rh_pct])\n        fig.add_annotation(\n            x=float(t_plot[idx]),\n            y=float(w_plot[idx]),\n            text=f\"<b>{rh_pct}%</b>\",\n            showarrow=False,\n            font={\"size\": 12, \"color\": f\"rgb({RH_LABEL[0]},{RH_LABEL[1]},{RH_LABEL[2]})\"},\n            xshift=14,\n            yshift=2,\n            bgcolor=LABEL_BG,\n        )\n\n# --- Wet-bulb temperature lines (constant t_wb, labeled near the saturation end) ---\nfor t_wb in range(0, 35, 5):\n    t_arr = np.linspace(t_wb, 50, 240)\n    p_ws_wb = float(np.interp(t_wb, t_fine, p_ws_fine))\n    w_s_wb = 0.621945 * p_ws_wb / (P_ATM - p_ws_wb)\n    w = (w_s_wb - 1.006 * (t_arr - t_wb) / (2501 + 1.86 * t_wb)) * 1000\n    w_lid = np.interp(t_arr, t_fine, w_sat_fine)\n    mask = (w >= 0) & (w <= W_MAX) & (w <= w_lid + 0.3)\n    t_plot, w_plot = t_arr[mask], w[mask]\n    if len(t_plot) > 5:\n        fig.add_trace(\n            go.Scatter(\n                x=t_plot,\n                y=w_plot,\n                mode=\"lines\",\n                line={\"color\": f\"rgba({WB[0]},{WB[1]},{WB[2]},0.75)\", \"width\": 1.8, \"dash\": \"dash\"},\n                showlegend=False,\n                hovertemplate=(\n                    f\"<b>Wet-Bulb: {t_wb} °C</b><br>\"\n                    \"Dry-Bulb: %{x:.1f} °C<br>\"\n                    \"Humidity Ratio: %{y:.2f} g/kg<extra></extra>\"\n                ),\n            )\n        )\n        # Wet-bulb labels nudge DOWN-RIGHT (into the chart, below the saturation\n        # curve) while enthalpy labels go UP-LEFT — the two families converge on\n        # the curve, so opposite offsets keep their labels from stacking (VQ-02).\n        fig.add_annotation(\n            x=float(t_plot[0]),\n            y=float(w_plot[0]),\n            text=f\"<b>{t_wb}°</b>\",\n            showarrow=False,\n            font={\"size\": 11, \"color\": f\"rgb({WB[0]},{WB[1]},{WB[2]})\"},\n            xshift=18,\n            yshift=-16,\n            bgcolor=LABEL_BG,\n        )\n\n# --- Constant-enthalpy lines (kJ/kg), labeled at the upper-left saturation end ---\nfor h in range(20, 120, 10):\n    t_arr = np.linspace(-10, 50, 300)\n    w = (h - 1.006 * t_arr) / (2501 + 1.86 * t_arr) * 1000\n    w_lid = np.interp(t_arr, t_fine, w_sat_fine)\n    mask = (w >= 0) & (w <= W_MAX) & (w <= w_lid + 0.3)\n    t_plot, w_plot = t_arr[mask], w[mask]\n    if len(t_plot) > 5:\n        fig.add_trace(\n            go.Scatter(\n                x=t_plot,\n                y=w_plot,\n                mode=\"lines\",\n                line={\"color\": f\"rgba({ENTH[0]},{ENTH[1]},{ENTH[2]},0.65)\", \"width\": 1.5, \"dash\": \"dot\"},\n                showlegend=False,\n                hovertemplate=(\n                    f\"<b>Enthalpy: {h} kJ/kg</b><br>\"\n                    \"Dry-Bulb: %{x:.1f} °C<br>\"\n                    \"Humidity Ratio: %{y:.2f} g/kg<extra></extra>\"\n                ),\n            )\n        )\n        if h % 20 == 0:\n            fig.add_annotation(\n                x=float(t_plot[0]),\n                y=float(w_plot[0]),\n                text=f\"<i>{h}</i>\",\n                showarrow=False,\n                font={\"size\": 11, \"color\": f\"rgb({ENTH[0]},{ENTH[1]},{ENTH[2]})\"},\n                xshift=-26,\n                yshift=20,\n                bgcolor=LABEL_BG,\n                textangle=-38,\n            )\n\n# --- Constant specific-volume lines (m³/kg dry air) ---\nfor v_100 in range(80, 96, 2):\n    v = v_100 / 100.0\n    t_arr = np.linspace(-10, 50, 300)\n    w = (P_ATM * v / (287.042 * (t_arr + 273.15)) - 1) / (1 + 287.042 / 461.524) * 1000\n    w_lid = np.interp(t_arr, t_fine, w_sat_fine)\n    mask = (w >= 0) & (w <= W_MAX) & (w <= w_lid + 0.3)\n    t_plot, w_plot = t_arr[mask], w[mask]\n    if len(t_plot) > 5:\n        fig.add_trace(\n            go.Scatter(\n                x=t_plot,\n                y=w_plot,\n                mode=\"lines\",\n                line={\"color\": f\"rgba({VOL[0]},{VOL[1]},{VOL[2]},0.60)\", \"width\": 1.5, \"dash\": \"dashdot\"},\n                showlegend=False,\n                hovertemplate=(\n                    f\"<b>Specific Volume: {v:.2f} m³/kg</b><br>\"\n                    \"Dry-Bulb: %{x:.1f} °C<br>\"\n                    \"Humidity Ratio: %{y:.2f} g/kg<extra></extra>\"\n                ),\n            )\n        )\n        if v_100 % 4 == 0:\n            # Label lower-right along each line so '0.84' clears the central\n            # comfort-zone / process annotations rather than sitting on them.\n            idx = int(len(t_plot) * 0.68)\n            fig.add_annotation(\n                x=float(t_plot[idx]),\n                y=float(w_plot[idx]),\n                text=f\"{v:.2f}\",\n                showarrow=False,\n                font={\"size\": 11, \"color\": f\"rgb({VOL[0]},{VOL[1]},{VOL[2]})\"},\n                xshift=-10,\n                yshift=10,\n                bgcolor=LABEL_BG,\n                textangle=-68,\n            )\n\n# --- Saturation curve (100% RH) — prominent upper boundary, drawn on top ---\nmask_sat = w_sat_fine <= W_MAX\nt_sat, w_sat = t_fine[mask_sat], w_sat_fine[mask_sat]\nh_sat = 1.006 * t_sat + (w_sat / 1000) * (2501 + 1.86 * t_sat)\nfig.add_trace(\n    go.Scatter(\n        x=t_sat,\n        y=w_sat,\n        mode=\"lines\",\n        line={\"color\": SAT, \"width\": 5},\n        name=\"Saturation (100% RH)\",\n        customdata=np.column_stack([h_sat]),\n        hovertemplate=(\n            \"<b>Saturation Curve</b><br>\"\n            \"Dry-Bulb: %{x:.1f} °C<br>\"\n            \"Humidity Ratio: %{y:.2f} g/kg<br>\"\n            \"Enthalpy: %{customdata[0]:.1f} kJ/kg<extra></extra>\"\n        ),\n    )\n)\n\n# --- Comfort zone (ASHRAE: 20-26 °C, 30-60% RH) ---\nct = np.array([20.0, 26.0, 26.0, 20.0])\ncrh = np.array([0.30, 0.30, 0.60, 0.60])\np_ws_c = np.interp(ct, t_fine, p_ws_fine)\ncw = 0.621945 * (crh * p_ws_c) / (P_ATM - crh * p_ws_c) * 1000\nfig.add_trace(\n    go.Scatter(\n        x=np.append(ct, ct[0]),\n        y=np.append(cw, cw[0]),\n        fill=\"toself\",\n        fillcolor=\"rgba(107,106,99,0.12)\" if THEME == \"light\" else \"rgba(168,167,159,0.14)\",\n        line={\"color\": COMFORT, \"width\": 2.5, \"dash\": \"dash\"},\n        name=\"Comfort Zone\",\n        hovertemplate=\"<b>ASHRAE Comfort Zone</b><br>20–26 °C, 30–60% RH<extra></extra>\",\n    )\n)\nfig.add_annotation(\n    x=23,\n    y=float((cw[1] + cw[2]) / 2),\n    text=\"<b>Comfort<br>Zone</b>\",\n    showarrow=False,\n    font={\"size\": 14, \"color\": INK},\n    bgcolor=LABEL_BG,\n)\n\n# --- HVAC process path: cooling & dehumidification (32 °C/60% RH → 24 °C/50% RH) ---\nst_t = np.array([32.0, 24.0])\nst_rh = np.array([0.60, 0.50])\np_ws_s = np.interp(st_t, t_fine, p_ws_fine)\nst_w = 0.621945 * (st_rh * p_ws_s) / (P_ATM - st_rh * p_ws_s) * 1000\nst_h = 1.006 * st_t + (st_w / 1000) * (2501 + 1.86 * st_t)\nfig.add_trace(\n    go.Scatter(\n        x=st_t,\n        y=st_w,\n        mode=\"lines+markers\",\n        line={\"color\": PROC, \"width\": 4.5},\n        marker={\"size\": 15, \"color\": PROC, \"line\": {\"color\": PAGE_BG, \"width\": 2.5}},\n        name=\"Cooling & Dehumidification\",\n        customdata=np.column_stack([st_rh * 100, st_h]),\n        hovertemplate=(\n            \"<b>State Point</b><br>\"\n            \"Dry-Bulb: %{x:.0f} °C<br>\"\n            \"RH: %{customdata[0]:.0f}%<br>\"\n            \"Humidity Ratio: %{y:.1f} g/kg<br>\"\n            \"Enthalpy: %{customdata[1]:.1f} kJ/kg<extra></extra>\"\n        ),\n    )\n)\nfig.add_annotation(\n    x=float(st_t[1] + 1.2),\n    y=float((st_w[0] + st_w[1]) / 2),\n    ax=float(st_t[0] - 1.2),\n    ay=float((st_w[0] + st_w[1]) / 2),\n    xref=\"x\",\n    yref=\"y\",\n    axref=\"x\",\n    ayref=\"y\",\n    showarrow=True,\n    arrowhead=3,\n    arrowsize=1.6,\n    arrowwidth=3,\n    arrowcolor=PROC,\n)\nfig.add_annotation(\n    x=float(st_t[0]),\n    y=float(st_w[0]),\n    text=\"<b>32 °C, 60% RH</b>\",\n    showarrow=True,\n    arrowhead=0,\n    arrowwidth=1.5,\n    arrowcolor=PROC,\n    ax=48,\n    ay=-34,\n    font={\"size\": 13, \"color\": PROC},\n    bgcolor=LABEL_BG,\n    borderpad=4,\n)\nfig.add_annotation(\n    x=float(st_t[1]),\n    y=float(st_w[1]),\n    text=\"<b>24 °C, 50% RH</b>\",\n    showarrow=True,\n    arrowhead=0,\n    arrowwidth=1.5,\n    arrowcolor=PROC,\n    ax=-48,\n    ay=34,\n    font={\"size\": 13, \"color\": PROC},\n    bgcolor=LABEL_BG,\n    borderpad=4,\n)\n\n# --- Legend proxies for the unlabeled property families ---\nfor lname, lrgb, ldash in [\n    (\"Relative Humidity (%)\", RH, \"solid\"),\n    (\"Wet-Bulb Temp (°C)\", WB, \"dash\"),\n    (\"Enthalpy (kJ/kg)\", ENTH, \"dot\"),\n    (\"Specific Volume (m³/kg)\", VOL, \"dashdot\"),\n]:\n    fig.add_trace(\n        go.Scatter(\n            x=[None],\n            y=[None],\n            mode=\"lines\",\n            line={\"color\": f\"rgb({lrgb[0]},{lrgb[1]},{lrgb[2]})\", \"width\": 2.5, \"dash\": ldash},\n            name=lname,\n        )\n    )\n\n# --- Layout ---\naxis_common = {\n    \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n    \"dtick\": 5,\n    \"gridcolor\": GRID,\n    \"gridwidth\": 1,\n    \"zeroline\": False,\n    \"showline\": True,\n    \"linewidth\": 1.5,\n    \"linecolor\": INK_SOFT,\n}\nfig.update_layout(\n    autosize=False,\n    title={\n        \"text\": \"psychrometric-basic · python · plotly · anyplot.ai\",\n        \"font\": {\"size\": 16, \"color\": INK},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n    },\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    xaxis={\n        \"title\": {\"text\": \"Dry-Bulb Temperature (°C)\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"range\": [-10, 50],\n        **axis_common,\n    },\n    yaxis={\n        \"title\": {\"text\": \"Humidity Ratio (g/kg dry air)\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"range\": [0, W_MAX],\n        **axis_common,\n    },\n    legend={\n        \"font\": {\"size\": 10, \"color\": INK_SOFT},\n        \"x\": 0.01,\n        \"y\": 0.99,\n        \"xanchor\": \"left\",\n        \"yanchor\": \"top\",\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n        \"itemsizing\": \"constant\",\n    },\n    margin={\"l\": 80, \"r\": 40, \"t\": 80, \"b\": 60},\n    hoverlabel={\"bgcolor\": ELEVATED_BG, \"font\": {\"size\": 13, \"color\": INK}, \"bordercolor\": INK_SOFT},\n    hovermode=\"closest\",\n)\n\n# Save\nfig.write_image(f\"plot-{THEME}.png\", width=800, height=450, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}