{"spec_id":"psychrometric-basic","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\npsychrometric-basic: Psychrometric Chart for HVAC\nLibrary: plotnine 0.15.7 | Python 3.13.13\nQuality: 93/100 | Updated: 2026-06-16\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    annotate,\n    arrow,\n    coord_cartesian,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_line,\n    geom_point,\n    geom_polygon,\n    geom_ribbon,\n    geom_segment,\n    geom_text,\n    ggplot,\n    labs,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\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\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Imprint palette — one hue family per psychrometric property type.\nBRAND = \"#009E73\"  # ★ first series: relative-humidity / saturation curves\nBLUE = \"#4467A3\"  # wet-bulb temperature (moisture cue)\nLAVENDER = \"#C475FD\"  # enthalpy\nOCHRE = \"#BD8233\"  # specific volume\nRED = \"#AE3030\"  # HVAC process path (semantic emphasis)\nCYAN = \"#2ABCCD\"  # comfort zone\n\n# Constants — standard sea-level atmosphere\nPATM = 101.325  # kPa\nT_MIN, T_MAX = -10, 50\nW_MAX = 30\n\nt_range = np.linspace(T_MIN, T_MAX, 300)\n\n# Magnus saturation vapor pressure: psat(t) = 0.61078 * exp(17.27 t / (t + 237.3))\n# Humidity ratio (inlined throughout): w = 622 * pw / (PATM - pw), pw = rh_frac * psat\n\n# Relative-humidity curves 10%-90%\nrh_frames = []\nfor rh in range(10, 100, 10):\n    pw = np.minimum(rh / 100.0 * 0.61078 * np.exp(17.27 * t_range / (t_range + 237.3)), PATM - 0.01)\n    w = 622.0 * pw / (PATM - pw)\n    mask = (w >= 0) & (w <= W_MAX)\n    rh_frames.append(pd.DataFrame({\"t\": t_range[mask], \"w\": w[mask], \"group\": f\"RH {rh}\"}))\ndf_rh = pd.concat(rh_frames, ignore_index=True)\n\n# Saturation curve (100% RH) — the visually prominent upper boundary\npw_sat = np.minimum(0.61078 * np.exp(17.27 * t_range / (t_range + 237.3)), PATM - 0.01)\nw_sat = 622.0 * pw_sat / (PATM - pw_sat)\nmask_sat = (w_sat >= 0) & (w_sat <= W_MAX)\ndf_sat = pd.DataFrame({\"t\": t_range[mask_sat], \"w\": w_sat[mask_sat]})\n\n# RH labels positioned along their curves, spaced to avoid the comfort zone and state points\nrh_label_pos = {10: 47, 20: 43, 30: 39, 40: 34, 50: 16, 60: 30, 70: 12, 80: 8, 90: 4}\nrh_labels = []\nfor rh, t_l in rh_label_pos.items():\n    pw_l = min(rh / 100.0 * 0.61078 * np.exp(17.27 * t_l / (t_l + 237.3)), PATM - 0.01)\n    w_l = 622.0 * pw_l / (PATM - pw_l)\n    if 0 < w_l < W_MAX:\n        rh_labels.append({\"t\": t_l, \"w\": w_l + 1.1, \"label\": f\"{rh}%\"})\ndf_rh_labels = pd.DataFrame(rh_labels)\n\n# Wet-bulb temperature lines\nwb_frames = []\nfor twb in range(-5, 35, 5):\n    pws_wb = 0.61078 * np.exp(17.27 * twb / (twb + 237.3))\n    ws_wb = 622.0 * pws_wb / (PATM - pws_wb)\n    t_line = np.linspace(twb, min(twb + 30, T_MAX), 150)\n    w_line = ((2501.0 - 2.326 * twb) * ws_wb - 1006.0 * (t_line - twb)) / (2501.0 + 1.86 * t_line - 4.186 * twb)\n    mask = (w_line >= 0) & (w_line <= W_MAX) & (t_line >= T_MIN)\n    if np.sum(mask) > 2:\n        wb_frames.append(pd.DataFrame({\"t\": t_line[mask], \"w\": w_line[mask], \"group\": f\"WB {twb}\"}))\ndf_wb = pd.concat(wb_frames, ignore_index=True)\n\n# Wet-bulb labels at the lower-right end of every other line\nwb_labels = []\nfor twb in range(0, 35, 10):\n    sub = df_wb[df_wb[\"group\"] == f\"WB {twb}\"]\n    if len(sub) > 0:\n        row = sub.loc[sub[\"t\"].idxmax()]\n        wb_labels.append({\"t\": row[\"t\"] + 1.4, \"w\": max(row[\"w\"] - 0.2, 0.6), \"label\": f\"{twb}°C wb\"})\ndf_wb_labels = pd.DataFrame(wb_labels)\n\n# Enthalpy lines (kJ/kg dry air)\nenth_frames = []\nfor h in range(10, 130, 20):\n    w_line = (h - 1.006 * t_range) / (2.501 + 0.00186 * t_range)\n    mask = (w_line >= 0) & (w_line <= W_MAX) & (t_range >= T_MIN) & (t_range <= T_MAX)\n    if np.sum(mask) > 2:\n        enth_frames.append(pd.DataFrame({\"t\": t_range[mask], \"w\": w_line[mask], \"group\": f\"h={h}\"}))\ndf_enth = pd.concat(enth_frames, ignore_index=True)\n\n# Enthalpy labels at the UPPER-LEFT (high-w) end of each line — keeps them clear of the\n# wet-bulb / specific-volume labels that cluster in the lower-right corner\nenth_labels = []\nfor h in range(30, 130, 20):\n    sub = df_enth[df_enth[\"group\"] == f\"h={h}\"]\n    if len(sub) > 0:\n        row = sub.loc[sub[\"w\"].idxmax()]\n        if row[\"w\"] < W_MAX - 1.5:\n            enth_labels.append({\"t\": row[\"t\"] - 0.5, \"w\": min(row[\"w\"] + 1.0, W_MAX - 0.5), \"label\": f\"{h} kJ/kg\"})\ndf_enth_labels = pd.DataFrame(enth_labels)\n\n# Specific volume lines (m³/kg dry air)\nsv_frames = []\nfor v_100 in range(80, 94, 2):\n    v = v_100 / 100.0\n    w_line = (v * PATM / (0.287055 * (t_range + 273.15)) - 1.0) / 1.6078 * 1000.0\n    mask = (w_line >= 0) & (w_line <= W_MAX) & (t_range >= T_MIN) & (t_range <= T_MAX)\n    if np.sum(mask) > 2:\n        sv_frames.append(pd.DataFrame({\"t\": t_range[mask], \"w\": w_line[mask], \"group\": f\"v={v:.2f}\"}))\ndf_sv = pd.concat(sv_frames, ignore_index=True)\n\n# Specific-volume labels along the bottom edge, well below the wet-bulb labels\nsv_labels = []\nfor v_100 in range(82, 94, 4):\n    v = v_100 / 100.0\n    sub = df_sv[df_sv[\"group\"] == f\"v={v:.2f}\"]\n    if len(sub) > 0:\n        row = sub.loc[sub[\"t\"].idxmax()]\n        sv_labels.append({\"t\": min(row[\"t\"] + 1.2, T_MAX), \"w\": max(row[\"w\"] - 0.9, 0.5), \"label\": f\"{v:.2f} m³/kg\"})\ndf_sv_labels = pd.DataFrame(sv_labels)\n\n# Comfort zone (20-26°C, 30-60% RH)\nt_comfort = np.linspace(20, 26, 50)\npw_clo = 0.30 * 0.61078 * np.exp(17.27 * t_comfort / (t_comfort + 237.3))\npw_chi = 0.60 * 0.61078 * np.exp(17.27 * t_comfort / (t_comfort + 237.3))\ndf_comfort_ribbon = pd.DataFrame(\n    {\"t\": t_comfort, \"w_lo\": 622.0 * pw_clo / (PATM - pw_clo), \"w_hi\": 622.0 * pw_chi / (PATM - pw_chi)}\n)\nt_corner = np.array([20.0, 26.0])\npw_lo = 0.30 * 0.61078 * np.exp(17.27 * t_corner / (t_corner + 237.3))\npw_hi = 0.60 * 0.61078 * np.exp(17.27 * t_corner / (t_corner + 237.3))\nc_lo = 622.0 * pw_lo / (PATM - pw_lo)\nc_hi = 622.0 * pw_hi / (PATM - pw_hi)\ndf_comfort = pd.DataFrame({\"t\": [20, 26, 26, 20, 20], \"w\": [c_lo[0], c_lo[1], c_hi[1], c_hi[0], c_lo[0]]})\n\n# HVAC process: cooling and dehumidification from A (35°C, 50% RH) to B (24°C, 50% RH)\npw_a = min(0.50 * 0.61078 * np.exp(17.27 * 35 / (35 + 237.3)), PATM - 0.01)\nw_a = 622.0 * pw_a / (PATM - pw_a)\npw_b = min(0.50 * 0.61078 * np.exp(17.27 * 24 / (24 + 237.3)), PATM - 0.01)\nw_b = 622.0 * pw_b / (PATM - pw_b)\ndf_states = pd.DataFrame({\"t\": [35, 24], \"w\": [w_a, w_b]})\ndf_arrow = pd.DataFrame({\"x\": [35], \"y\": [w_a], \"xend\": [24], \"yend\": [w_b]})\n\n# Build plot with layered grammar of graphics\nplot = (\n    ggplot()\n    # Comfort zone (plotnine-distinctive: geom_ribbon with ymin/ymax mapping)\n    + geom_ribbon(aes(x=\"t\", ymin=\"w_lo\", ymax=\"w_hi\"), data=df_comfort_ribbon, fill=CYAN, alpha=0.15)\n    + geom_polygon(aes(x=\"t\", y=\"w\"), data=df_comfort, fill=\"none\", color=CYAN, size=0.7, alpha=0.85)\n    # Specific volume — ochre dotted\n    + geom_line(aes(x=\"t\", y=\"w\", group=\"group\"), data=df_sv, color=OCHRE, size=0.7, alpha=0.85, linetype=\"dotted\")\n    # Enthalpy — lavender dashed\n    + geom_line(aes(x=\"t\", y=\"w\", group=\"group\"), data=df_enth, color=LAVENDER, size=0.7, alpha=0.8, linetype=\"dashed\")\n    # Wet-bulb — blue solid, thin\n    + geom_line(aes(x=\"t\", y=\"w\", group=\"group\"), data=df_wb, color=BLUE, size=0.6, alpha=0.7)\n    # Relative-humidity curves — brand green, light\n    + geom_line(aes(x=\"t\", y=\"w\", group=\"group\"), data=df_rh, color=BRAND, size=0.6, alpha=0.5)\n    # Saturation curve (100% RH) — prominent brand-green boundary\n    + geom_line(aes(x=\"t\", y=\"w\"), data=df_sat, color=BRAND, size=1.6)\n    # HVAC process arrow\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=df_arrow,\n        color=RED,\n        size=1.4,\n        arrow=arrow(length=0.16, type=\"closed\"),\n    )\n    + geom_point(aes(x=\"t\", y=\"w\"), data=df_states, color=RED, fill=RED, size=3.2)\n    # Direct property labels (geom_text size is in mm, not pt)\n    + geom_text(aes(x=\"t\", y=\"w\", label=\"label\"), data=df_rh_labels, size=3.0, color=BRAND, fontweight=\"bold\")\n    + geom_text(aes(x=\"t\", y=\"w\", label=\"label\"), data=df_wb_labels, size=3.0, color=BLUE, ha=\"left\")\n    + geom_text(aes(x=\"t\", y=\"w\", label=\"label\"), data=df_enth_labels, size=3.0, color=LAVENDER, ha=\"left\")\n    + geom_text(aes(x=\"t\", y=\"w\", label=\"label\"), data=df_sv_labels, size=3.0, color=OCHRE, ha=\"left\")\n    # State-point and comfort annotations\n    + annotate(\"text\", x=36.5, y=w_a + 1.6, label=\"A · 35°C\", size=3.4, color=RED, ha=\"left\", fontweight=\"bold\")\n    + annotate(\"text\", x=21.5, y=w_b + 1.7, label=\"B · 24°C\", size=3.4, color=RED, ha=\"right\", fontweight=\"bold\")\n    + annotate(\n        \"text\",\n        x=23,\n        y=(c_lo.mean() + c_hi.mean()) / 2,\n        label=\"Comfort\\nZone\",\n        size=3.2,\n        color=INK_SOFT,\n        fontweight=\"bold\",\n        ha=\"center\",\n        va=\"center\",\n    )\n    + labs(\n        x=\"Dry-Bulb Temperature (°C)\",\n        y=\"Humidity Ratio (g/kg dry air)\",\n        title=\"psychrometric-basic · python · plotnine · anyplot.ai\",\n    )\n    + scale_x_continuous(breaks=range(T_MIN, T_MAX + 1, 5))\n    + scale_y_continuous(breaks=range(0, W_MAX + 1, 5))\n    + coord_cartesian(xlim=(T_MIN - 1, T_MAX + 6), ylim=(0, W_MAX))\n    + theme_minimal()\n    + theme(\n        figure_size=(8, 4.5),\n        plot_title=element_text(size=12, weight=\"bold\", color=INK),\n        axis_title=element_text(size=10, color=INK),\n        axis_text=element_text(size=8, color=INK_SOFT),\n        panel_grid_major=element_line(color=INK, size=0.3, alpha=0.15),\n        panel_grid_minor=element_blank(),\n        legend_position=\"none\",\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=\"none\"),\n    )\n)\n\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\")\n"}