{"spec_id":"psychrometric-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\npsychrometric-basic: Psychrometric Chart for HVAC\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-06-16\n\"\"\"\n\nimport os\nimport sys\n\n\n# Strip the script directory from sys.path so `import altair` resolves to the\n# installed package, not this file (which is named altair.py).\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path[:] = [p for p in sys.path if os.path.abspath(p or \".\") != _script_dir]\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens (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 — each moist-air property family gets one canonical hue.\nCLR_RH = \"#009E73\"  # brand green — relative humidity + saturation (first series)\nCLR_WB = \"#C475FD\"  # lavender — wet-bulb temperature lines\nCLR_ENTHALPY = \"#4467A3\"  # blue — constant enthalpy lines\nCLR_VOL = \"#BD8233\"  # ochre — constant specific-volume lines\nCLR_PROCESS = \"#AE3030\"  # matte red (semantic emphasis) — HVAC process path\nCLR_COMFORT = \"#2ABCCD\"  # cyan — thermal comfort zone\n\n# Constants\nP_ATM = 101325  # Pa, standard sea-level atmospheric pressure\n\n# Saturation pressure over a fine grid (ASHRAE 2017 formula), computed once.\n_t_grid = np.linspace(-10, 50, 500)\n_t_grid_k = _t_grid + 273.15\n_p_sat_grid = np.where(\n    _t_grid >= 0,\n    np.exp(\n        -5.8002206e3 / _t_grid_k\n        + 1.3914993\n        - 4.8640239e-2 * _t_grid_k\n        + 4.1764768e-5 * _t_grid_k**2\n        - 1.4452093e-8 * _t_grid_k**3\n        + 6.5459673 * np.log(_t_grid_k)\n    ),\n    np.exp(\n        -5.6745359e3 / _t_grid_k\n        + 6.3925247\n        - 9.677843e-3 * _t_grid_k\n        + 6.2215701e-7 * _t_grid_k**2\n        + 2.0747825e-9 * _t_grid_k**3\n        - 9.484024e-13 * _t_grid_k**4\n        + 4.1635019 * np.log(_t_grid_k)\n    ),\n)\n\n# Data — psychrometric property curves over the -10..50 °C dry-bulb range.\nt_range = np.linspace(-10, 50, 200)\np_sat = np.interp(t_range, _t_grid, _p_sat_grid)\n\n# Relative-humidity curves (10% to 100%)\nrh_curves = []\nfor rh_pct in range(10, 110, 10):\n    p_w = (rh_pct / 100) * p_sat\n    w_vals = 0.621945 * p_w / (P_ATM - p_w) * 1000  # g/kg\n    for t, w in zip(t_range, w_vals, strict=True):\n        if 0 < w <= 30:\n            rh_curves.append({\"t_db\": float(t), \"w\": float(w), \"rh\": f\"{rh_pct}%\"})\n\nrh_df = pd.DataFrame(rh_curves)\n\n# Wet-bulb temperature lines (constant t_wb)\nwb_lines = []\nfor t_wb_val in range(0, 36, 5):\n    p_sat_wb = float(np.interp(t_wb_val, _t_grid, _p_sat_grid))\n    w_s_wb = 0.621945 * p_sat_wb / (P_ATM - p_sat_wb)\n    for t_db in np.linspace(t_wb_val, 50, 80):\n        w = (2501 * w_s_wb - 1.006 * (t_db - t_wb_val)) / (2501 + 1.86 * t_db - 4.186 * t_wb_val)\n        w_gkg = w * 1000\n        if 0 <= w_gkg <= 30:\n            wb_lines.append({\"t_db\": float(t_db), \"w\": float(w_gkg), \"wb\": f\"{t_wb_val}°C\"})\n\nwb_df = pd.DataFrame(wb_lines)\n\n# Constant-enthalpy lines (h = 1.006*t + w*(2501 + 1.86*t), solved for w)\nenthalpy_lines = []\nfor h_val in range(10, 120, 10):\n    for t_db in np.linspace(-10, 50, 80):\n        w_gkg = (h_val - 1.006 * t_db) / (2.501 + 0.00186 * t_db)\n        if 0 <= w_gkg <= 30:\n            enthalpy_lines.append({\"t_db\": float(t_db), \"w\": float(w_gkg), \"h\": f\"{h_val} kJ/kg\"})\n\nenthalpy_df = pd.DataFrame(enthalpy_lines)\n\n# Constant specific-volume lines (v = 0.287042*T_k*(1+1.6078*w)/P, solved for w)\nvol_lines = []\nfor v_val in [0.78, 0.82, 0.86, 0.90, 0.94]:\n    for t_db in np.linspace(-10, 50, 80):\n        w = (v_val * P_ATM / 1000 / (0.287042 * (t_db + 273.15)) - 1) / 1.6078\n        w_gkg = w * 1000\n        if 0 <= w_gkg <= 30:\n            vol_lines.append({\"t_db\": float(t_db), \"w\": float(w_gkg), \"v\": f\"{v_val} m³/kg\"})\n\nvol_df = pd.DataFrame(vol_lines)\n\n# Comfort zone (20-26 °C, 30-60% RH)\ncomfort_temps = np.linspace(20, 26, 30)\ncomfort_psat = np.interp(comfort_temps, _t_grid, _p_sat_grid)\ncomfort_w_lo = 0.621945 * 0.30 * comfort_psat / (P_ATM - 0.30 * comfort_psat) * 1000\ncomfort_w_hi = 0.621945 * 0.60 * comfort_psat / (P_ATM - 0.60 * comfort_psat) * 1000\ncomfort_df = pd.DataFrame({\"t_db\": comfort_temps, \"w\": comfort_w_lo, \"w2\": comfort_w_hi})\n\n# HVAC process path: cooling + dehumidification (35 °C/50% RH -> 13 °C/saturated)\nt1, t2 = 35.0, 13.0\np_sat_t1, p_sat_t2 = float(np.interp(t1, _t_grid, _p_sat_grid)), float(np.interp(t2, _t_grid, _p_sat_grid))\nw1 = 0.621945 * 0.50 * p_sat_t1 / (P_ATM - 0.50 * p_sat_t1) * 1000\nw2 = 0.621945 * 1.00 * p_sat_t2 / (P_ATM - 1.00 * p_sat_t2) * 1000\n\nprocess_points = pd.DataFrame(\n    {\n        \"t_db\": [t1, t2],\n        \"w\": [float(w1), float(w2)],\n        \"label\": [\"Outdoor Air (35°C, 50% RH)\", \"Supply Air (13°C, 100% RH)\"],\n        \"rh_pct\": [\"50%\", \"100%\"],\n        \"order\": [0, 1],\n    }\n)\n\n# RH labels — staggered along the curves to avoid convergence overlap near saturation\nrh_labels = []\nfor rh_pct in range(10, 110, 10):\n    if rh_pct == 100:\n        t_label = 31\n    elif rh_pct >= 80:\n        t_label = 35\n    elif rh_pct >= 60:\n        t_label = 39\n    elif rh_pct >= 40:\n        t_label = 43\n    else:\n        t_label = 47\n    p_sat_label = float(np.interp(t_label, _t_grid, _p_sat_grid))\n    w_label = 0.621945 * (rh_pct / 100) * p_sat_label / (P_ATM - (rh_pct / 100) * p_sat_label) * 1000\n    if w_label <= 30:\n        rh_labels.append({\"t_db\": float(t_label), \"w\": float(w_label), \"label\": f\"{rh_pct}%\"})\n\nrh_label_df = pd.DataFrame(rh_labels)\n\n# Wet-bulb labels — placed in the interior along each line, away from the saturation crowd.\n# Per-line anchor offsets: 25 °C steps left to clear the red process marker (~35 °C, 17.8 g/kg);\n# 30 °C steps right to descend out of the crowded saturation apex.\nwb_label_offset = {25: 4, 30: 11}\nwb_labels_data = []\nfor t_wb_val in range(0, 36, 5):\n    p_sat_wb = float(np.interp(t_wb_val, _t_grid, _p_sat_grid))\n    w_s_wb = 0.621945 * p_sat_wb / (P_ATM - p_sat_wb)\n    t_at = t_wb_val + wb_label_offset.get(t_wb_val, 7)\n    w_at = (2501 * w_s_wb - 1.006 * (t_at - t_wb_val)) / (2501 + 1.86 * t_at - 4.186 * t_wb_val) * 1000\n    if 0 < w_at <= 28:\n        wb_labels_data.append({\"t_db\": float(t_at), \"w\": float(w_at), \"label\": f\"{t_wb_val}°C WB\"})\n\nwb_label_df = pd.DataFrame(wb_labels_data)\n\n# Enthalpy labels — anchored on the left/top edges where wet-bulb labels are absent\nenthalpy_labels = []\nfor h_val in range(20, 120, 20):\n    w_at_left = (h_val - 1.006 * (-10)) / (2.501 + 0.00186 * (-10))\n    if 0 <= w_at_left <= 30:\n        enthalpy_labels.append({\"t_db\": -9.0, \"w\": float(w_at_left), \"label\": f\"{h_val} kJ/kg\"})\n    else:\n        t_at_top = (h_val - 2.501 * 30) / (1.006 + 0.00186 * 30)\n        if -10 <= t_at_top <= 50:\n            enthalpy_labels.append({\"t_db\": float(t_at_top), \"w\": 29.4, \"label\": f\"{h_val} kJ/kg\"})\n\nenthalpy_label_df = pd.DataFrame(enthalpy_labels)\n\n# Volume labels — along the lower-right where the volume lines exit the frame\nvol_labels = []\nfor v_val in [0.78, 0.82, 0.86, 0.90, 0.94]:\n    w_at = (v_val * P_ATM / 1000 / (0.287042 * (44 + 273.15)) - 1) / 1.6078 * 1000\n    if 0 <= w_at <= 30:\n        vol_labels.append({\"t_db\": 44.0, \"w\": float(w_at), \"label\": f\"{v_val} m³/kg\"})\n\nvol_label_df = pd.DataFrame(vol_labels)\n\n# Plot\nx_scale = alt.Scale(domain=[-10, 50])\ny_scale = alt.Scale(domain=[0, 30])\n\n# Comfort zone shaded band\ncomfort = (\n    alt.Chart(comfort_df)\n    .mark_area(opacity=0.18, color=CLR_COMFORT)\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), y2=\"w2:Q\")\n)\n\ncomfort_label = (\n    alt.Chart(pd.DataFrame({\"t_db\": [23.0], \"w\": [10.8], \"label\": [\"Comfort Zone\"]}))\n    .mark_text(fontSize=12, color=CLR_COMFORT, fontWeight=\"bold\", fontStyle=\"italic\")\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Specific-volume lines (ochre)\nvol_chart = (\n    alt.Chart(vol_df)\n    .mark_line(strokeWidth=1.2, strokeDash=[2, 4], opacity=0.65, color=CLR_VOL)\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), detail=\"v:N\")\n)\n\nvol_text = (\n    alt.Chart(vol_label_df)\n    .mark_text(fontSize=10, color=CLR_VOL, align=\"left\", dx=3, dy=-4, fontWeight=\"bold\")\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Constant-enthalpy lines (blue)\nenthalpy_chart = (\n    alt.Chart(enthalpy_df)\n    .mark_line(strokeWidth=1.2, strokeDash=[4, 5], opacity=0.7, color=CLR_ENTHALPY)\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), detail=\"h:N\")\n)\n\nenthalpy_text = (\n    alt.Chart(enthalpy_label_df)\n    .mark_text(fontSize=10, color=CLR_ENTHALPY, align=\"left\", dx=2, dy=-4, fontWeight=\"bold\")\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Wet-bulb lines (lavender)\nwb_chart = (\n    alt.Chart(wb_df)\n    .mark_line(strokeWidth=1.2, strokeDash=[6, 4], opacity=0.7, color=CLR_WB)\n    .encode(\n        x=alt.X(\"t_db:Q\", scale=x_scale),\n        y=alt.Y(\"w:Q\", scale=y_scale),\n        detail=\"wb:N\",\n        tooltip=[\n            alt.Tooltip(\"t_db:Q\", title=\"Dry-Bulb (°C)\", format=\".1f\"),\n            alt.Tooltip(\"w:Q\", title=\"Humidity (g/kg)\", format=\".1f\"),\n            alt.Tooltip(\"wb:N\", title=\"Wet-Bulb Temp\"),\n        ],\n    )\n)\n\nwb_text = (\n    alt.Chart(wb_label_df)\n    .mark_text(fontSize=10, color=CLR_WB, align=\"left\", dx=2, dy=-5, fontWeight=\"bold\")\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Relative-humidity curves (10%-90%, brand green, lighter than saturation)\nother_rh_df = rh_df[rh_df[\"rh\"] != \"100%\"]\nrh_chart = (\n    alt.Chart(other_rh_df)\n    .mark_line(strokeWidth=1.5, opacity=0.5, color=CLR_RH)\n    .encode(\n        x=alt.X(\"t_db:Q\", scale=x_scale),\n        y=alt.Y(\"w:Q\", scale=y_scale),\n        detail=\"rh:N\",\n        tooltip=[\n            alt.Tooltip(\"t_db:Q\", title=\"Dry-Bulb (°C)\", format=\".1f\"),\n            alt.Tooltip(\"w:Q\", title=\"Humidity (g/kg)\", format=\".1f\"),\n            alt.Tooltip(\"rh:N\", title=\"Relative Humidity\"),\n        ],\n    )\n)\n\n# Saturation curve (100% RH) — the prominent upper boundary\nsat_df = rh_df[rh_df[\"rh\"] == \"100%\"]\nsaturation = (\n    alt.Chart(sat_df)\n    .mark_line(strokeWidth=3.5, color=CLR_RH)\n    .encode(\n        x=alt.X(\"t_db:Q\", scale=x_scale, title=\"Dry-Bulb Temperature (°C)\"),\n        y=alt.Y(\"w:Q\", scale=y_scale, title=\"Humidity Ratio (g/kg)\"),\n    )\n)\n\nrh_text = (\n    alt.Chart(rh_label_df)\n    .mark_text(fontSize=12, color=CLR_RH, fontWeight=\"bold\")\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# HVAC process path (matte red) — focal point with state-point markers\nprocess_line = (\n    alt.Chart(process_points)\n    .mark_line(strokeWidth=3.5, color=CLR_PROCESS, point=alt.OverlayMarkDef(size=130, filled=True, color=CLR_PROCESS))\n    .encode(\n        x=alt.X(\"t_db:Q\", scale=x_scale),\n        y=alt.Y(\"w:Q\", scale=y_scale),\n        order=\"order:Q\",\n        tooltip=[\n            alt.Tooltip(\"label:N\", title=\"State Point\"),\n            alt.Tooltip(\"t_db:Q\", title=\"Dry-Bulb (°C)\", format=\".1f\"),\n            alt.Tooltip(\"w:Q\", title=\"Humidity (g/kg)\", format=\".1f\"),\n            alt.Tooltip(\"rh_pct:N\", title=\"RH\"),\n        ],\n    )\n)\n\noutdoor_label = (\n    alt.Chart(process_points[process_points[\"order\"] == 0])\n    .mark_text(fontSize=11, fontWeight=\"bold\", color=CLR_PROCESS, align=\"right\", dx=-14, dy=-16)\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), text=\"label:N\")\n)\n\nsupply_label = (\n    alt.Chart(process_points[process_points[\"order\"] == 1])\n    .mark_text(fontSize=11, fontWeight=\"bold\", color=CLR_PROCESS, align=\"right\", dx=-14, dy=16)\n    .encode(x=alt.X(\"t_db:Q\", scale=x_scale), y=alt.Y(\"w:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Layer all elements (faint property grids first, prominent features last)\nchart = (\n    alt.layer(\n        comfort,\n        vol_chart,\n        enthalpy_chart,\n        wb_chart,\n        rh_chart,\n        saturation,\n        vol_text,\n        enthalpy_text,\n        wb_text,\n        rh_text,\n        comfort_label,\n        process_line,\n        outdoor_label,\n        supply_label,\n    )\n    .properties(\n        width=640,\n        height=330,\n        background=PAGE_BG,\n        title=alt.Title(\n            text=\"psychrometric-basic · python · altair · anyplot.ai\",\n            fontSize=16,\n            anchor=\"middle\",\n            color=INK,\n            subtitle=\"Standard Atmosphere (101.325 kPa) · Moist-Air Properties for HVAC\",\n            subtitleFontSize=11,\n            subtitleColor=INK_SOFT,\n            offset=10,\n        ),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        titleColor=INK,\n        labelColor=INK_SOFT,\n        grid=True,\n        gridOpacity=0.15,\n        gridColor=INK,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n    )\n)\n\n# Save — interactive HTML untouched; PNG padded to the exact canonical target.\nchart.save(f\"plot-{THEME}.html\")\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}