{"spec_id":"psychrometric-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\npsychrometric-basic: Psychrometric Chart for HVAC\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-06-16\n\"\"\"\n\nimport math\nimport os\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# pygal stores its layout (margin_box + coordinate view) in transient state that\n# is discarded after render(); keeping it lets us place direct labels at exact\n# data coordinates instead of estimating the plot box.\nos.environ[\"PYGAL_KEEP_STATE\"] = \"1\"\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nGRID = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\n# Imprint palette — one hue family per psychrometric property type\nGREEN = \"#009E73\"  # comfort zone (good / comfort → green)\nBLUE = \"#4467A3\"  # relative-humidity curves (moisture / water → blue)\nCYAN = \"#2ABCCD\"  # wet-bulb temperature lines\nLAV = \"#C475FD\"  # specific-volume lines\nOCHRE = \"#BD8233\"  # enthalpy lines (energy → warm earth)\nRED = \"#AE3030\"  # HVAC process path (highlighted action)\n\nP_ATM = 101325.0  # Pa — standard sea-level atmosphere\n\n# Data — saturation vapour pressure grid (ASHRAE 2017), vectorised once\nt_grid = np.linspace(-15, 55, 1400)\ntk = t_grid + 273.15\nln_pws = np.where(\n    t_grid >= 0,\n    -5.8002206e3 / tk\n    + 1.3914993\n    - 4.8640239e-2 * tk\n    + 4.1764768e-5 * tk**2\n    - 1.4452093e-8 * tk**3\n    + 6.5459673 * np.log(tk),\n    -5.6745359e3 / tk\n    + 6.3925247\n    - 9.6778430e-3 * tk\n    + 6.2215701e-7 * tk**2\n    + 2.0747825e-9 * tk**3\n    - 9.4840240e-13 * tk**4\n    + 4.1635019 * np.log(tk),\n)\npws_grid = np.exp(ln_pws)\nwsat_grid = 0.62198 * pws_grid / (P_ATM - pws_grid) * 1000  # g/kg at saturation\n\n# Relative-humidity curves (10%–100%)\nrh_levels = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1]\nt_curve = np.linspace(-10, 50, 240)\npws_curve = np.interp(t_curve, t_grid, pws_grid)\nrh_curves = {}\nfor rh in rh_levels:\n    pw = rh * pws_curve\n    w = 0.62198 * pw / (P_ATM - pw) * 1000\n    rh_curves[rh] = [\n        (round(float(t), 2), round(float(wi), 3)) for t, wi in zip(t_curve, w, strict=True) if 0 <= wi <= 30\n    ]\n\n# Constant wet-bulb lines (ASHRAE psychrometric energy balance, analytical)\nwb_temps = [0, 5, 10, 15, 20, 25, 30]\nwb_lines = {}\nfor tw in wb_temps:\n    pws_wb = float(np.interp(tw, t_grid, pws_grid))\n    w_swb = 0.62198 * pws_wb / (P_ATM - pws_wb)  # kg/kg at saturation\n    t_db = np.linspace(tw, 50, 120)\n    w = ((2501 - 2.326 * tw) * w_swb - 1.006 * (t_db - tw)) / (2501 + 1.86 * t_db - 4.186 * tw) * 1000\n    w_sat = np.interp(t_db, t_grid, wsat_grid)\n    wb_lines[tw] = [\n        (round(float(t), 2), round(float(wi), 3))\n        for t, wi, ws in zip(t_db, w, w_sat, strict=True)\n        if 0 <= wi <= min(30, ws + 0.1)\n    ]\n\n# Constant enthalpy and specific-volume lines share the dry-bulb sweep\nt_line = np.linspace(-10, 50, 220)\nwsat_line = np.interp(t_line, t_grid, wsat_grid)\n\nenthalpy_values = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]\nenthalpy_lines = {}\nfor h in enthalpy_values:\n    w = (h - 1.006 * t_line) / (2501 + 1.86 * t_line) * 1000\n    enthalpy_lines[h] = [\n        (round(float(t), 2), round(float(wi), 3))\n        for t, wi, ws in zip(t_line, w, wsat_line, strict=True)\n        if 0 <= wi <= min(30, ws + 0.1)\n    ]\n\nsv_values = [0.80, 0.84, 0.88, 0.92, 0.96]\nsv_lines = {}\nfor v in sv_values:\n    w = (v * P_ATM / 1000 / (0.287042 * (t_line + 273.15)) - 1) / 1.6078 * 1000\n    sv_lines[v] = [\n        (round(float(t), 2), round(float(wi), 3))\n        for t, wi, ws in zip(t_line, w, wsat_line, strict=True)\n        if 0 <= wi <= min(30, ws + 0.1)\n    ]\n\n# Comfort zone polygon (20–26 °C, 30–60% RH)\nct = np.linspace(20, 26, 30)\npws_c = np.interp(ct, t_grid, pws_grid)\nw_low = 0.62198 * (0.30 * pws_c) / (P_ATM - 0.30 * pws_c) * 1000\nw_high = 0.62198 * (0.60 * pws_c) / (P_ATM - 0.60 * pws_c) * 1000\ncomfort_pts = [(round(float(t), 2), round(float(w), 3)) for t, w in zip(ct, w_low, strict=True)]\ncomfort_pts += [(round(float(t), 2), round(float(w), 3)) for t, w in zip(ct[::-1], w_high[::-1], strict=True)]\ncomfort_pts.append(comfort_pts[0])\n\n# HVAC process path — cooling & dehumidification (35 °C/60% RH → 24 °C/50% RH)\npa = 0.60 * float(np.interp(35.0, t_grid, pws_grid))\npb = 0.50 * float(np.interp(24.0, t_grid, pws_grid))\nstate_a = (35.0, round(0.62198 * pa / (P_ATM - pa) * 1000, 3))\nstate_b = (24.0, round(0.62198 * pb / (P_ATM - pb) * 1000, 3))\n\n# Style — palette assigned per series in add order (comfort first → brand green)\npalette = (GREEN,) + (BLUE,) * len(rh_levels) + (CYAN,) * len(wb_temps)\npalette += (LAV,) * len(sv_values) + (OCHRE,) * len(enthalpy_values) + (RED,)\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK_SOFT,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    opacity=\"0.95\",\n    opacity_hover=\"1\",\n    colors=palette,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    tooltip_font_size=30,\n    stroke_width=2.4,\n    font_family=\"Helvetica Neue, Helvetica, Arial, sans-serif\",\n    guide_stroke_color=GRID,\n    major_guide_stroke_color=GRID,\n)\n\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    explicit_size=True,\n    style=custom_style,\n    title=\"psychrometric-basic · python · pygal · anyplot.ai\",\n    x_title=\"Dry-Bulb Temperature (°C)\",\n    y_title=\"Humidity Ratio (g/kg)\",\n    show_legend=False,\n    dots_size=0,\n    stroke=True,\n    show_x_guides=True,\n    show_y_guides=True,\n    xrange=(-10, 50),\n    range=(0, 30),\n    x_labels=list(range(-10, 51, 5)),\n    y_labels=list(range(0, 31, 5)),\n    print_values=False,\n    truncate_label=-1,\n    margin_top=30,\n    margin_bottom=20,\n    margin_left=30,\n    margin_right=40,\n)\n\n# Comfort zone first → brand green, and drawn beneath every property line\nchart.add(\"Comfort Zone\", comfort_pts, show_dots=False, fill=True, stroke_style={\"width\": 2.4})\n\n# Relative-humidity curves — saturation (100%) thickest and most prominent\nfor rh in rh_levels:\n    width = 6.0 if rh == 1.0 else 2.2\n    chart.add(\n        f\"{int(rh * 100)}% RH\",\n        rh_curves[rh],\n        show_dots=False,\n        stroke_style={\"width\": width, \"linecap\": \"round\", \"linejoin\": \"round\"},\n    )\n\n# Wet-bulb lines — dashed cyan\nfor tw in wb_temps:\n    chart.add(\n        f\"Tw={tw}°C\",\n        wb_lines[tw],\n        show_dots=False,\n        stroke_style={\"width\": 2.0, \"dasharray\": \"12, 9\", \"linecap\": \"round\"},\n    )\n\n# Specific-volume lines — dotted lavender\nfor v in sv_values:\n    chart.add(\n        f\"v={v} m³/kg\",\n        sv_lines[v],\n        show_dots=False,\n        stroke_style={\"width\": 2.8, \"dasharray\": \"5, 6\", \"linecap\": \"round\"},\n    )\n\n# Enthalpy lines — dash-dot ochre\nfor h in enthalpy_values:\n    chart.add(\n        f\"h={h} kJ/kg\",\n        enthalpy_lines[h],\n        show_dots=False,\n        stroke_style={\"width\": 1.8, \"dasharray\": \"16, 6, 3, 6\", \"linecap\": \"round\"},\n    )\n\n# HVAC process path — bold red with state-point markers, on top\nchart.add(\n    \"Cooling & Dehumidification\",\n    [state_a, state_b],\n    show_dots=True,\n    dots_size=13,\n    stroke_style={\"width\": 5.0, \"linecap\": \"round\"},\n)\n\nsvg = chart.render(is_unicode=True)\n\n# Soften the comfort-zone fill so it tints rather than masks the RH curves. An inline\n# style attribute beats pygal's stylesheet fill-opacity; it targets the only filled\n# path (serie-0, the lone `line reactive` class without `nofill`).\nsvg = svg.replace('class=\"line reactive\"', 'class=\"line reactive\" style=\"fill-opacity:0.16\"', 1)\n\n# Direct labels — exact data→pixel mapping from pygal's own (linear) view transform\nox, oy = chart.margin_box.left, chart.margin_box.top\npx_per_t = chart.view.x(1.0) - chart.view.x(0.0)\npx_per_w = chart.view.y(1.0) - chart.view.y(0.0)\nx_at_0 = ox + chart.view.x(0.0)\ny_at_0 = oy + chart.view.y(0.0)\n\n# Pixel coords for each property line, reused for placement and slope\nwb_px = {tw: [(x_at_0 + t * px_per_t, y_at_0 + w * px_per_w) for t, w in pts] for tw, pts in wb_lines.items()}\nen_px = {h: [(x_at_0 + t * px_per_t, y_at_0 + w * px_per_w) for t, w in pts] for h, pts in enthalpy_lines.items()}\nsv_px = {v: [(x_at_0 + t * px_per_t, y_at_0 + w * px_per_w) for t, w in pts] for v, pts in sv_lines.items()}\n\nlabels = []\n\n# RH curve labels at staggered temperatures so they ride the curves without piling up\nrh_label_t = {1.0: 7, 0.8: 13, 0.6: 19, 0.4: 26, 0.2: 35}\nfor rh, t_l in rh_label_t.items():\n    pws_l = float(np.interp(t_l, t_grid, pws_grid))\n    w_l = 0.62198 * (rh * pws_l) / (P_ATM - rh * pws_l) * 1000\n    sx = x_at_0 + t_l * px_per_t\n    sy = y_at_0 + w_l * px_per_w\n    labels.append((sx, sy - 14, f\"{int(rh * 100)}%\", BLUE, 36, \"middle\", 0))\n\n# Diagonal family labels — rotated to match each line's local slope\ndiagonals = [\n    (wb_px, [5, 15, 25], 0.50, \"Tw {k}°C\", CYAN, 32),\n    (en_px, [20, 40, 60], 0.40, \"h={k} kJ/kg\", OCHRE, 32),\n    (sv_px, [0.84, 0.92], 0.66, \"v={k} m³/kg\", LAV, 30),\n]\nfor line_px, keys, frac, template, color, size in diagonals:\n    for k in keys:\n        pts = line_px[k]\n        i = int(len(pts) * frac)\n        sx, sy = pts[i]\n        x0, y0 = pts[max(0, i - 4)]\n        x1, y1 = pts[min(len(pts) - 1, i + 4)]\n        ang = math.degrees(math.atan2(y1 - y0, x1 - x0))\n        labels.append((sx, sy - 12, template.format(k=k), color, size, \"middle\", ang))\n\n# Comfort zone, HVAC state points\n# Anchored low in the zone (≈37% RH) so it clears the crowded 40%/60% RH band above\ncz_pws = float(np.interp(23, t_grid, pws_grid))\ncz_w = 0.62198 * (0.37 * cz_pws) / (P_ATM - 0.37 * cz_pws) * 1000\nlabels.append((x_at_0 + 23 * px_per_t, y_at_0 + cz_w * px_per_w, \"Comfort Zone\", GREEN, 36, \"middle\", 0))\n\nax, ay = x_at_0 + state_a[0] * px_per_t, y_at_0 + state_a[1] * px_per_w\nlabels.append((ax + 24, ay - 16, \"A · 35°C, 60% RH\", RED, 34, \"start\", 0))\nbx, by = x_at_0 + state_b[0] * px_per_t, y_at_0 + state_b[1] * px_per_w\nlabels.append((bx - 24, by - 18, \"B · 24°C, 50% RH\", RED, 34, \"end\", 0))\n\n# A thin page-background halo (paint-order stroke) keeps labels legible over lines\nlabel_svg = []\nfor sx, sy, text, fill, size, anchor, ang in labels:\n    transform = f' transform=\"rotate({ang:.1f},{sx:.1f},{sy:.1f})\"' if abs(ang) > 0.5 else \"\"\n    label_svg.append(\n        f'<text x=\"{sx:.1f}\" y=\"{sy:.1f}\" font-size=\"{size}\" '\n        f'font-family=\"Helvetica Neue, Helvetica, Arial, sans-serif\" font-weight=\"bold\" '\n        f'fill=\"{fill}\" stroke=\"{PAGE_BG}\" stroke-width=\"4\" paint-order=\"stroke\" '\n        f'text-anchor=\"{anchor}\"{transform}>{text}</text>'\n    )\n\nsvg = svg.replace(\"</svg>\", \"\\n\".join(label_svg) + \"\\n</svg>\")\n\n# Save — theme-suffixed PNG (gallery) + interactive HTML (pygal is interactive)\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(svg)\n\ncairosvg.svg2png(bytestring=svg.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\", output_width=3200, output_height=1800)\n"}