|
| 1 | +""" pyplots.ai |
| 2 | +psychrometric-basic: Psychrometric Chart for HVAC |
| 3 | +Library: plotnine 0.15.3 | Python 3.14.3 |
| 4 | +Quality: 87/100 | Created: 2026-03-15 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pandas as pd |
| 9 | +from plotnine import ( |
| 10 | + aes, |
| 11 | + annotate, |
| 12 | + arrow, |
| 13 | + coord_cartesian, |
| 14 | + element_blank, |
| 15 | + element_line, |
| 16 | + element_rect, |
| 17 | + element_text, |
| 18 | + geom_line, |
| 19 | + geom_point, |
| 20 | + geom_polygon, |
| 21 | + geom_ribbon, |
| 22 | + geom_segment, |
| 23 | + geom_text, |
| 24 | + ggplot, |
| 25 | + labs, |
| 26 | + scale_alpha_identity, |
| 27 | + scale_color_identity, |
| 28 | + scale_linetype_identity, |
| 29 | + scale_size_identity, |
| 30 | + scale_x_continuous, |
| 31 | + scale_y_continuous, |
| 32 | + theme, |
| 33 | + theme_minimal, |
| 34 | +) |
| 35 | + |
| 36 | + |
| 37 | +# Constants |
| 38 | +PATM = 101.325 # kPa, standard atmospheric pressure |
| 39 | +T_MIN, T_MAX = -10, 50 |
| 40 | +W_MAX = 30 |
| 41 | + |
| 42 | +t_range = np.linspace(T_MIN, T_MAX, 300) |
| 43 | + |
| 44 | +# Inline saturation vapor pressure (Magnus formula) and humidity ratio calculations |
| 45 | +# psat(t) = 0.61078 * exp(17.27 * t / (t + 237.3)) |
| 46 | +# w = 622 * pw / (PATM - pw), where pw = rh/100 * psat |
| 47 | + |
| 48 | +# Relative humidity curves (10%-100%) |
| 49 | +rh_frames = [] |
| 50 | +for rh in range(10, 110, 10): |
| 51 | + pw = np.minimum(rh / 100.0 * 0.61078 * np.exp(17.27 * t_range / (t_range + 237.3)), PATM - 0.01) |
| 52 | + w = 622.0 * pw / (PATM - pw) |
| 53 | + mask = (w >= 0) & (w <= W_MAX) |
| 54 | + rh_frames.append( |
| 55 | + pd.DataFrame( |
| 56 | + { |
| 57 | + "t": t_range[mask], |
| 58 | + "w": w[mask], |
| 59 | + "group": f"RH {rh}%", |
| 60 | + "color": "#1B4F72" if rh == 100 else "#7FB3D8", |
| 61 | + "sz": 2.2 if rh == 100 else 0.55, |
| 62 | + "alpha": 1.0 if rh == 100 else 0.75, |
| 63 | + } |
| 64 | + ) |
| 65 | + ) |
| 66 | +df_rh = pd.concat(rh_frames, ignore_index=True) |
| 67 | + |
| 68 | +# RH labels: carefully positioned to avoid overlap with state points and wet-bulb labels |
| 69 | +# Moved 50% label away from point A (35°C), shifted others for even spacing |
| 70 | +rh_label_pos = {10: 48, 20: 44, 30: 40, 40: 36, 50: 17, 60: 32, 70: 13, 80: 9, 90: 4, 100: -5} |
| 71 | +rh_labels = [] |
| 72 | +for rh, t_l in rh_label_pos.items(): |
| 73 | + pw_l = rh / 100.0 * 0.61078 * np.exp(17.27 * t_l / (t_l + 237.3)) |
| 74 | + pw_l = min(pw_l, PATM - 0.01) |
| 75 | + w_l = 622.0 * pw_l / (PATM - pw_l) |
| 76 | + if 0 < w_l < W_MAX: |
| 77 | + rh_labels.append({"t": t_l, "w": w_l + 1.2, "label": f"{rh}%"}) |
| 78 | +df_rh_labels = pd.DataFrame(rh_labels) |
| 79 | + |
| 80 | +# Wet-bulb temperature lines |
| 81 | +wb_frames = [] |
| 82 | +for twb in range(-5, 35, 5): |
| 83 | + pws_wb = 0.61078 * np.exp(17.27 * twb / (twb + 237.3)) |
| 84 | + ws_wb = 622.0 * pws_wb / (PATM - pws_wb) |
| 85 | + t_line = np.linspace(twb, min(twb + 30, T_MAX), 150) |
| 86 | + w_line = ((2501.0 - 2.326 * twb) * ws_wb - 1006.0 * (t_line - twb)) / (2501.0 + 1.86 * t_line - 4.186 * twb) |
| 87 | + mask = (w_line >= 0) & (w_line <= W_MAX) & (t_line >= T_MIN) |
| 88 | + if np.sum(mask) > 2: |
| 89 | + wb_frames.append(pd.DataFrame({"t": t_line[mask], "w": w_line[mask], "group": f"WB {twb}°C"})) |
| 90 | +df_wb = pd.concat(wb_frames, ignore_index=True) |
| 91 | + |
| 92 | +# Wet-bulb labels: placed at right end of lines (lower-right), away from saturation curve |
| 93 | +wb_labels = [] |
| 94 | +for twb in range(0, 35, 10): |
| 95 | + sub = df_wb[df_wb["group"] == f"WB {twb}°C"] |
| 96 | + if len(sub) > 0: |
| 97 | + row = sub.loc[sub["t"].idxmax()] |
| 98 | + wb_labels.append({"t": row["t"] + 1.5, "w": max(row["w"] - 0.3, 0.5), "label": f"{twb}°C"}) |
| 99 | +df_wb_labels = pd.DataFrame(wb_labels) |
| 100 | + |
| 101 | +# Enthalpy lines (kJ/kg dry air) - wider spacing to reduce overlap |
| 102 | +enth_frames = [] |
| 103 | +for h in range(10, 130, 20): |
| 104 | + t_line = np.linspace(T_MIN, T_MAX, 200) |
| 105 | + w_line = (h - 1.006 * t_line) / (2.501 + 0.00186 * t_line) |
| 106 | + mask = (w_line >= 0) & (w_line <= W_MAX) & (t_line >= T_MIN) & (t_line <= T_MAX) |
| 107 | + if np.sum(mask) > 2: |
| 108 | + enth_frames.append(pd.DataFrame({"t": t_line[mask], "w": w_line[mask], "group": f"h={h}"})) |
| 109 | +df_enth = pd.concat(enth_frames, ignore_index=True) if enth_frames else pd.DataFrame(columns=["t", "w", "group"]) |
| 110 | + |
| 111 | +# Enthalpy labels at the right/bottom end of lines (away from saturation curve) |
| 112 | +enth_labels = [] |
| 113 | +for h in range(10, 130, 20): |
| 114 | + sub = df_enth[df_enth["group"] == f"h={h}"] |
| 115 | + if len(sub) > 0: |
| 116 | + row = sub.loc[sub["t"].idxmax()] |
| 117 | + if row["w"] > 0.5: |
| 118 | + enth_labels.append({"t": row["t"] + 1.0, "w": max(row["w"] - 0.5, 0.5), "label": f"{h} kJ/kg"}) |
| 119 | +df_enth_labels = pd.DataFrame(enth_labels) |
| 120 | + |
| 121 | +# Specific volume lines (m³/kg dry air) - wider step for clarity |
| 122 | +sv_frames = [] |
| 123 | +for v_10 in range(80, 94, 2): |
| 124 | + v = v_10 / 100.0 |
| 125 | + t_line = np.linspace(T_MIN, T_MAX, 200) |
| 126 | + w_line = (v * PATM / (0.287055 * (t_line + 273.15)) - 1.0) / 1.6078 * 1000.0 |
| 127 | + mask = (w_line >= 0) & (w_line <= W_MAX) & (t_line >= T_MIN) & (t_line <= T_MAX) |
| 128 | + if np.sum(mask) > 2: |
| 129 | + sv_frames.append(pd.DataFrame({"t": t_line[mask], "w": w_line[mask], "group": f"v={v:.2f}"})) |
| 130 | +df_sv = pd.concat(sv_frames, ignore_index=True) if sv_frames else pd.DataFrame(columns=["t", "w", "group"]) |
| 131 | + |
| 132 | +# Specific volume labels at bottom-right end (well separated from enthalpy labels) |
| 133 | +sv_labels = [] |
| 134 | +for v_10 in range(80, 94, 4): |
| 135 | + v = v_10 / 100.0 |
| 136 | + sub = df_sv[df_sv["group"] == f"v={v:.2f}"] |
| 137 | + if len(sub) > 0: |
| 138 | + row = sub.loc[sub["t"].idxmax()] |
| 139 | + sv_labels.append( |
| 140 | + {"t": min(row["t"] + 1.0, T_MAX - 1), "w": max(row["w"] - 0.8, 0.5), "label": f"{v:.2f} m³/kg"} |
| 141 | + ) |
| 142 | +df_sv_labels = pd.DataFrame(sv_labels) |
| 143 | + |
| 144 | +# Comfort zone polygon (20-26°C, 30-60% RH) |
| 145 | +pw_cz_lo = np.minimum( |
| 146 | + 0.30 * 0.61078 * np.exp(17.27 * np.array([20.0, 26.0]) / (np.array([20.0, 26.0]) + 237.3)), PATM - 0.01 |
| 147 | +) |
| 148 | +c_w_lo = 622.0 * pw_cz_lo / (PATM - pw_cz_lo) |
| 149 | +pw_cz_hi = np.minimum( |
| 150 | + 0.60 * 0.61078 * np.exp(17.27 * np.array([20.0, 26.0]) / (np.array([20.0, 26.0]) + 237.3)), PATM - 0.01 |
| 151 | +) |
| 152 | +c_w_hi = 622.0 * pw_cz_hi / (PATM - pw_cz_hi) |
| 153 | +df_comfort = pd.DataFrame({"t": [20, 26, 26, 20, 20], "w": [c_w_lo[0], c_w_lo[1], c_w_hi[1], c_w_hi[0], c_w_lo[0]]}) |
| 154 | + |
| 155 | +# Comfort zone ribbon for shading between RH 30% and 60% within 20-26°C |
| 156 | +t_comfort = np.linspace(20, 26, 50) |
| 157 | +pw_crib_lo = np.minimum(0.30 * 0.61078 * np.exp(17.27 * t_comfort / (t_comfort + 237.3)), PATM - 0.01) |
| 158 | +pw_crib_hi = np.minimum(0.60 * 0.61078 * np.exp(17.27 * t_comfort / (t_comfort + 237.3)), PATM - 0.01) |
| 159 | +df_comfort_ribbon = pd.DataFrame( |
| 160 | + {"t": t_comfort, "w_lo": 622.0 * pw_crib_lo / (PATM - pw_crib_lo), "w_hi": 622.0 * pw_crib_hi / (PATM - pw_crib_hi)} |
| 161 | +) |
| 162 | + |
| 163 | +# HVAC process: cooling and dehumidification |
| 164 | +pw_a = min(0.50 * 0.61078 * np.exp(17.27 * 35 / (35 + 237.3)), PATM - 0.01) |
| 165 | +w_a = 622.0 * pw_a / (PATM - pw_a) |
| 166 | +pw_b = min(0.50 * 0.61078 * np.exp(17.27 * 24 / (24 + 237.3)), PATM - 0.01) |
| 167 | +w_b = 622.0 * pw_b / (PATM - pw_b) |
| 168 | +df_states = pd.DataFrame({"t": [35, 24], "w": [w_a, w_b], "label": ["A", "B"]}) |
| 169 | +df_arrow = pd.DataFrame({"x": [35], "y": [w_a], "xend": [24], "yend": [w_b]}) |
| 170 | + |
| 171 | +# Add linetype columns for identity scale |
| 172 | +df_enth["lt"] = "dashed" |
| 173 | +df_sv["lt"] = "dotted" |
| 174 | + |
| 175 | +# Build plot using plotnine grammar of graphics with layered composition |
| 176 | +plot = ( |
| 177 | + ggplot() |
| 178 | + # Comfort zone ribbon (plotnine-distinctive: geom_ribbon with ymin/ymax mapping) |
| 179 | + + geom_ribbon(aes(x="t", ymin="w_lo", ymax="w_hi"), data=df_comfort_ribbon, fill="#306998", alpha=0.12) |
| 180 | + # Comfort zone border |
| 181 | + + geom_polygon( |
| 182 | + aes(x="t", y="w"), data=df_comfort, fill="none", color="#306998", size=0.8, linetype="solid", alpha=0.6 |
| 183 | + ) |
| 184 | + # RH curves with identity aesthetics for per-group color/size/alpha |
| 185 | + + geom_line(aes(x="t", y="w", group="group", color="color", size="sz", alpha="alpha"), data=df_rh) |
| 186 | + + scale_color_identity() |
| 187 | + + scale_size_identity() |
| 188 | + + scale_alpha_identity() |
| 189 | + # Wet-bulb lines - warm orange, distinct from RH blues |
| 190 | + + geom_line(aes(x="t", y="w", group="group"), data=df_wb, color="#D4760A", size=0.55, alpha=0.65) |
| 191 | + # Enthalpy lines - olive green, dashed, increased visibility |
| 192 | + + geom_line(aes(x="t", y="w", group="group", linetype="lt"), data=df_enth, color="#5B8C3E", size=0.6, alpha=0.75) |
| 193 | + # Specific volume lines - muted purple, dotted, distinct from enthalpy |
| 194 | + + geom_line(aes(x="t", y="w", group="group", linetype="lt"), data=df_sv, color="#8E5EA2", size=0.5, alpha=0.6) |
| 195 | + + scale_linetype_identity() |
| 196 | + # HVAC process arrow |
| 197 | + + geom_segment( |
| 198 | + aes(x="x", y="y", xend="xend", yend="yend"), |
| 199 | + data=df_arrow, |
| 200 | + color="#C0392B", |
| 201 | + size=2.0, |
| 202 | + arrow=arrow(length=0.18, type="closed"), |
| 203 | + ) |
| 204 | + # State points with fill identity |
| 205 | + + geom_point(aes(x="t", y="w"), data=df_states, color="#C0392B", fill="#E74C3C", size=5, shape="o") |
| 206 | + # RH labels - larger, bold, positioned away from state points |
| 207 | + + geom_text(aes(x="t", y="w", label="label"), data=df_rh_labels, size=9.5, color="#2471A3", fontweight="bold") |
| 208 | + # Wet-bulb labels - at right end of lines, larger |
| 209 | + + geom_text(aes(x="t", y="w", label="label"), data=df_wb_labels, size=9, color="#D4760A", fontstyle="italic") |
| 210 | + # Enthalpy labels at right/bottom end of lines (away from saturation curve) |
| 211 | + + geom_text(aes(x="t", y="w", label="label"), data=df_enth_labels, size=8.5, color="#5B8C3E", ha="left") |
| 212 | + # Specific volume labels at bottom-right of lines |
| 213 | + + geom_text(aes(x="t", y="w", label="label"), data=df_sv_labels, size=8.5, color="#8E5EA2", ha="left") |
| 214 | + # State point annotations - repositioned to avoid comfort zone overlap |
| 215 | + + annotate("text", x=37, y=w_a + 1.5, label="A (35°C)", size=10, color="#C0392B", ha="left", fontweight="bold") |
| 216 | + + annotate("text", x=29, y=w_b + 1.8, label="B (24°C)", size=9.5, color="#C0392B", ha="left", fontweight="bold") |
| 217 | + # Comfort zone label - centered in the zone |
| 218 | + + annotate( |
| 219 | + "text", |
| 220 | + x=23, |
| 221 | + y=(c_w_lo.mean() + c_w_hi.mean()) / 2, |
| 222 | + label="Comfort\nZone", |
| 223 | + size=9, |
| 224 | + color="#306998", |
| 225 | + alpha=0.7, |
| 226 | + fontweight="bold", |
| 227 | + ha="center", |
| 228 | + va="center", |
| 229 | + ) |
| 230 | + # Axes |
| 231 | + + labs( |
| 232 | + x="Dry-Bulb Temperature (°C)", |
| 233 | + y="Humidity Ratio (g/kg dry air)", |
| 234 | + title="psychrometric-basic · plotnine · pyplots.ai", |
| 235 | + ) |
| 236 | + + scale_x_continuous(breaks=range(T_MIN, T_MAX + 1, 5)) |
| 237 | + + scale_y_continuous(breaks=range(0, W_MAX + 1, 5)) |
| 238 | + + coord_cartesian(xlim=(T_MIN - 1, T_MAX + 5), ylim=(0, W_MAX)) |
| 239 | + # Theme: polished publication style |
| 240 | + + theme_minimal() |
| 241 | + + theme( |
| 242 | + figure_size=(16, 9), |
| 243 | + plot_title=element_text(size=24, weight="bold", color="#1B2631"), |
| 244 | + axis_title_x=element_text(size=20, color="#2C3E50"), |
| 245 | + axis_title_y=element_text(size=20, color="#2C3E50"), |
| 246 | + axis_text=element_text(size=16, color="#566573"), |
| 247 | + panel_grid_major=element_line(color="#E8E8E8", size=0.25), |
| 248 | + panel_grid_minor=element_blank(), |
| 249 | + legend_position="none", |
| 250 | + plot_background=element_rect(fill="#FAFBFC", color="none"), |
| 251 | + panel_background=element_rect(fill="#FAFBFC", color="none"), |
| 252 | + ) |
| 253 | +) |
| 254 | + |
| 255 | +plot.save("plot.png", dpi=300) |
0 commit comments