Skip to content

Commit c3a54b1

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

2 files changed

Lines changed: 604 additions & 0 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
""" pyplots.ai
2+
psychrometric-basic: Psychrometric Chart for HVAC
3+
Library: altair 6.0.0 | Python 3.14.3
4+
Quality: 86/100 | Created: 2026-03-15
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
# Constants
13+
P_ATM = 101325 # Pa, standard atmospheric pressure
14+
15+
# Precompute saturation pressure over a fine grid (ASHRAE formula, computed once)
16+
_t_grid = np.linspace(-10, 50, 500)
17+
_t_grid_k = _t_grid + 273.15
18+
_p_sat_grid = np.where(
19+
_t_grid >= 0,
20+
np.exp(
21+
-5.8002206e3 / _t_grid_k
22+
+ 1.3914993
23+
- 4.8640239e-2 * _t_grid_k
24+
+ 4.1764768e-5 * _t_grid_k**2
25+
- 1.4452093e-8 * _t_grid_k**3
26+
+ 6.5459673 * np.log(_t_grid_k)
27+
),
28+
np.exp(
29+
-5.6745359e3 / _t_grid_k
30+
+ 6.3925247
31+
- 9.677843e-3 * _t_grid_k
32+
+ 6.2215701e-7 * _t_grid_k**2
33+
+ 2.0747825e-9 * _t_grid_k**3
34+
- 9.484024e-13 * _t_grid_k**4
35+
+ 4.1635019 * np.log(_t_grid_k)
36+
),
37+
)
38+
39+
# Interpolation-based saturation pressure lookup (replaces 6x formula repetition)
40+
p_sat_at = lambda t: np.interp(t, _t_grid, _p_sat_grid) # noqa: E731
41+
42+
# Data - Generate psychrometric curves
43+
t_range = np.linspace(-10, 50, 200)
44+
p_sat = p_sat_at(t_range)
45+
46+
# Relative humidity curves (10% to 100%)
47+
rh_curves = []
48+
for rh_pct in range(10, 110, 10):
49+
rh_frac = rh_pct / 100
50+
p_w = rh_frac * p_sat
51+
w_vals = 0.621945 * p_w / (P_ATM - p_w) * 1000 # g/kg
52+
w_vals = np.clip(w_vals, 0, 30)
53+
for t, w in zip(t_range, w_vals, strict=True):
54+
if 0 < w <= 30:
55+
rh_curves.append({"t_db": float(t), "w": float(w), "rh": f"{rh_pct}%"})
56+
57+
rh_df = pd.DataFrame(rh_curves)
58+
59+
# Wet-bulb temperature lines
60+
wb_lines = []
61+
for t_wb_val in range(0, 36, 5):
62+
p_sat_wb = float(p_sat_at(t_wb_val))
63+
w_s_wb = 0.621945 * p_sat_wb / (P_ATM - p_sat_wb)
64+
for t_db in np.linspace(max(-10, t_wb_val), 50, 80):
65+
w = (2501 * w_s_wb - 1.006 * (t_db - t_wb_val)) / (2501 + 1.86 * t_db - 4.186 * t_wb_val)
66+
w_gkg = w * 1000
67+
if 0 <= w_gkg <= 30:
68+
wb_lines.append({"t_db": float(t_db), "w": float(w_gkg), "wb": f"{t_wb_val}°C"})
69+
70+
wb_df = pd.DataFrame(wb_lines)
71+
72+
# Enthalpy lines (h = 1.006*t + w*(2501 + 1.86*t), solve for w)
73+
enthalpy_lines = []
74+
for h_val in range(10, 120, 10):
75+
for t_db in np.linspace(-10, 50, 80):
76+
w_gkg = (h_val - 1.006 * t_db) / (2.501 + 0.00186 * t_db)
77+
if 0 <= w_gkg <= 30:
78+
enthalpy_lines.append({"t_db": float(t_db), "w": float(w_gkg), "h": f"{h_val} kJ/kg"})
79+
80+
enthalpy_df = pd.DataFrame(enthalpy_lines)
81+
82+
# Specific volume lines (v = 0.287042*T_k*(1+1.6078*w)/P, solve for w)
83+
vol_lines = []
84+
for v_val in [0.75, 0.80, 0.85, 0.90, 0.95]:
85+
for t_db in np.linspace(-10, 50, 80):
86+
w = (v_val * P_ATM / 1000 / (0.287042 * (t_db + 273.15)) - 1) / 1.6078
87+
w_gkg = w * 1000
88+
if 0 <= w_gkg <= 30:
89+
vol_lines.append({"t_db": float(t_db), "w": float(w_gkg), "v": f"{v_val} m³/kg"})
90+
91+
vol_df = pd.DataFrame(vol_lines)
92+
93+
# Comfort zone (20-26°C, 30-60% RH)
94+
comfort_temps = np.linspace(20, 26, 30)
95+
comfort_psat = p_sat_at(comfort_temps)
96+
comfort_w_lo = 0.621945 * 0.30 * comfort_psat / (P_ATM - 0.30 * comfort_psat) * 1000
97+
comfort_w_hi = 0.621945 * 0.60 * comfort_psat / (P_ATM - 0.60 * comfort_psat) * 1000
98+
comfort_df = pd.DataFrame({"t_db": comfort_temps, "w": comfort_w_lo, "w2": comfort_w_hi})
99+
100+
# HVAC process path: cooling and dehumidification (35°C/50%RH -> 13°C/sat)
101+
t1, t2 = 35.0, 13.0
102+
p_sat_t1, p_sat_t2 = float(p_sat_at(t1)), float(p_sat_at(t2))
103+
w1 = 0.621945 * 0.50 * p_sat_t1 / (P_ATM - 0.50 * p_sat_t1) * 1000
104+
w2 = 0.621945 * 1.00 * p_sat_t2 / (P_ATM - 1.00 * p_sat_t2) * 1000
105+
106+
process_points = pd.DataFrame(
107+
{
108+
"t_db": [t1, t2],
109+
"w": [float(w1), float(w2)],
110+
"label": ["Outdoor Air (35°C, 50% RH)", "Supply Air (13°C, 100% RH)"],
111+
"rh_pct": ["50%", "100%"],
112+
"order": [0, 1],
113+
}
114+
)
115+
116+
# RH label positions (staggered to avoid overlap)
117+
rh_labels = []
118+
for rh_pct in range(10, 110, 10):
119+
rh_frac = rh_pct / 100
120+
# Stagger label temperatures to reduce convergence overlap
121+
if rh_pct == 100:
122+
t_label = 33
123+
elif rh_pct >= 80:
124+
t_label = 36
125+
elif rh_pct >= 60:
126+
t_label = 40
127+
elif rh_pct >= 40:
128+
t_label = 44
129+
else:
130+
t_label = 47
131+
p_sat_label = float(p_sat_at(t_label))
132+
w_label = 0.621945 * rh_frac * p_sat_label / (P_ATM - rh_frac * p_sat_label) * 1000
133+
if w_label <= 30:
134+
rh_labels.append({"t_db": float(t_label), "w": float(w_label), "label": f"{rh_pct}%"})
135+
136+
rh_label_df = pd.DataFrame(rh_labels)
137+
138+
# Wet-bulb labels (offset from saturation curve to avoid overlap with enthalpy labels)
139+
wb_labels_data = []
140+
for t_wb_val in range(0, 36, 5):
141+
p_sat_wb = float(p_sat_at(t_wb_val))
142+
w_wb_label = 0.621945 * p_sat_wb / (P_ATM - p_sat_wb) * 1000
143+
if w_wb_label <= 28:
144+
wb_labels_data.append({"t_db": float(t_wb_val) + 1.5, "w": float(w_wb_label) + 0.8, "label": f"{t_wb_val}°C"})
145+
146+
wb_label_df = pd.DataFrame(wb_labels_data)
147+
148+
# Enthalpy labels (along left edge, skip values that would overlap with wet-bulb labels)
149+
enthalpy_labels = []
150+
for h_val in range(20, 120, 20):
151+
w_at_left = (h_val - 1.006 * (-10)) / (2.501 + 0.00186 * (-10))
152+
if 0 <= w_at_left <= 30:
153+
enthalpy_labels.append({"t_db": -9.5, "w": float(w_at_left), "label": f"{h_val} kJ/kg"})
154+
else:
155+
t_at_top = (h_val - 2.501 * 30) / (1.006 + 0.00186 * 30)
156+
if -10 <= t_at_top <= 50:
157+
enthalpy_labels.append({"t_db": float(t_at_top), "w": 30.0, "label": f"{h_val} kJ/kg"})
158+
159+
enthalpy_label_df = pd.DataFrame(enthalpy_labels)
160+
161+
# Volume labels (along bottom-right)
162+
vol_labels = []
163+
for v_val in [0.75, 0.80, 0.85, 0.90, 0.95]:
164+
w_at_bot = (v_val * P_ATM / 1000 / (0.287042 * (45 + 273.15)) - 1) / 1.6078 * 1000
165+
if 0 <= w_at_bot <= 30:
166+
vol_labels.append({"t_db": 45.0, "w": float(w_at_bot), "label": f"{v_val} m³/kg"})
167+
168+
vol_label_df = pd.DataFrame(vol_labels)
169+
170+
# Colorblind-safe palette: blue (RH), orange (wet-bulb), teal (enthalpy), purple (volume)
171+
CLR_RH = "#306998"
172+
CLR_WB = "#d97b0e"
173+
CLR_ENTHALPY = "#17becf"
174+
CLR_VOL = "#9467bd"
175+
CLR_COMFORT = "#2ecc71"
176+
CLR_PROCESS = "#c0392b"
177+
CLR_BG = "#fafbfc"
178+
179+
# Plot
180+
x_scale = alt.Scale(domain=[-10, 50])
181+
y_scale = alt.Scale(domain=[0, 30])
182+
183+
# Saturation curve (100% RH) - visually prominent
184+
sat_df = rh_df[rh_df["rh"] == "100%"]
185+
saturation = (
186+
alt.Chart(sat_df)
187+
.mark_line(strokeWidth=3.5, color=CLR_RH)
188+
.encode(
189+
x=alt.X("t_db:Q", scale=x_scale, title="Dry-Bulb Temperature (°C)"),
190+
y=alt.Y("w:Q", scale=y_scale, title="Humidity Ratio (g/kg)"),
191+
)
192+
)
193+
194+
# Other RH curves with tooltips
195+
other_rh_df = rh_df[rh_df["rh"] != "100%"]
196+
rh_chart = (
197+
alt.Chart(other_rh_df)
198+
.mark_line(strokeWidth=1.5, opacity=0.55)
199+
.encode(
200+
x=alt.X("t_db:Q", scale=x_scale),
201+
y=alt.Y("w:Q", scale=y_scale),
202+
color=alt.Color("rh:N", scale=alt.Scale(scheme="blues"), legend=None),
203+
detail="rh:N",
204+
tooltip=[
205+
alt.Tooltip("t_db:Q", title="Dry-Bulb (°C)", format=".1f"),
206+
alt.Tooltip("w:Q", title="Humidity (g/kg)", format=".1f"),
207+
alt.Tooltip("rh:N", title="Relative Humidity"),
208+
],
209+
)
210+
)
211+
212+
# RH labels
213+
rh_text = (
214+
alt.Chart(rh_label_df)
215+
.mark_text(fontSize=13, color="#4a7fb5", fontWeight="bold")
216+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), text="label:N")
217+
)
218+
219+
# Wet-bulb lines (orange, colorblind-safe)
220+
wb_chart = (
221+
alt.Chart(wb_df)
222+
.mark_line(strokeWidth=1, strokeDash=[6, 4], opacity=0.5, color=CLR_WB)
223+
.encode(
224+
x=alt.X("t_db:Q", scale=x_scale),
225+
y=alt.Y("w:Q", scale=y_scale),
226+
detail="wb:N",
227+
tooltip=[
228+
alt.Tooltip("t_db:Q", title="Dry-Bulb (°C)", format=".1f"),
229+
alt.Tooltip("w:Q", title="Humidity (g/kg)", format=".1f"),
230+
alt.Tooltip("wb:N", title="Wet-Bulb Temp"),
231+
],
232+
)
233+
)
234+
235+
# Wet-bulb labels (offset to avoid overlap)
236+
wb_text = (
237+
alt.Chart(wb_label_df)
238+
.mark_text(fontSize=12, color=CLR_WB, align="left", dx=2, dy=-6, fontWeight="bold")
239+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), text="label:N")
240+
)
241+
242+
# Enthalpy lines (teal, colorblind-safe)
243+
enthalpy_chart = (
244+
alt.Chart(enthalpy_df)
245+
.mark_line(strokeWidth=1, strokeDash=[4, 6], opacity=0.45, color=CLR_ENTHALPY)
246+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), detail="h:N")
247+
)
248+
249+
# Enthalpy labels
250+
enthalpy_text = (
251+
alt.Chart(enthalpy_label_df)
252+
.mark_text(fontSize=11, color=CLR_ENTHALPY, align="left", dx=2, dy=-4, fontWeight="bold")
253+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), text="label:N")
254+
)
255+
256+
# Specific volume lines
257+
vol_chart = (
258+
alt.Chart(vol_df)
259+
.mark_line(strokeWidth=1, strokeDash=[2, 4], opacity=0.4, color=CLR_VOL)
260+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), detail="v:N")
261+
)
262+
263+
# Volume labels
264+
vol_text = (
265+
alt.Chart(vol_label_df)
266+
.mark_text(fontSize=11, color=CLR_VOL, align="left", dx=3, dy=-5, fontWeight="bold")
267+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), text="label:N")
268+
)
269+
270+
# Comfort zone shaded area
271+
comfort = (
272+
alt.Chart(comfort_df)
273+
.mark_area(opacity=0.12, color=CLR_COMFORT)
274+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), y2="w2:Q")
275+
)
276+
277+
comfort_label = (
278+
alt.Chart(pd.DataFrame({"t_db": [23.0], "w": [10.5], "label": ["Comfort Zone"]}))
279+
.mark_text(fontSize=14, color="#27ae60", fontWeight="bold", fontStyle="italic")
280+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), text="label:N")
281+
)
282+
283+
# Interactive selection for HVAC process points
284+
point_selection = alt.selection_point(on="pointerover", nearest=True, fields=["t_db"])
285+
286+
# HVAC process path with tooltips
287+
process_line = (
288+
alt.Chart(process_points)
289+
.mark_line(strokeWidth=3.5, color=CLR_PROCESS, point=alt.OverlayMarkDef(size=140, filled=True, color=CLR_PROCESS))
290+
.encode(
291+
x=alt.X("t_db:Q", scale=x_scale),
292+
y=alt.Y("w:Q", scale=y_scale),
293+
order="order:Q",
294+
tooltip=[
295+
alt.Tooltip("label:N", title="State Point"),
296+
alt.Tooltip("t_db:Q", title="Dry-Bulb (°C)", format=".1f"),
297+
alt.Tooltip("w:Q", title="Humidity (g/kg)", format=".1f"),
298+
alt.Tooltip("rh_pct:N", title="RH"),
299+
],
300+
)
301+
.add_params(point_selection)
302+
)
303+
304+
# Process labels (adjusted positions to avoid overlap)
305+
outdoor_label = (
306+
alt.Chart(process_points[process_points["order"] == 0])
307+
.mark_text(fontSize=12, fontWeight="bold", color=CLR_PROCESS, align="right", dx=-12, dy=-14)
308+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), text="label:N")
309+
)
310+
311+
supply_label = (
312+
alt.Chart(process_points[process_points["order"] == 1])
313+
.mark_text(fontSize=12, fontWeight="bold", color=CLR_PROCESS, align="left", dx=12, dy=14)
314+
.encode(x=alt.X("t_db:Q", scale=x_scale), y=alt.Y("w:Q", scale=y_scale), text="label:N")
315+
)
316+
317+
# Layer all elements
318+
chart = (
319+
alt.layer(
320+
comfort,
321+
rh_chart,
322+
saturation,
323+
wb_chart,
324+
enthalpy_chart,
325+
vol_chart,
326+
rh_text,
327+
wb_text,
328+
enthalpy_text,
329+
vol_text,
330+
comfort_label,
331+
process_line,
332+
outdoor_label,
333+
supply_label,
334+
)
335+
.properties(
336+
width=1600,
337+
height=900,
338+
title=alt.Title(
339+
text="psychrometric-basic · altair · pyplots.ai",
340+
fontSize=28,
341+
anchor="middle",
342+
subtitle="Standard Atmosphere (101.325 kPa) · HVAC Air Properties",
343+
subtitleFontSize=18,
344+
subtitleColor="#888888",
345+
offset=12,
346+
),
347+
)
348+
.configure_axis(
349+
labelFontSize=18,
350+
titleFontSize=22,
351+
titleColor="#444444",
352+
labelColor="#555555",
353+
grid=True,
354+
gridOpacity=0.12,
355+
gridColor="#cccccc",
356+
domainColor="#999999",
357+
)
358+
.configure_view(strokeWidth=0, fill=CLR_BG)
359+
)
360+
361+
# Save
362+
chart.save("plot.png", scale_factor=3.0)
363+
chart.save("plot.html")

0 commit comments

Comments
 (0)