{"spec_id":"psychrometric-basic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\npsychrometric-basic: Psychrometric Chart for HVAC\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-06-16\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    arrow,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_line,\n    geom_path,\n    geom_point,\n    geom_polygon,\n    geom_segment,\n    geom_text,\n    ggplot,\n    ggsize,\n    labs,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\n\n# Theme-adaptive chrome (only data colors stay constant across themes)\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nGRID = \"#E3E0D6\" if THEME == \"light\" else \"#33332E\"\n\n# Imprint palette — one hue per psychrometric property family\nSATURATION = \"#009E73\"  # brand green — saturation curve & RH family (★ first series)\nWET_BULB = \"#4467A3\"  # blue\nENTHALPY = \"#BD8233\"  # ochre\nVOLUME = \"#C475FD\"  # lavender\nCOMFORT = \"#2ABCCD\"  # cyan — comfort-zone region\nPROCESS = \"#AE3030\"  # matte red — HVAC process path (focal point)\n\n# Constants\nP_ATM = 101325.0  # Pa, standard sea-level atmospheric pressure (101.325 kPa)\n\n\n# Psychrometric equations (ASHRAE Fundamentals)\ndef saturation_pressure(t):\n    t_k = t + 273.15\n    if t >= 0:\n        ln_ps = (\n            -5.8002206e3 / t_k\n            + 1.3914993\n            - 4.8640239e-2 * t_k\n            + 4.1764768e-5 * t_k**2\n            - 1.4452093e-8 * t_k**3\n            + 6.5459673 * np.log(t_k)\n        )\n    else:\n        ln_ps = (\n            -5.6745359e3 / t_k\n            + 6.3925247\n            - 9.677843e-3 * t_k\n            + 6.2215701e-7 * t_k**2\n            + 2.0747825e-9 * t_k**3\n            - 9.484024e-13 * t_k**4\n            + 4.1635019 * np.log(t_k)\n        )\n    return np.exp(ln_ps)\n\n\ndef humidity_ratio(t_db, rh):\n    p_s = saturation_pressure(t_db)\n    p_w = rh * p_s\n    return 0.621945 * p_w / (P_ATM - p_w)\n\n\ndef wet_bulb_line(t_wb, t_db_range):\n    w_sat = humidity_ratio(t_wb, 1.0)\n    h_fg, cp_a, cp_w = 2501.0, 1.006, 1.86\n    w_values = [\n        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)\n        for t_db in t_db_range\n    ]\n    return np.array(w_values)\n\n\n# Data — relative-humidity curves (10% to 100%)\nt_db_fine = np.linspace(-10, 50, 300)\nrh_frames = []\nfor rh_val in np.round(np.arange(0.1, 1.05, 0.1), 1):\n    w_vals, t_valid = [], []\n    for t in t_db_fine:\n        w = humidity_ratio(t, rh_val) * 1000  # g/kg\n        if 0 <= w <= 30:\n            w_vals.append(w)\n            t_valid.append(t)\n    rh_frames.append(pd.DataFrame({\"t_db\": t_valid, \"w\": w_vals, \"group\": f\"rh{rh_val}\"}))\ndf_rh = pd.concat(rh_frames, ignore_index=True)\n# Saturation curve (100% RH) — the prominent upper boundary\ndf_sat = df_rh[df_rh[\"group\"] == \"rh1.0\"]\ndf_rh_inner = df_rh[df_rh[\"group\"] != \"rh1.0\"]\n\n# Wet-bulb temperature lines\nwb_temps = [0, 5, 10, 15, 20, 25, 30, 35]\nwb_frames = []\nfor t_wb in wb_temps:\n    t_range = np.linspace(t_wb, min(t_wb + 30, 50), 100)\n    w_vals = wet_bulb_line(t_wb, t_range) * 1000\n    mask = (w_vals >= 0) & (w_vals <= 30)\n    wb_frames.append(pd.DataFrame({\"t_db\": t_range[mask], \"w\": w_vals[mask], \"group\": f\"wb{t_wb}\"}))\ndf_wb = pd.concat(wb_frames, ignore_index=True)\n\n# Constant-enthalpy lines (kJ/kg)\nenthalpy_values = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110]\nenth_frames = []\nfor h_target in enthalpy_values:\n    t_points, w_points = [], []\n    for t in np.linspace(-10, 50, 200):\n        w_gkg = (h_target - 1.006 * t) / (2501.0 + 1.86 * t) * 1000\n        if 0 <= w_gkg <= 30:\n            t_points.append(t)\n            w_points.append(w_gkg)\n    if len(t_points) > 1:\n        enth_frames.append(pd.DataFrame({\"t_db\": t_points, \"w\": w_points, \"group\": f\"h{h_target}\"}))\ndf_enth = pd.concat(enth_frames, ignore_index=True)\n\n# Constant specific-volume lines (m3/kg)\nvol_values = [0.78, 0.80, 0.82, 0.84, 0.86, 0.88, 0.90, 0.92, 0.94]\nvol_frames = []\nfor v_target in vol_values:\n    t_points, w_points = [], []\n    for t in np.linspace(-10, 50, 200):\n        w_gkg = (v_target * (P_ATM / 1000) / (0.287042 * (t + 273.15)) - 1) / 1.6078 * 1000\n        if 0 <= w_gkg <= 30:\n            t_points.append(t)\n            w_points.append(w_gkg)\n    if len(t_points) > 1:\n        vol_frames.append(pd.DataFrame({\"t_db\": t_points, \"w\": w_points, \"group\": f\"v{v_target}\"}))\ndf_vol = pd.concat(vol_frames, ignore_index=True)\n\n# Comfort zone polygon (20-26 C, 30-60% RH)\ndf_comfort = pd.DataFrame(\n    {\n        \"t_db\": [20, 26, 26, 20],\n        \"w\": [\n            humidity_ratio(20, 0.3) * 1000,\n            humidity_ratio(26, 0.3) * 1000,\n            humidity_ratio(26, 0.6) * 1000,\n            humidity_ratio(20, 0.6) * 1000,\n        ],\n    }\n)\n\n# HVAC process path: cooling + dehumidification (32 C / 50% RH -> 24 C / 50% RH)\ns1_t, s2_t = 32, 24\ndf_process = pd.DataFrame(\n    {\"t_db\": [s1_t, s2_t], \"w\": [humidity_ratio(s1_t, 0.50) * 1000, humidity_ratio(s2_t, 0.50) * 1000]}\n)\n\n# Direct labels (all property lines labelled on-chart, not in a legend)\n# RH labels — staggered along their curves to avoid the crowded upper-left\nrh_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}\nrh_labels = []\nfor rh_val, t_label in rh_label_t.items():\n    w_label = humidity_ratio(t_label, rh_val) * 1000\n    if w_label <= 28:\n        rh_labels.append({\"t_db\": t_label, \"w\": w_label, \"label\": f\"{int(rh_val * 100)}%\"})\ndf_rh_labels = pd.DataFrame(rh_labels)\n\n# Wet-bulb labels — nudged down each diagonal (off the saturation curve) so they\n# don't crowd the RH labels in the dense upper-left convergence zone\nwb_label_offset = 7\ndf_wb_labels = pd.DataFrame(\n    [\n        {\"t_db\": t_wb + wb_label_offset, \"w\": w_lbl, \"label\": f\"{t_wb}°C wb\"}\n        for t_wb in wb_temps\n        if t_wb % 10 == 0\n        for w_lbl in [wet_bulb_line(t_wb, [t_wb + wb_label_offset])[0] * 1000]\n        if 0.5 < w_lbl <= 27\n    ]\n)\n\n# Enthalpy labels — on the left edge\ndf_enth_labels = pd.DataFrame(\n    [\n        {\"t_db\": -8, \"w\": w_gkg, \"label\": f\"{h} kJ/kg\"}\n        for h in enthalpy_values\n        for w_gkg in [(h - 1.006 * (-5)) / (2501.0 + 1.86 * (-5)) * 1000]\n        if 0 < w_gkg <= 28\n    ]\n)\n\n# Specific-volume labels — on the right edge\ndf_vol_labels = pd.DataFrame(\n    [\n        {\"t_db\": 46, \"w\": w_gkg, \"label\": f\"{v} m³/kg\"}\n        for v in vol_values\n        for w_gkg in [(v * (P_ATM / 1000) / (0.287042 * (45 + 273.15)) - 1) / 1.6078 * 1000]\n        if 0 < w_gkg <= 28\n    ]\n)\n\n# Plot\nplot = (\n    ggplot()\n    # Comfort zone region\n    + geom_polygon(data=df_comfort, mapping=aes(x=\"t_db\", y=\"w\"), fill=COMFORT, alpha=0.16, color=COMFORT, size=1.0)\n    # Specific-volume lines (secondary family)\n    + geom_line(data=df_vol, mapping=aes(x=\"t_db\", y=\"w\", group=\"group\"), color=VOLUME, size=0.6, alpha=0.7)\n    # Enthalpy lines (secondary family)\n    + geom_line(data=df_enth, mapping=aes(x=\"t_db\", y=\"w\", group=\"group\"), color=ENTHALPY, size=0.6, alpha=0.7)\n    # Wet-bulb lines\n    + geom_line(data=df_wb, mapping=aes(x=\"t_db\", y=\"w\", group=\"group\"), color=WET_BULB, size=0.7, alpha=0.7)\n    # Inner RH curves (10-90%)\n    + geom_line(data=df_rh_inner, mapping=aes(x=\"t_db\", y=\"w\", group=\"group\"), color=INK_MUTED, size=0.7, alpha=0.7)\n    # Saturation curve (100% RH) — prominent upper boundary\n    + geom_line(data=df_sat, mapping=aes(x=\"t_db\", y=\"w\"), color=SATURATION, size=2.4)\n    # HVAC process path with arrowhead (focal point)\n    + geom_path(\n        data=df_process,\n        mapping=aes(x=\"t_db\", y=\"w\"),\n        color=PROCESS,\n        size=2.6,\n        arrow=arrow(type=\"closed\", length=14, angle=20),\n    )\n    + geom_point(data=df_process, mapping=aes(x=\"t_db\", y=\"w\"), color=PROCESS, size=5.5, shape=21, fill=PAGE_BG)\n    # Direct labels\n    + geom_text(data=df_rh_labels, mapping=aes(x=\"t_db\", y=\"w\", label=\"label\"), size=4.6, color=INK_MUTED)\n    + geom_text(data=df_wb_labels, mapping=aes(x=\"t_db\", y=\"w\", label=\"label\"), size=4.2, color=WET_BULB)\n    + geom_text(data=df_enth_labels, mapping=aes(x=\"t_db\", y=\"w\", label=\"label\"), size=4.2, color=ENTHALPY, hjust=1)\n    + geom_text(data=df_vol_labels, mapping=aes(x=\"t_db\", y=\"w\", label=\"label\"), size=4.2, color=VOLUME, hjust=0)\n    + geom_text(\n        data=pd.DataFrame({\"t_db\": [23], \"w\": [7.6], \"label\": [\"Comfort\\nzone\"]}),\n        mapping=aes(x=\"t_db\", y=\"w\", label=\"label\"),\n        size=6.4,\n        color=COMFORT,\n        fontface=\"bold\",\n    )\n    + geom_text(\n        data=pd.DataFrame({\"t_db\": [40.5], \"w\": [16.5], \"label\": [\"Cooling &\\ndehumidification\"]}),\n        mapping=aes(x=\"t_db\", y=\"w\", label=\"label\"),\n        size=5.4,\n        color=PROCESS,\n        fontface=\"bold\",\n    )\n    + geom_segment(\n        data=pd.DataFrame({\"x\": [38.5], \"xend\": [33.0], \"y\": [16.0], \"yend\": [14.2]}),\n        mapping=aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        color=PROCESS,\n        size=0.6,\n        alpha=0.7,\n    )\n    + scale_x_continuous(limits=[-10, 51], breaks=list(range(-10, 55, 5)))\n    + scale_y_continuous(limits=[0, 30], breaks=list(range(0, 35, 5)))\n    + labs(\n        x=\"Dry-bulb temperature (°C)\",\n        y=\"Humidity ratio (g/kg)\",\n        title=\"psychrometric-basic · python · letsplot · anyplot.ai\",\n    )\n    + theme_minimal()\n    + theme(\n        plot_title=element_text(size=16, face=\"bold\", color=INK),\n        axis_title=element_text(size=12, color=INK),\n        axis_text=element_text(size=10, color=INK_SOFT),\n        legend_position=\"none\",\n        panel_grid_major=element_line(color=GRID, size=0.4),\n        panel_grid_minor=element_blank(),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    )\n    + ggsize(800, 450)\n)\n\n# Save (3200 × 1800 px at scale=4) + interactive HTML\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}