Skip to content

Commit 587651a

Browse files
feat(letsplot): implement psychrometric-basic (#4889)
## Implementation: `psychrometric-basic` - letsplot Implements the **letsplot** version of `psychrometric-basic`. **File:** `plots/psychrometric-basic/implementations/letsplot.py` **Parent Issue:** #4583 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23120046652)* --------- 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 045a81b commit 587651a

2 files changed

Lines changed: 592 additions & 0 deletions

File tree

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
""" pyplots.ai
2+
psychrometric-basic: Psychrometric Chart for HVAC
3+
Library: letsplot 4.9.0 | Python 3.14.3
4+
Quality: 81/100 | Created: 2026-03-15
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import (
10+
LetsPlot,
11+
aes,
12+
element_blank,
13+
element_line,
14+
element_rect,
15+
element_text,
16+
geom_line,
17+
geom_path,
18+
geom_point,
19+
geom_polygon,
20+
geom_segment,
21+
geom_text,
22+
ggplot,
23+
ggsize,
24+
labs,
25+
scale_color_identity,
26+
scale_fill_identity,
27+
scale_x_continuous,
28+
scale_y_continuous,
29+
theme,
30+
theme_minimal,
31+
)
32+
from lets_plot.export import ggsave
33+
34+
35+
LetsPlot.setup_html()
36+
37+
# Constants
38+
P_ATM = 101325.0 # Pa, standard atmospheric pressure
39+
40+
# Colorblind-safe palette (no red-green)
41+
COLOR_SATURATION = "#306998" # Python blue for 100% RH
42+
COLOR_WET_BULB = "#0072B2" # Blue (colorblind-safe)
43+
COLOR_ENTHALPY = "#D55E00" # Vermillion (colorblind-safe)
44+
COLOR_VOLUME = "#9467bd" # Purple
45+
COLOR_COMFORT = "#306998" # Python blue
46+
COLOR_PROCESS = "#e67e22" # Orange
47+
48+
49+
# Psychrometric equations (ASHRAE-based)
50+
def saturation_pressure(t):
51+
t_k = t + 273.15
52+
if t >= 0:
53+
ln_ps = (
54+
-5.8002206e3 / t_k
55+
+ 1.3914993
56+
- 4.8640239e-2 * t_k
57+
+ 4.1764768e-5 * t_k**2
58+
- 1.4452093e-8 * t_k**3
59+
+ 6.5459673 * np.log(t_k)
60+
)
61+
else:
62+
ln_ps = (
63+
-5.6745359e3 / t_k
64+
+ 6.3925247
65+
- 9.677843e-3 * t_k
66+
+ 6.2215701e-7 * t_k**2
67+
+ 2.0747825e-9 * t_k**3
68+
- 9.484024e-13 * t_k**4
69+
+ 4.1635019 * np.log(t_k)
70+
)
71+
return np.exp(ln_ps)
72+
73+
74+
def humidity_ratio(t_db, rh):
75+
p_s = saturation_pressure(t_db)
76+
p_w = rh * p_s
77+
return 0.621945 * p_w / (P_ATM - p_w)
78+
79+
80+
def wet_bulb_line(t_wb, t_db_range):
81+
w_sat = humidity_ratio(t_wb, 1.0)
82+
h_fg = 2501.0
83+
cp_a = 1.006
84+
cp_w = 1.86
85+
w_values = []
86+
for t_db in t_db_range:
87+
w = (h_fg * w_sat - cp_a * (t_db - t_wb)) / (h_fg + cp_w * t_db - cp_a * t_wb + (cp_w - cp_a) * t_wb)
88+
w_values.append(max(w, 0))
89+
return np.array(w_values)
90+
91+
92+
# Data - Generate psychrometric curves
93+
t_db_fine = np.linspace(-10, 50, 300)
94+
95+
all_frames = []
96+
97+
# Relative humidity curves (10% to 100%)
98+
rh_colors = {
99+
0.1: "#b0b0b0",
100+
0.2: "#a0a0a0",
101+
0.3: "#909090",
102+
0.4: "#808080",
103+
0.5: "#707070",
104+
0.6: "#606060",
105+
0.7: "#505050",
106+
0.8: "#404040",
107+
0.9: "#303030",
108+
1.0: "#306998",
109+
}
110+
111+
for rh_val in np.arange(0.1, 1.05, 0.1):
112+
rh_val = round(rh_val, 1)
113+
w_values = []
114+
t_valid = []
115+
for t in t_db_fine:
116+
w = humidity_ratio(t, rh_val) * 1000 # convert to g/kg
117+
if 0 <= w <= 30:
118+
w_values.append(w)
119+
t_valid.append(t)
120+
df_rh = pd.DataFrame(
121+
{
122+
"t_db": t_valid,
123+
"w": w_values,
124+
"group": f"RH {int(rh_val * 100)}%",
125+
"color": rh_colors[rh_val],
126+
"line_type": "rh",
127+
}
128+
)
129+
all_frames.append(df_rh)
130+
131+
df_rh_all = pd.concat(all_frames, ignore_index=True)
132+
133+
# Wet-bulb temperature lines
134+
wb_frames = []
135+
wb_temps = [0, 5, 10, 15, 20, 25, 30, 35]
136+
137+
for t_wb in wb_temps:
138+
t_range = np.linspace(t_wb, min(t_wb + 30, 50), 100)
139+
w_vals = wet_bulb_line(t_wb, t_range) * 1000 # g/kg
140+
mask = (w_vals >= 0) & (w_vals <= 30)
141+
df_wb = pd.DataFrame(
142+
{"t_db": t_range[mask], "w": w_vals[mask], "group": f"WB {t_wb}°C", "color": COLOR_WET_BULB, "line_type": "wb"}
143+
)
144+
wb_frames.append(df_wb)
145+
146+
df_wb_all = pd.concat(wb_frames, ignore_index=True)
147+
148+
# Enthalpy lines
149+
enth_frames = []
150+
enthalpy_values_list = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110]
151+
152+
for h_target in enthalpy_values_list:
153+
t_points = []
154+
w_points = []
155+
for t in np.linspace(-10, 50, 200):
156+
w = (h_target - 1.006 * t) / (2501.0 + 1.86 * t)
157+
w_gkg = w * 1000
158+
if 0 <= w_gkg <= 30 and -10 <= t <= 50:
159+
t_points.append(t)
160+
w_points.append(w_gkg)
161+
if len(t_points) > 1:
162+
df_h = pd.DataFrame(
163+
{
164+
"t_db": t_points,
165+
"w": w_points,
166+
"group": f"h={h_target}",
167+
"color": COLOR_ENTHALPY,
168+
"line_type": "enthalpy",
169+
}
170+
)
171+
enth_frames.append(df_h)
172+
173+
df_enth_all = pd.concat(enth_frames, ignore_index=True)
174+
175+
# Specific volume lines
176+
vol_frames = []
177+
vol_values = [0.78, 0.80, 0.82, 0.84, 0.86, 0.88, 0.90, 0.92, 0.94]
178+
179+
for v_target in vol_values:
180+
t_points = []
181+
w_points = []
182+
for t in np.linspace(-10, 50, 200):
183+
w = (v_target * (P_ATM / 1000) / (0.287042 * (t + 273.15)) - 1) / 1.6078
184+
w_gkg = w * 1000
185+
if 0 <= w_gkg <= 30 and -10 <= t <= 50:
186+
t_points.append(t)
187+
w_points.append(w_gkg)
188+
if len(t_points) > 1:
189+
df_v = pd.DataFrame(
190+
{"t_db": t_points, "w": w_points, "group": f"v={v_target}", "color": COLOR_VOLUME, "line_type": "volume"}
191+
)
192+
vol_frames.append(df_v)
193+
194+
df_vol_all = pd.concat(vol_frames, ignore_index=True)
195+
196+
# Comfort zone polygon (20-26°C, humidity ratio at 30-60% RH)
197+
comfort_t = [20, 26, 26, 20, 20]
198+
comfort_w = [
199+
humidity_ratio(20, 0.3) * 1000,
200+
humidity_ratio(26, 0.3) * 1000,
201+
humidity_ratio(26, 0.6) * 1000,
202+
humidity_ratio(20, 0.6) * 1000,
203+
humidity_ratio(20, 0.3) * 1000,
204+
]
205+
df_comfort = pd.DataFrame({"t_db": comfort_t, "w": comfort_w})
206+
207+
# HVAC process path: cooling and dehumidification (32°C, 50% RH → 24°C, 50% RH)
208+
state1_t, state1_rh = 32, 0.50
209+
state2_t, state2_rh = 24, 0.50
210+
state1_w = humidity_ratio(state1_t, state1_rh) * 1000
211+
state2_w = humidity_ratio(state2_t, state2_rh) * 1000
212+
213+
df_process = pd.DataFrame({"t_db": [state1_t, state2_t], "w": [state1_w, state2_w]})
214+
215+
# RH label positions (staggered to avoid overlap)
216+
rh_label_frames = []
217+
for rh_val in np.arange(0.1, 1.05, 0.1):
218+
rh_val = round(rh_val, 1)
219+
t_label = (
220+
44
221+
if rh_val <= 0.2
222+
else (
223+
40
224+
if rh_val == 0.3
225+
else (
226+
34
227+
if rh_val == 0.4
228+
else (
229+
30
230+
if rh_val <= 0.6
231+
else (24 if rh_val == 0.7 else (22 if rh_val == 0.8 else (17 if rh_val == 0.9 else 14)))
232+
)
233+
)
234+
)
235+
)
236+
w_label = humidity_ratio(t_label, rh_val) * 1000
237+
if w_label <= 28:
238+
rh_label_frames.append({"t_db": t_label, "w": w_label, "label": f"{int(rh_val * 100)}%"})
239+
240+
df_rh_labels = pd.DataFrame(rh_label_frames)
241+
242+
# Wet-bulb labels (offset from saturation curve to avoid RH label collision)
243+
wb_label_frames = []
244+
for t_wb in wb_temps:
245+
w_at_sat = humidity_ratio(t_wb, 1.0) * 1000
246+
if w_at_sat <= 28:
247+
wb_label_frames.append({"t_db": t_wb + 2.5, "w": w_at_sat + 1.0, "label": f"{t_wb}°C"})
248+
249+
df_wb_labels = pd.DataFrame(wb_label_frames)
250+
251+
# Enthalpy labels (with units)
252+
enth_label_frames = []
253+
for h_target in enthalpy_values_list:
254+
w_at_zero = (h_target - 1.006 * (-5)) / (2501.0 + 1.86 * (-5))
255+
w_gkg = w_at_zero * 1000
256+
if 0 < w_gkg <= 28:
257+
enth_label_frames.append({"t_db": -8, "w": w_gkg, "label": f"{h_target} kJ/kg"})
258+
259+
df_enth_labels = pd.DataFrame(enth_label_frames)
260+
261+
# Volume labels (with units)
262+
vol_label_frames = []
263+
for v_target in vol_values:
264+
w_at_45 = (v_target * (P_ATM / 1000) / (0.287042 * (45 + 273.15)) - 1) / 1.6078
265+
w_gkg = w_at_45 * 1000
266+
if 0 < w_gkg <= 28:
267+
vol_label_frames.append({"t_db": 46, "w": w_gkg, "label": f"{v_target} m³/kg"})
268+
269+
df_vol_labels = pd.DataFrame(vol_label_frames)
270+
271+
# Plot
272+
plot = (
273+
ggplot()
274+
# Comfort zone
275+
+ geom_polygon(
276+
data=df_comfort,
277+
mapping=aes(x="t_db", y="w"),
278+
fill=COLOR_COMFORT,
279+
alpha=0.10,
280+
color=COLOR_COMFORT,
281+
size=1.0,
282+
linetype="dashed",
283+
)
284+
# RH curves
285+
+ geom_line(data=df_rh_all, mapping=aes(x="t_db", y="w", group="group", color="color"), size=1.0)
286+
# Wet-bulb lines (colorblind-safe blue)
287+
+ geom_line(data=df_wb_all, mapping=aes(x="t_db", y="w", group="group"), color=COLOR_WET_BULB, size=0.6, alpha=0.55)
288+
# Enthalpy lines (colorblind-safe vermillion)
289+
+ geom_line(
290+
data=df_enth_all, mapping=aes(x="t_db", y="w", group="group"), color=COLOR_ENTHALPY, size=0.5, alpha=0.45
291+
)
292+
# Specific volume lines
293+
+ geom_line(data=df_vol_all, mapping=aes(x="t_db", y="w", group="group"), color=COLOR_VOLUME, size=0.5, alpha=0.45)
294+
# HVAC process path with arrow
295+
+ geom_path(
296+
data=df_process,
297+
mapping=aes(x="t_db", y="w"),
298+
color=COLOR_PROCESS,
299+
size=2.5,
300+
arrow={"type": "closed", "length": 12},
301+
)
302+
# State points
303+
+ geom_point(data=df_process, mapping=aes(x="t_db", y="w"), color=COLOR_PROCESS, size=5, shape=16)
304+
# RH labels
305+
+ geom_text(data=df_rh_labels, mapping=aes(x="t_db", y="w", label="label"), size=8, color="#505050")
306+
# Wet-bulb labels
307+
+ geom_text(data=df_wb_labels, mapping=aes(x="t_db", y="w", label="label"), size=7, color=COLOR_WET_BULB)
308+
# Enthalpy labels (with units, on left edge)
309+
+ geom_text(data=df_enth_labels, mapping=aes(x="t_db", y="w", label="label"), size=7, color=COLOR_ENTHALPY, hjust=1)
310+
# Volume labels (with units, on right edge)
311+
+ geom_text(data=df_vol_labels, mapping=aes(x="t_db", y="w", label="label"), size=7, color=COLOR_VOLUME, hjust=0)
312+
# Comfort zone label
313+
+ geom_text(
314+
data=pd.DataFrame({"t_db": [23], "w": [7.5], "label": ["Comfort\nZone"]}),
315+
mapping=aes(x="t_db", y="w", label="label"),
316+
size=10,
317+
color=COLOR_COMFORT,
318+
fontface="bold",
319+
)
320+
# Process label (positioned clear of RH labels)
321+
+ geom_text(
322+
data=pd.DataFrame({"t_db": [40], "w": [16.0], "label": ["Cooling &\nDehumidification"]}),
323+
mapping=aes(x="t_db", y="w", label="label"),
324+
size=9,
325+
color=COLOR_PROCESS,
326+
fontface="bold",
327+
)
328+
# Arrow annotation connecting label to process path
329+
+ geom_segment(
330+
data=pd.DataFrame({"x": [38], "xend": [33], "y": [15.5], "yend": [14.0]}),
331+
mapping=aes(x="x", y="y", xend="xend", yend="yend"),
332+
color=COLOR_PROCESS,
333+
size=0.5,
334+
alpha=0.6,
335+
)
336+
+ scale_color_identity()
337+
+ scale_fill_identity()
338+
+ scale_x_continuous(limits=[-10, 50], breaks=list(range(-10, 55, 5)))
339+
+ scale_y_continuous(limits=[0, 30], breaks=list(range(0, 35, 5)))
340+
+ labs(
341+
x="Dry-Bulb Temperature (°C)", y="Humidity Ratio (g/kg)", title="psychrometric-basic · letsplot · pyplots.ai"
342+
)
343+
+ theme_minimal()
344+
+ theme(
345+
plot_title=element_text(size=24, face="bold", color="#1a1a1a"),
346+
axis_title=element_text(size=20, color="#333333"),
347+
axis_text=element_text(size=16, color="#555555"),
348+
legend_position="none",
349+
panel_grid_major=element_line(color="#e8e8e8", size=0.3),
350+
panel_grid_minor=element_blank(),
351+
plot_background=element_rect(fill="white", color="white"),
352+
panel_background=element_rect(fill="#fafafa"),
353+
)
354+
+ ggsize(1600, 900)
355+
)
356+
357+
# Save
358+
ggsave(plot, "plot.png", path=".", scale=3)
359+
ggsave(plot, "plot.html", path=".")

0 commit comments

Comments
 (0)