Skip to content

Commit 4a54060

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

2 files changed

Lines changed: 625 additions & 0 deletions

File tree

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
""" pyplots.ai
2+
psychrometric-basic: Psychrometric Chart for HVAC
3+
Library: pygal 3.1.0 | Python 3.14.3
4+
Quality: 78/100 | Created: 2026-03-15
5+
"""
6+
7+
import math
8+
9+
import cairosvg
10+
import numpy as np
11+
import pygal
12+
from pygal.style import Style
13+
14+
15+
# Constants
16+
P_ATM = 101325.0 # Pa (standard atmosphere)
17+
18+
19+
def sat_pressure(t_celsius):
20+
"""ASHRAE saturation vapor pressure (Pa) from dry-bulb temperature."""
21+
tk = t_celsius + 273.15
22+
if t_celsius >= 0:
23+
ln_p = (
24+
-5.8002206e3 / tk
25+
+ 1.3914993
26+
- 4.8640239e-2 * tk
27+
+ 4.1764768e-5 * tk**2
28+
- 1.4452093e-8 * tk**3
29+
+ 6.5459673 * math.log(tk)
30+
)
31+
else:
32+
ln_p = (
33+
-5.6745359e3 / tk
34+
+ 6.3925247
35+
- 9.6778430e-3 * tk
36+
+ 6.2215701e-7 * tk**2
37+
+ 2.0747825e-9 * tk**3
38+
- 9.4840240e-13 * tk**4
39+
+ 4.1635019 * math.log(tk)
40+
)
41+
return math.exp(ln_p)
42+
43+
44+
def humidity_ratio(t_celsius, rh):
45+
"""Humidity ratio (g/kg) from temperature and relative humidity (0-1)."""
46+
pw = rh * sat_pressure(t_celsius)
47+
return 0.62198 * pw / (P_ATM - pw) * 1000
48+
49+
50+
def specific_volume_w(t_celsius, sv_target):
51+
"""Solve analytically for humidity ratio (kg/kg) given temperature and target specific volume."""
52+
tk = t_celsius + 273.15
53+
return (sv_target * P_ATM / 1000 / (0.287042 * tk) - 1) / 1.6078
54+
55+
56+
# Data — precompute all psychrometric curves
57+
t_range = np.linspace(-10, 50, 250)
58+
pws_arr = np.array([sat_pressure(float(t)) for t in t_range])
59+
60+
rh_levels = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1]
61+
rh_curves = {}
62+
for rh in rh_levels:
63+
pts = []
64+
for i, t in enumerate(t_range):
65+
pw = rh * pws_arr[i]
66+
w_gkg = 0.62198 * pw / (P_ATM - pw) * 1000
67+
if 0 <= w_gkg <= 30:
68+
pts.append((round(float(t), 2), round(w_gkg, 3)))
69+
rh_curves[rh] = pts
70+
71+
# Wet-bulb temperature lines (Stull 2011 approximation)
72+
wb_temps = [0, 5, 10, 15, 20, 25, 30]
73+
wb_lines = {}
74+
for tw_target in wb_temps:
75+
pts = []
76+
for t in np.linspace(max(-10, tw_target), min(tw_target + 30, 50), 100):
77+
t_f = float(t)
78+
for rh_pct in np.linspace(1, 100, 500):
79+
tw = (
80+
t_f * math.atan(0.151977 * math.sqrt(rh_pct + 8.313659))
81+
+ math.atan(t_f + rh_pct)
82+
- math.atan(rh_pct - 1.676331)
83+
+ 0.00391838 * rh_pct**1.5 * math.atan(0.023101 * rh_pct)
84+
- 4.686035
85+
)
86+
if abs(tw - tw_target) < 0.25:
87+
w_gkg = humidity_ratio(t_f, rh_pct / 100)
88+
w_sat = humidity_ratio(t_f, 1.0)
89+
if 0 <= w_gkg <= min(30, w_sat + 0.2):
90+
pts.append((round(t_f, 2), round(w_gkg, 3)))
91+
break
92+
if len(pts) > 2:
93+
pts.sort(key=lambda p: p[0])
94+
step = max(1, len(pts) // 30)
95+
wb_lines[tw_target] = pts[::step]
96+
97+
# Specific volume lines — analytical solution
98+
sv_values = [0.80, 0.84, 0.88, 0.92, 0.96]
99+
sv_lines = {}
100+
for sv_target in sv_values:
101+
pts = []
102+
for t in np.linspace(-10, 50, 200):
103+
t_f = float(t)
104+
w_kgkg = specific_volume_w(t_f, sv_target)
105+
w_gkg = w_kgkg * 1000
106+
w_sat = humidity_ratio(t_f, 1.0)
107+
if 0 <= w_gkg <= min(30, w_sat + 0.2):
108+
pts.append((round(t_f, 2), round(w_gkg, 3)))
109+
if len(pts) > 2:
110+
pts.sort(key=lambda p: p[0])
111+
step = max(1, len(pts) // 25)
112+
sv_lines[sv_target] = pts[::step]
113+
114+
# Enthalpy lines (h = 1.006*t + w*(2501 + 1.86*t), w in kg/kg)
115+
enthalpy_values = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
116+
enthalpy_lines = {}
117+
for h_target in enthalpy_values:
118+
pts = []
119+
for t in np.linspace(-10, 50, 200):
120+
t_f = float(t)
121+
w_kgkg = (h_target - 1.006 * t_f) / (2501 + 1.86 * t_f)
122+
w_gkg = w_kgkg * 1000
123+
w_sat = humidity_ratio(t_f, 1.0)
124+
if 0 <= w_gkg <= min(30, w_sat + 0.2):
125+
pts.append((round(t_f, 2), round(w_gkg, 3)))
126+
if len(pts) > 2:
127+
pts.sort(key=lambda p: p[0])
128+
step = max(1, len(pts) // 25)
129+
enthalpy_lines[h_target] = pts[::step]
130+
131+
# Comfort zone polygon (20-26°C, 30-60% RH)
132+
comfort_t = np.linspace(20, 26, 40)
133+
comfort_bottom = [humidity_ratio(float(t), 0.30) for t in comfort_t]
134+
comfort_top = [humidity_ratio(float(t), 0.60) for t in comfort_t]
135+
comfort_pts = [(round(float(t), 2), round(w, 3)) for t, w in zip(comfort_t, comfort_bottom, strict=True)] + [
136+
(round(float(t), 2), round(w, 3)) for t, w in zip(reversed(comfort_t), reversed(comfort_top), strict=False)
137+
]
138+
comfort_pts.append(comfort_pts[0])
139+
140+
# HVAC process: cooling and dehumidification (35°C/60%RH → 24°C/50%RH)
141+
hvac_states = [(35.0, round(humidity_ratio(35, 0.60), 3)), (24.0, round(humidity_ratio(24, 0.50), 3))]
142+
143+
# Color palette — distinct families per property type
144+
blue_rh = ["#0d3b66", "#1a4d80", "#276099", "#3572b0", "#4384c4", "#5195d4", "#5fa5e0", "#6db5ea", "#7bc4f2", "#89d1f8"]
145+
orange_wb = "#d4792a"
146+
purple_sv = "#6a4c93"
147+
teal_enth = "#2a8a8a"
148+
149+
palette = tuple(blue_rh + [orange_wb] * 7 + [purple_sv] * 5 + [teal_enth] * 10 + ["#4caf6e", "#c62828"])
150+
151+
# Style
152+
custom_style = Style(
153+
background="#ffffff",
154+
plot_background="#fafbfc",
155+
foreground="#2d2d2d",
156+
foreground_strong="#111111",
157+
foreground_subtle="#e8ebef",
158+
opacity=".85",
159+
opacity_hover="1",
160+
colors=palette,
161+
title_font_size=72,
162+
label_font_size=42,
163+
major_label_font_size=46,
164+
legend_font_size=34,
165+
value_font_size=34,
166+
stroke_width=2.5,
167+
title_font_family="Trebuchet MS, Helvetica Neue, sans-serif",
168+
label_font_family="Trebuchet MS, Helvetica Neue, sans-serif",
169+
major_label_font_family="Trebuchet MS, Helvetica Neue, sans-serif",
170+
legend_font_family="Trebuchet MS, Helvetica Neue, sans-serif",
171+
value_font_family="Trebuchet MS, Helvetica Neue, sans-serif",
172+
tooltip_font_size=24,
173+
tooltip_font_family="Trebuchet MS, Helvetica Neue, sans-serif",
174+
transition="150ms ease-in",
175+
value_colors=(),
176+
guide_stroke_color="#eceef1",
177+
major_guide_stroke_color="#dde1e6",
178+
)
179+
180+
# Chart — increased right margin to prevent label truncation
181+
chart = pygal.XY(
182+
width=4800,
183+
height=2700,
184+
style=custom_style,
185+
title="psychrometric-basic · pygal · pyplots.ai",
186+
x_title="Dry-Bulb Temperature (°C)",
187+
y_title="Humidity Ratio (g/kg)",
188+
show_legend=False,
189+
dots_size=0,
190+
stroke=True,
191+
show_x_guides=True,
192+
show_y_guides=True,
193+
xrange=(-10, 50),
194+
range=(0, 30),
195+
x_labels=list(range(-10, 55, 5)),
196+
y_labels=list(range(0, 32, 2)),
197+
x_labels_major_every=2,
198+
y_labels_major_every=5,
199+
show_minor_x_labels=True,
200+
show_minor_y_labels=True,
201+
print_values=False,
202+
tooltip_border_radius=8,
203+
tooltip_fancy_mode=True,
204+
explicit_size=True,
205+
spacing=25,
206+
margin_bottom=60,
207+
margin_top=70,
208+
margin_left=80,
209+
margin_right=180,
210+
truncate_legend=-1,
211+
js=[],
212+
)
213+
214+
# RH curves — saturation (100%) thickest
215+
for rh in rh_levels:
216+
rh_pct = int(rh * 100)
217+
w = 5.5 if rh == 1.0 else max(2.2, 1.5 + rh * 2)
218+
chart.add(f"{rh_pct}% RH", rh_curves[rh], show_dots=False, stroke_style={"width": w, "linecap": "round"})
219+
220+
# Wet-bulb lines — dashed orange
221+
for tw in wb_temps:
222+
chart.add(
223+
f"Tw={tw}°C",
224+
wb_lines.get(tw, []),
225+
show_dots=False,
226+
stroke_style={"width": 2.0, "dasharray": "12, 6", "linecap": "round"},
227+
)
228+
229+
# Specific volume lines — dotted purple, increased width for visibility
230+
for sv in sv_values:
231+
chart.add(
232+
f"v={sv} m\u00b3/kg",
233+
sv_lines.get(sv, []),
234+
show_dots=False,
235+
stroke_style={"width": 2.2, "dasharray": "4, 8", "linecap": "round"},
236+
)
237+
238+
# Enthalpy lines — dash-dot teal
239+
for h in enthalpy_values:
240+
chart.add(
241+
f"h={h} kJ/kg",
242+
enthalpy_lines.get(h, []),
243+
show_dots=False,
244+
stroke_style={"width": 1.6, "dasharray": "14, 4, 4, 4", "linecap": "round"},
245+
)
246+
247+
# Comfort zone — filled green
248+
chart.add(
249+
"Comfort Zone (20\u201326\u00b0C, 30\u201360% RH)",
250+
comfort_pts,
251+
show_dots=False,
252+
fill=True,
253+
stroke_style={"width": 2.5, "linecap": "round"},
254+
)
255+
256+
# HVAC process — bold red with dots at state points
257+
chart.add(
258+
"Cooling & Dehumidification (A\u2192B)",
259+
[
260+
{"value": hvac_states[0], "label": "State A: 35\u00b0C, 60% RH"},
261+
{"value": hvac_states[1], "label": "State B: 24\u00b0C, 50% RH"},
262+
],
263+
show_dots=True,
264+
dots_size=14,
265+
stroke_style={"width": 4.5, "linecap": "round"},
266+
)
267+
268+
# Render SVG, then add direct labels via SVG text elements
269+
svg_content = chart.render(is_unicode=True)
270+
271+
# Coordinate mapping for label placement
272+
plot_left = 80 + 120 # margin_left + y-axis labels space
273+
plot_right = 4800 - 180 # width - margin_right
274+
plot_top = 70 + 80 # margin_top + title space
275+
plot_bottom = 2700 - 60 - 80 # height - margin_bottom - x-axis space
276+
277+
x_min, x_max = -10, 50
278+
y_min, y_max = 0, 30
279+
280+
281+
def to_svg_x(val):
282+
return plot_left + (val - x_min) / (x_max - x_min) * (plot_right - plot_left)
283+
284+
285+
def to_svg_y(val):
286+
return plot_bottom - (val - y_min) / (y_max - y_min) * (plot_bottom - plot_top)
287+
288+
289+
labels_svg = []
290+
291+
# RH labels — positioned along curves where they're readable
292+
rh_label_temps = {1.0: 16, 0.9: 20, 0.8: 24, 0.7: 27, 0.6: 30, 0.5: 33, 0.4: 36, 0.3: 39, 0.2: 42, 0.1: 45}
293+
for rh in rh_levels:
294+
rh_pct = int(rh * 100)
295+
t_l = rh_label_temps[rh]
296+
w_l = humidity_ratio(t_l, rh)
297+
if 0 < w_l < 30:
298+
sx, sy = to_svg_x(t_l), to_svg_y(w_l)
299+
labels_svg.append(
300+
f'<text x="{sx}" y="{sy - 10}" font-size="34" font-family="Trebuchet MS, sans-serif" '
301+
f'fill="#0d3b66" font-weight="bold" text-anchor="middle">{rh_pct}%</text>'
302+
)
303+
304+
# Wet-bulb labels — placed at 2/5 along line to separate from enthalpy labels
305+
for tw in wb_temps:
306+
pts = wb_lines.get(tw, [])
307+
if len(pts) > 4:
308+
idx = 2 * len(pts) // 5 # place label at 2/5 of the line from left
309+
p = pts[idx]
310+
sx, sy = to_svg_x(p[0]), to_svg_y(p[1])
311+
# Compute rotation angle from nearby points for alignment
312+
p_prev = pts[max(0, idx - 1)]
313+
p_next = pts[min(len(pts) - 1, idx + 1)]
314+
dx = to_svg_x(p_next[0]) - to_svg_x(p_prev[0])
315+
dy = to_svg_y(p_next[1]) - to_svg_y(p_prev[1])
316+
angle = math.degrees(math.atan2(dy, dx))
317+
labels_svg.append(
318+
f'<text x="{sx}" y="{sy - 10}" font-size="30" font-family="Trebuchet MS, sans-serif" '
319+
f'fill="{orange_wb}" font-weight="bold" text-anchor="middle" '
320+
f'transform="rotate({angle:.1f},{sx},{sy - 10})">Tw {tw}\u00b0C</text>'
321+
)
322+
323+
# Specific volume labels — larger and placed at 1/3 position for visibility
324+
for sv in sv_values:
325+
pts = sv_lines.get(sv, [])
326+
if len(pts) > 4:
327+
idx = len(pts) // 3
328+
p = pts[idx]
329+
sx, sy = to_svg_x(p[0]), to_svg_y(p[1])
330+
p_prev = pts[max(0, idx - 1)]
331+
p_next = pts[min(len(pts) - 1, idx + 1)]
332+
dx = to_svg_x(p_next[0]) - to_svg_x(p_prev[0])
333+
dy = to_svg_y(p_next[1]) - to_svg_y(p_prev[1])
334+
angle = math.degrees(math.atan2(dy, dx))
335+
labels_svg.append(
336+
f'<text x="{sx}" y="{sy - 10}" font-size="32" font-family="Trebuchet MS, sans-serif" '
337+
f'fill="{purple_sv}" font-weight="bold" text-anchor="middle" '
338+
f'transform="rotate({angle:.1f},{sx},{sy - 10})">{sv} m\u00b3/kg</text>'
339+
)
340+
341+
# Enthalpy labels — placed at 2/3 along line (separated from wet-bulb labels at 2/5)
342+
for h in [10, 30, 50, 70, 90]:
343+
pts = enthalpy_lines.get(h, [])
344+
if len(pts) > 4:
345+
idx = 2 * len(pts) // 3
346+
p = pts[idx]
347+
sx, sy = to_svg_x(p[0]), to_svg_y(p[1])
348+
p_prev = pts[max(0, idx - 1)]
349+
p_next = pts[min(len(pts) - 1, idx + 1)]
350+
dx = to_svg_x(p_next[0]) - to_svg_x(p_prev[0])
351+
dy = to_svg_y(p_next[1]) - to_svg_y(p_prev[1])
352+
angle = math.degrees(math.atan2(dy, dx))
353+
labels_svg.append(
354+
f'<text x="{sx}" y="{sy - 10}" font-size="30" font-family="Trebuchet MS, sans-serif" '
355+
f'fill="{teal_enth}" font-weight="bold" text-anchor="middle" '
356+
f'transform="rotate({angle:.1f},{sx},{sy - 10})">h={h} kJ/kg</text>'
357+
)
358+
359+
# Comfort zone label
360+
cz_x, cz_y = 23, humidity_ratio(23, 0.45)
361+
sx, sy = to_svg_x(cz_x), to_svg_y(cz_y)
362+
labels_svg.append(
363+
f'<text x="{sx}" y="{sy}" font-size="38" font-family="Trebuchet MS, sans-serif" '
364+
f'fill="#2e7d32" font-weight="bold" text-anchor="middle">Comfort Zone</text>'
365+
)
366+
367+
# HVAC state point labels
368+
sx_a, sy_a = to_svg_x(hvac_states[0][0]), to_svg_y(hvac_states[0][1])
369+
labels_svg.append(
370+
f'<text x="{sx_a + 20}" y="{sy_a - 20}" font-size="36" font-family="Trebuchet MS, sans-serif" '
371+
f'fill="#c62828" font-weight="bold" text-anchor="start">A (35\u00b0C, 60%RH)</text>'
372+
)
373+
sx_b, sy_b = to_svg_x(hvac_states[1][0]), to_svg_y(hvac_states[1][1])
374+
labels_svg.append(
375+
f'<text x="{sx_b - 20}" y="{sy_b - 20}" font-size="36" font-family="Trebuchet MS, sans-serif" '
376+
f'fill="#c62828" font-weight="bold" text-anchor="end">B (24\u00b0C, 50%RH)</text>'
377+
)
378+
379+
# Insert labels before closing </svg>
380+
label_block = "\n".join(labels_svg)
381+
svg_labeled = svg_content.replace("</svg>", f"{label_block}\n</svg>")
382+
383+
with open("plot.html", "w") as f:
384+
f.write(svg_labeled)
385+
386+
cairosvg.svg2png(bytestring=svg_labeled.encode("utf-8"), write_to="plot.png")

0 commit comments

Comments
 (0)