Skip to content

Commit ca6e0a2

Browse files
feat(plotnine): implement psychrometric-basic (#4895)
## Implementation: `psychrometric-basic` - plotnine Implements the **plotnine** version of `psychrometric-basic`. **File:** `plots/psychrometric-basic/implementations/plotnine.py` **Parent Issue:** #4583 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23120046621)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent efdb938 commit ca6e0a2

2 files changed

Lines changed: 493 additions & 0 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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

Comments
 (0)