{"spec_id":"psychrometric-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\npsychrometric-basic: Psychrometric Chart for HVAC\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-06-16\n\"\"\"\n\nimport base64\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import Arrow, Band, ColumnDataSource, Label, Title, VeeHead\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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\"\n\n# Imprint palette — each psychrometric property family gets one hue.\nRH_COLOR = \"#009E73\"  # brand green — relative humidity (first series, always #009E73)\nWB_COLOR = \"#4467A3\"  # blue — wet-bulb temperature (cool/temperature cue)\nENTH_COLOR = \"#C475FD\"  # lavender — enthalpy\nSV_COLOR = \"#BD8233\"  # ochre — specific volume\nCOMFORT_COLOR = \"#2ABCCD\"  # cyan — comfort-zone region\nPROCESS_COLOR = \"#AE3030\"  # matte red — highlighted HVAC process path\n\n# Constants — standard sea-level atmosphere\nP_ATM = 101325.0  # Pa\n# ASHRAE saturation-pressure coefficients (over liquid water, t >= 0 °C)\nC8, C9, C10, C11, C12, C13 = (-5.8002206e3, 1.3914993, -4.8640239e-2, 4.1764768e-5, -1.4452093e-8, 6.5459673)\n# Over ice (t < 0 °C)\nC1, C2, C3, C4, C5, C6, C7 = (\n    -5.6745359e3,\n    6.3925247,\n    -9.677843e-3,\n    6.2215701e-7,\n    2.0747825e-9,\n    -9.484024e-13,\n    4.1635019,\n)\n\n# Data — saturation vapor pressure across the dry-bulb range\nt_db = np.linspace(-10, 45, 400)\ntk = t_db + 273.15\npsat = np.where(\n    tk >= 273.15,\n    np.exp(C8 / tk + C9 + C10 * tk + C11 * tk**2 + C12 * tk**3 + C13 * np.log(tk)),\n    np.exp(C1 / tk + C2 + C3 * tk + C4 * tk**2 + C5 * tk**3 + C6 * tk**4 + C7 * np.log(tk)),\n)\n\n# Plot\ntitle = \"psychrometric-basic · python · bokeh · anyplot.ai\"\np = figure(\n    width=3200,\n    height=1800,\n    title=title,\n    x_axis_label=\"Dry-Bulb Temperature (°C)\",\n    y_axis_label=\"Humidity Ratio (g water / kg dry air)\",\n    toolbar_location=None,\n    x_range=(-10, 45),\n    y_range=(0, 30),\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=130,\n    min_border_right=70,\n)\n\n# --- Relative-humidity curves (10 % to 100 %) ---\nrh_label_x = {10: 44, 20: 42, 30: 40, 40: 38, 50: 36, 60: 34, 70: 31, 80: 28, 90: 25, 100: 21}\nfor rh_pct in range(10, 101, 10):\n    pw = (rh_pct / 100.0) * psat\n    w = 0.621945 * pw / (P_ATM - pw) * 1000.0\n    mask = (w >= 0) & (w <= 30)\n    t_plot, w_plot = t_db[mask], w[mask]\n    is_sat = rh_pct == 100\n    source = ColumnDataSource(data={\"t\": t_plot, \"w\": w_plot})\n    p.line(\n        \"t\",\n        \"w\",\n        source=source,\n        line_color=RH_COLOR,\n        line_width=6.0 if is_sat else 2.6,\n        line_alpha=1.0 if is_sat else 0.55,\n    )\n    target = rh_label_x[rh_pct]\n    idx = int(np.argmin(np.abs(t_plot - target)))\n    if w_plot[idx] > 28.5:\n        idx = int(np.argmin(np.abs(w_plot - 27.5)))\n    p.add_layout(\n        Label(\n            x=t_plot[idx],\n            y=w_plot[idx],\n            text=f\"{rh_pct}%\",\n            text_font_size=\"26pt\" if is_sat else \"22pt\",\n            text_color=RH_COLOR,\n            text_font_style=\"bold\" if is_sat else \"normal\",\n            x_offset=8,\n            y_offset=-2,\n        )\n    )\n\n# --- Wet-bulb temperature lines (diagonal, upper-left to lower-right) ---\nfor twb in [5, 10, 15, 20, 25, 30]:\n    tk_wb = twb + 273.15\n    ps_wb = np.exp(C8 / tk_wb + C9 + C10 * tk_wb + C11 * tk_wb**2 + C12 * tk_wb**3 + C13 * np.log(tk_wb))\n    ws_wb = 0.621945 * ps_wb / (P_ATM - ps_wb) * 1000.0\n    t_range = np.linspace(twb, 45, 200)\n    w = ws_wb - 1.006 * (t_range - twb) / (2501.0 - 2.326 * twb) * 1000.0\n    mask = (w >= 0) & (w <= 30)\n    t_plot, w_plot = t_range[mask], w[mask]\n    if t_plot.size < 2:\n        continue\n    source = ColumnDataSource(data={\"t\": t_plot, \"w\": w_plot})\n    p.line(\"t\", \"w\", source=source, line_color=WB_COLOR, line_width=2.2, line_alpha=0.7, line_dash=\"dashed\")\n    # Label at the saturation-curve end (spreads naturally along the boundary)\n    p.add_layout(\n        Label(\n            x=t_plot[0],\n            y=w_plot[0],\n            text=f\"{twb}°C wb\",\n            text_font_size=\"18pt\",\n            text_color=WB_COLOR,\n            text_alpha=0.9,\n            x_offset=-2,\n            y_offset=12,\n        )\n    )\n\n# --- Enthalpy lines (oblique, kJ/kg dry air) ---\nfor h in [20, 40, 60, 80]:\n    t_range = np.linspace(-10, 45, 200)\n    w = (h - 1.006 * t_range) / (2501.0 + 1.86 * t_range) * 1000.0\n    mask = (w >= 0) & (w <= 30)\n    t_plot, w_plot = t_range[mask], w[mask]\n    if t_plot.size < 2:\n        continue\n    source = ColumnDataSource(data={\"t\": t_plot, \"w\": w_plot})\n    p.line(\"t\", \"w\", source=source, line_color=ENTH_COLOR, line_width=2.2, line_alpha=0.7, line_dash=\"dotted\")\n    # Label at the lower-right (low-humidity) end — keeps enthalpy text out of the\n    # crowded saturation corner where wet-bulb labels already sit.\n    p.add_layout(\n        Label(\n            x=t_plot[-1],\n            y=w_plot[-1],\n            text=f\"{h} kJ/kg\",\n            text_font_size=\"18pt\",\n            text_color=ENTH_COLOR,\n            text_alpha=0.95,\n            x_offset=10,\n            y_offset=6,\n        )\n    )\n\n# --- Specific-volume lines (m³/kg dry air) ---\nfor v in [0.80, 0.84, 0.88, 0.92]:\n    t_range = np.linspace(-10, 45, 200)\n    w = (v * 101.325 / (0.287055 * (t_range + 273.15)) - 1.0) / 1.6078 * 1000.0\n    mask = (w >= 0) & (w <= 30)\n    t_plot, w_plot = t_range[mask], w[mask]\n    if t_plot.size < 2:\n        continue\n    source = ColumnDataSource(data={\"t\": t_plot, \"w\": w_plot})\n    p.line(\"t\", \"w\", source=source, line_color=SV_COLOR, line_width=2.2, line_alpha=0.7, line_dash=\"dashdot\")\n    # Label at the high-humidity (top) end — the four lines fan out across the top.\n    idx = int(np.argmax(w_plot))\n    p.add_layout(\n        Label(\n            x=t_plot[idx],\n            y=w_plot[idx],\n            text=f\"{v:.2f} m³/kg\",\n            text_font_size=\"17pt\",\n            text_color=SV_COLOR,\n            text_alpha=0.95,\n            x_offset=8,\n            y_offset=-6,\n        )\n    )\n\n# --- Comfort zone (20-26 °C, 30-60 % RH) as a filled Band ---\ncomfort_t = np.linspace(20, 26, 40)\ncomfort_tk = comfort_t + 273.15\ncomfort_psat = np.exp(\n    C8 / comfort_tk + C9 + C10 * comfort_tk + C11 * comfort_tk**2 + C12 * comfort_tk**3 + C13 * np.log(comfort_tk)\n)\npw_lo, pw_hi = 0.30 * comfort_psat, 0.60 * comfort_psat\nw_lo = 0.621945 * pw_lo / (P_ATM - pw_lo) * 1000.0\nw_hi = 0.621945 * pw_hi / (P_ATM - pw_hi) * 1000.0\ncomfort_source = ColumnDataSource(data={\"t\": comfort_t, \"w_lo\": w_lo, \"w_hi\": w_hi})\np.add_layout(\n    Band(\n        base=\"t\",\n        lower=\"w_lo\",\n        upper=\"w_hi\",\n        source=comfort_source,\n        fill_color=COMFORT_COLOR,\n        fill_alpha=0.18,\n        line_color=COMFORT_COLOR,\n        line_width=2.5,\n        line_alpha=0.7,\n    )\n)\np.add_layout(\n    Label(\n        x=23,\n        y=(w_lo[20] + w_hi[20]) / 2,\n        text=\"Comfort Zone\",\n        text_font_size=\"22pt\",\n        text_color=COMFORT_COLOR,\n        text_font_style=\"bold\",\n        text_align=\"center\",\n    )\n)\n\n# --- HVAC process path: cooling & dehumidification (state 1 -> state 2) ---\nstate1_t, state1_rh = 33, 0.55\nstate2_t, state2_rh = 14, 0.90\ntk1 = state1_t + 273.15\nps1 = np.exp(C8 / tk1 + C9 + C10 * tk1 + C11 * tk1**2 + C12 * tk1**3 + C13 * np.log(tk1))\nstate1_w = 0.621945 * (state1_rh * ps1) / (P_ATM - state1_rh * ps1) * 1000.0\ntk2 = state2_t + 273.15\nps2 = np.exp(C8 / tk2 + C9 + C10 * tk2 + C11 * tk2**2 + C12 * tk2**3 + C13 * np.log(tk2))\nstate2_w = 0.621945 * (state2_rh * ps2) / (P_ATM - state2_rh * ps2) * 1000.0\n\nh1 = 1.006 * state1_t + (state1_w / 1000.0) * (2501.0 + 1.86 * state1_t)\nh2 = 1.006 * state2_t + (state2_w / 1000.0) * (2501.0 + 1.86 * state2_t)\ndelta_h = h1 - h2\n\np.add_layout(\n    Arrow(\n        end=VeeHead(size=45, fill_color=PROCESS_COLOR, line_color=PROCESS_COLOR),\n        x_start=state1_t,\n        y_start=state1_w,\n        x_end=state2_t,\n        y_end=state2_w,\n        line_color=PROCESS_COLOR,\n        line_width=6.0,\n    )\n)\nstate_source = ColumnDataSource(data={\"t\": [state1_t, state2_t], \"w\": [state1_w, state2_w]})\np.scatter(\"t\", \"w\", source=state_source, size=22, fill_color=PROCESS_COLOR, line_color=PAGE_BG, line_width=4)\n\np.add_layout(\n    Label(\n        x=state1_t,\n        y=state1_w,\n        text=f\"Outdoor Air ({state1_t}°C, {int(state1_rh * 100)}% RH)\",\n        text_font_size=\"19pt\",\n        text_color=PROCESS_COLOR,\n        text_font_style=\"bold\",\n        x_offset=14,\n        y_offset=8,\n    )\n)\np.add_layout(\n    Label(\n        x=state2_t,\n        y=state2_w,\n        text=f\"Supply Air ({state2_t}°C, {int(state2_rh * 100)}% RH)\",\n        text_font_size=\"19pt\",\n        text_color=PROCESS_COLOR,\n        text_font_style=\"bold\",\n        x_offset=-380,\n        y_offset=-6,\n    )\n)\np.add_layout(\n    Label(\n        x=(state1_t + state2_t) / 2,\n        y=(state1_w + state2_w) / 2,\n        text=f\"Cooling & dehumidification  Δh = {delta_h:.1f} kJ/kg\",\n        text_font_size=\"18pt\",\n        text_color=PROCESS_COLOR,\n        text_font_style=\"italic\",\n        x_offset=-90,\n        y_offset=22,\n        background_fill_color=ELEVATED_BG,\n        background_fill_alpha=0.85,\n    )\n)\n\n# --- Style (theme-adaptive chrome + native-pixel sizing) ---\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.title.text_font_style = \"bold\"\np.title.offset = 6\n\np.add_layout(\n    Title(\n        text=\"Standard atmosphere (101.325 kPa) · ASHRAE psychrometric properties\",\n        text_font_size=\"26pt\",\n        text_color=INK_SOFT,\n        text_font_style=\"italic\",\n    ),\n    \"above\",\n)\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\np.xgrid.grid_line_color = INK\np.ygrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.12\np.ygrid.grid_line_alpha = 0.12\n\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\n\n# Save — HTML artifact + headless-Chrome screenshot at the exact canvas size\noutput_file(f\"plot-{THEME}.html\", title=title)\nsave(p)\n\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\n# CDP screenshot with an explicit clip: `driver.save_screenshot` only captures the\n# visible viewport, which is ~140px shorter than the window in headless Chrome and\n# would crop the canvas to 3200x1657. `captureBeyondViewport` grabs the full clip.\nshot = driver.execute_cdp_cmd(\n    \"Page.captureScreenshot\",\n    {\"clip\": {\"x\": 0, \"y\": 0, \"width\": W, \"height\": H, \"scale\": 1}, \"captureBeyondViewport\": True},\n)\nPath(f\"plot-{THEME}.png\").write_bytes(base64.b64decode(shot[\"data\"]))\ndriver.quit()\n"}