Spaces:
Sleeping
Sleeping
Upload psychrometrics.py
Browse files- utils/psychrometrics.py +592 -398
utils/psychrometrics.py
CHANGED
@@ -1,581 +1,775 @@
|
|
1 |
"""
|
2 |
Psychrometric module for HVAC Load Calculator.
|
3 |
-
This module implements psychrometric calculations for air properties
|
|
|
4 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
|
|
|
|
|
|
|
|
|
5 |
"""
|
6 |
|
7 |
from typing import Dict, List, Any, Optional, Tuple
|
8 |
import math
|
9 |
import numpy as np
|
|
|
|
|
|
|
|
|
10 |
|
11 |
-
# Constants
|
12 |
-
ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure in Pa
|
13 |
WATER_MOLECULAR_WEIGHT = 18.01534 # kg/kmol
|
14 |
DRY_AIR_MOLECULAR_WEIGHT = 28.9645 # kg/kmol
|
15 |
UNIVERSAL_GAS_CONSTANT = 8314.462618 # J/(kmol·K)
|
16 |
-
GAS_CONSTANT_DRY_AIR = UNIVERSAL_GAS_CONSTANT / DRY_AIR_MOLECULAR_WEIGHT # J/(kg·K)
|
17 |
-
GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K)
|
18 |
|
|
|
|
|
|
|
|
|
19 |
|
20 |
class Psychrometrics:
|
21 |
"""Class for psychrometric calculations."""
|
22 |
-
|
|
|
23 |
@staticmethod
|
24 |
-
def validate_inputs(t_db:
|
|
|
|
|
25 |
"""
|
26 |
Validate input parameters for psychrometric calculations.
|
27 |
-
|
28 |
Args:
|
29 |
t_db: Dry-bulb temperature in °C
|
30 |
-
rh: Relative humidity in % (0-100)
|
31 |
-
|
32 |
-
|
|
|
33 |
Raises:
|
34 |
ValueError: If inputs are invalid
|
35 |
"""
|
36 |
-
if not -
|
37 |
-
raise ValueError(f"Temperature {t_db}°C must be
|
38 |
if rh is not None and not 0 <= rh <= 100:
|
39 |
-
|
40 |
-
|
41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
|
|
|
|
|
|
|
43 |
@staticmethod
|
44 |
def saturation_pressure(t_db: float) -> float:
|
45 |
"""
|
46 |
Calculate saturation pressure of water vapor.
|
47 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5 and 6.
|
48 |
-
|
49 |
Args:
|
50 |
t_db: Dry-bulb temperature in °C
|
51 |
-
|
52 |
Returns:
|
53 |
Saturation pressure in Pa
|
54 |
"""
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
t_k = t_db + 273.15
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
c2 = 1.3914993
|
65 |
-
c3 = -4.8640239e-2
|
66 |
-
c4 = 4.1764768e-5
|
67 |
-
c5 = -1.4452093e-8
|
68 |
-
c6 = 6.5459673
|
69 |
-
else:
|
70 |
-
# Equation 6 for temperatures below freezing
|
71 |
-
c1 = -5.6745359e3
|
72 |
-
c2 = 6.3925247
|
73 |
-
c3 = -9.6778430e-3
|
74 |
-
c4 = 6.2215701e-7
|
75 |
-
c5 = 2.0747825e-9
|
76 |
-
c6 = -9.4840240e-13
|
77 |
-
c7 = 4.1635019
|
78 |
-
|
79 |
-
# Calculate natural log of saturation pressure in Pa
|
80 |
if t_db >= 0:
|
81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
82 |
else:
|
83 |
-
|
84 |
-
|
85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
86 |
p_ws = math.exp(ln_p_ws)
|
87 |
-
|
88 |
return p_ws
|
89 |
-
|
90 |
@staticmethod
|
91 |
def humidity_ratio(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
92 |
"""
|
93 |
Calculate humidity ratio (mass of water vapor per unit mass of dry air).
|
94 |
-
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20.
|
95 |
-
|
96 |
Args:
|
97 |
t_db: Dry-bulb temperature in °C
|
98 |
rh: Relative humidity (0-100)
|
99 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
100 |
-
|
101 |
Returns:
|
102 |
Humidity ratio in kg water vapor / kg dry air
|
103 |
"""
|
104 |
-
Psychrometrics.validate_inputs(t_db, rh, p_atm)
|
105 |
-
|
106 |
-
# Convert relative humidity to decimal
|
107 |
-
rh_decimal = rh / 100.0
|
108 |
-
|
109 |
-
# Calculate saturation pressure
|
110 |
p_ws = Psychrometrics.saturation_pressure(t_db)
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
if p_w >= p_atm:
|
116 |
-
|
117 |
-
|
118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
119 |
w = 0.621945 * p_w / (p_atm - p_w)
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
@staticmethod
|
124 |
def relative_humidity(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
125 |
"""
|
126 |
Calculate relative humidity from humidity ratio.
|
127 |
-
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation
|
128 |
-
|
129 |
Args:
|
130 |
t_db: Dry-bulb temperature in °C
|
131 |
w: Humidity ratio in kg water vapor / kg dry air
|
132 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
133 |
-
|
134 |
Returns:
|
135 |
Relative humidity (0-100)
|
136 |
"""
|
137 |
-
Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
|
138 |
-
|
139 |
-
raise ValueError("Humidity ratio cannot be negative")
|
140 |
-
|
141 |
-
# Calculate saturation pressure
|
142 |
p_ws = Psychrometrics.saturation_pressure(t_db)
|
143 |
-
|
144 |
-
#
|
145 |
p_w = p_atm * w / (0.621945 + w)
|
146 |
-
|
147 |
-
|
|
|
|
|
|
|
|
|
148 |
rh = 100.0 * p_w / p_ws
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
@staticmethod
|
153 |
-
def wet_bulb_temperature(t_db: float, rh: float,
|
|
|
154 |
"""
|
155 |
-
Calculate wet-bulb temperature using iterative method.
|
156 |
-
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 35.
|
157 |
-
|
|
|
158 |
Args:
|
159 |
t_db: Dry-bulb temperature in °C
|
160 |
-
rh: Relative humidity (0-100)
|
|
|
161 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
162 |
-
|
163 |
Returns:
|
164 |
Wet-bulb temperature in °C
|
165 |
"""
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
175 |
max_iterations = 100
|
176 |
-
|
177 |
-
|
178 |
for i in range(max_iterations):
|
179 |
-
#
|
180 |
-
Psychrometrics.validate_inputs(t_wb)
|
181 |
-
|
182 |
-
# Calculate saturation pressure at wet-bulb temperature
|
183 |
p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
|
184 |
-
|
185 |
-
# Calculate saturation humidity ratio at wet-bulb temperature
|
186 |
w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
195 |
# Check convergence
|
196 |
-
if abs(
|
197 |
break
|
198 |
-
|
199 |
-
# Adjust wet-bulb temperature
|
200 |
-
|
201 |
-
|
|
|
|
|
|
|
|
|
|
|
202 |
else:
|
203 |
-
t_wb +=
|
204 |
-
|
205 |
-
|
206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
207 |
@staticmethod
|
208 |
-
def dew_point_temperature(t_db: float, rh: float
|
|
|
209 |
"""
|
210 |
Calculate dew point temperature.
|
211 |
-
|
212 |
-
|
213 |
Args:
|
214 |
-
t_db: Dry-bulb temperature in °C
|
215 |
-
rh: Relative humidity (0-100)
|
216 |
-
|
|
|
217 |
Returns:
|
218 |
Dew point temperature in °C
|
219 |
"""
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
243 |
else:
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
253 |
@staticmethod
|
254 |
def enthalpy(t_db: float, w: float) -> float:
|
255 |
"""
|
256 |
Calculate specific enthalpy of moist air.
|
257 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30.
|
258 |
-
|
259 |
Args:
|
260 |
t_db: Dry-bulb temperature in °C
|
261 |
w: Humidity ratio in kg water vapor / kg dry air
|
262 |
-
|
263 |
Returns:
|
264 |
Specific enthalpy in J/kg dry air
|
265 |
"""
|
266 |
-
Psychrometrics.validate_inputs(t_db)
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
c_pa = 1006
|
271 |
-
|
272 |
-
c_pw = 1860
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
return h
|
277 |
-
|
278 |
@staticmethod
|
279 |
def specific_volume(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
280 |
"""
|
281 |
Calculate specific volume of moist air.
|
282 |
-
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation
|
283 |
-
|
284 |
Args:
|
285 |
t_db: Dry-bulb temperature in °C
|
286 |
w: Humidity ratio in kg water vapor / kg dry air
|
287 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
288 |
-
|
289 |
Returns:
|
290 |
Specific volume in m³/kg dry air
|
291 |
"""
|
292 |
-
Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
|
293 |
-
|
294 |
-
raise ValueError("Humidity ratio cannot be negative")
|
295 |
-
|
296 |
-
# Convert temperature to Kelvin
|
297 |
t_k = t_db + 273.15
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
return v
|
304 |
-
|
305 |
@staticmethod
|
306 |
def density(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
307 |
"""
|
308 |
Calculate density of moist air.
|
309 |
-
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, derived from Equation
|
310 |
-
|
|
|
311 |
Args:
|
312 |
t_db: Dry-bulb temperature in °C
|
313 |
w: Humidity ratio in kg water vapor / kg dry air
|
314 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
315 |
-
|
316 |
Returns:
|
317 |
-
Density in kg/m³
|
318 |
-
"""
|
319 |
-
Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
# Density is the reciprocal of specific volume
|
327 |
rho = (1 + w) / v
|
328 |
-
|
329 |
return rho
|
330 |
-
|
|
|
331 |
@staticmethod
|
332 |
-
def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE
|
|
|
333 |
"""
|
334 |
Calculate all psychrometric properties of moist air.
|
335 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
|
336 |
-
|
337 |
Args:
|
338 |
t_db: Dry-bulb temperature in °C
|
339 |
rh: Relative humidity (0-100)
|
340 |
-
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
341 |
-
|
|
|
342 |
Returns:
|
343 |
-
Dictionary with all psychrometric properties
|
344 |
-
"""
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
|
|
357 |
h = Psychrometrics.enthalpy(t_db, w)
|
358 |
-
|
359 |
-
|
360 |
-
v = Psychrometrics.specific_volume(t_db, w, p_atm)
|
361 |
-
|
362 |
-
# Calculate density
|
363 |
-
rho = Psychrometrics.density(t_db, w, p_atm)
|
364 |
-
|
365 |
-
# Calculate saturation pressure
|
366 |
p_ws = Psychrometrics.saturation_pressure(t_db)
|
367 |
-
|
368 |
-
|
369 |
-
p_w = rh / 100.0 * p_ws
|
370 |
-
|
371 |
-
# Return all properties
|
372 |
return {
|
373 |
-
"
|
374 |
-
"
|
375 |
-
"
|
376 |
-
"
|
377 |
-
"
|
378 |
-
"
|
379 |
-
"
|
380 |
-
"
|
381 |
-
"
|
382 |
-
"
|
383 |
-
"
|
384 |
}
|
385 |
-
|
|
|
386 |
@staticmethod
|
387 |
def find_humidity_ratio_for_enthalpy(t_db: float, h: float) -> float:
|
388 |
"""
|
389 |
Find humidity ratio for a given dry-bulb temperature and enthalpy.
|
390 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
|
391 |
-
|
392 |
Args:
|
393 |
t_db: Dry-bulb temperature in °C
|
394 |
h: Specific enthalpy in J/kg dry air
|
395 |
-
|
396 |
Returns:
|
397 |
Humidity ratio in kg water vapor / kg dry air
|
398 |
"""
|
399 |
-
Psychrometrics.validate_inputs(t_db)
|
400 |
-
|
401 |
-
|
402 |
-
|
403 |
-
|
404 |
-
|
405 |
-
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
return max(0, w)
|
410 |
-
|
411 |
@staticmethod
|
412 |
def find_temperature_for_enthalpy(w: float, h: float) -> float:
|
413 |
"""
|
414 |
Find dry-bulb temperature for a given humidity ratio and enthalpy.
|
415 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
|
416 |
-
|
417 |
Args:
|
418 |
w: Humidity ratio in kg water vapor / kg dry air
|
419 |
h: Specific enthalpy in J/kg dry air
|
420 |
-
|
421 |
Returns:
|
422 |
Dry-bulb temperature in °C
|
423 |
"""
|
424 |
-
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
435 |
-
Psychrometrics.validate_inputs(t_db)
|
436 |
return t_db
|
437 |
-
|
|
|
438 |
@staticmethod
|
439 |
def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
|
440 |
"""
|
441 |
-
Calculate sensible heat ratio.
|
442 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.5.
|
443 |
-
|
444 |
Args:
|
445 |
-
q_sensible: Sensible heat load in W
|
446 |
-
q_total: Total heat load in W
|
447 |
-
|
448 |
Returns:
|
449 |
-
Sensible heat ratio (0
|
450 |
-
"""
|
451 |
-
if q_total
|
452 |
-
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
|
458 |
@staticmethod
|
459 |
-
def air_flow_rate_for_load(q_sensible: float,
|
460 |
-
|
|
|
461 |
"""
|
462 |
-
Calculate
|
463 |
-
|
464 |
-
|
465 |
Args:
|
466 |
-
q_sensible: Sensible heat load in W
|
467 |
-
|
468 |
-
|
469 |
-
|
470 |
-
|
471 |
-
|
472 |
Returns:
|
473 |
-
|
474 |
-
"""
|
475 |
-
|
476 |
-
|
477 |
-
|
478 |
-
|
479 |
-
|
480 |
-
|
481 |
-
|
482 |
-
|
483 |
-
|
484 |
-
|
485 |
-
|
486 |
-
|
487 |
-
|
488 |
-
|
489 |
-
if
|
490 |
-
|
491 |
-
|
492 |
-
|
493 |
-
|
494 |
-
|
495 |
-
|
496 |
-
|
497 |
-
# Convert to different units
|
498 |
-
v_dot_m3_s = v_dot
|
499 |
-
v_dot_m3_h = v_dot * 3600
|
500 |
-
v_dot_cfm = v_dot * 2118.88
|
501 |
-
v_dot_l_s = v_dot * 1000
|
502 |
-
|
503 |
-
return {
|
504 |
-
"mass_flow_rate_kg_s": m_dot,
|
505 |
-
"volumetric_flow_rate_m3_s": v_dot_m3_s,
|
506 |
-
"volumetric_flow_rate_m3_h": v_dot_m3_h,
|
507 |
-
"volumetric_flow_rate_cfm": v_dot_cfm,
|
508 |
-
"volumetric_flow_rate_l_s": v_dot_l_s
|
509 |
-
}
|
510 |
-
|
511 |
@staticmethod
|
512 |
-
def
|
513 |
-
|
514 |
-
p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
|
515 |
"""
|
516 |
-
Calculate properties of
|
517 |
-
|
518 |
-
|
|
|
519 |
Args:
|
520 |
-
|
521 |
-
|
522 |
-
|
523 |
-
|
524 |
-
t_db2: Dry-bulb temperature of airstream 2 in °C
|
525 |
-
rh2: Relative humidity of airstream 2 in %
|
526 |
-
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
527 |
-
|
528 |
Returns:
|
529 |
-
Dictionary with mixed
|
530 |
-
|
531 |
-
|
532 |
-
|
533 |
-
|
534 |
-
|
535 |
-
|
536 |
-
|
537 |
-
|
538 |
-
|
539 |
-
|
540 |
-
|
541 |
-
|
542 |
-
|
543 |
-
|
544 |
-
|
545 |
-
|
546 |
-
|
547 |
-
|
548 |
-
raise ValueError("
|
549 |
-
|
550 |
-
|
551 |
-
|
552 |
-
|
553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
554 |
t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
|
555 |
-
|
556 |
-
#
|
557 |
rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
|
558 |
-
|
559 |
-
# Return mixed air properties
|
560 |
-
return Psychrometrics.moist_air_properties(t_db_mix, rh_mix, p_atm)
|
561 |
|
|
|
|
|
|
|
|
|
562 |
|
563 |
-
|
564 |
-
|
|
|
|
|
|
|
|
|
|
|
565 |
|
566 |
-
# Example
|
567 |
if __name__ == "__main__":
|
568 |
-
#
|
569 |
-
|
570 |
-
|
571 |
-
|
572 |
-
|
573 |
-
|
574 |
-
print(f"
|
575 |
-
|
576 |
-
|
577 |
-
|
578 |
-
|
579 |
-
print(f"
|
580 |
-
|
581 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
"""
|
2 |
Psychrometric module for HVAC Load Calculator.
|
3 |
+
This module implements psychrometric calculations for air properties,
|
4 |
+
including functions for mixing air streams and handling different altitudes.
|
5 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
|
6 |
+
|
7 |
+
Author: Dr Majed Abuseif
|
8 |
+
Date: May 2025 (Enhanced based on plan, preserving original features)
|
9 |
+
Version: 1.3.0
|
10 |
"""
|
11 |
|
12 |
from typing import Dict, List, Any, Optional, Tuple
|
13 |
import math
|
14 |
import numpy as np
|
15 |
+
import logging
|
16 |
+
|
17 |
+
# Set up logging
|
18 |
+
logger = logging.getLogger(__name__)
|
19 |
|
20 |
+
# Constants (Preserved from original)
|
21 |
+
ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure at sea level in Pa
|
22 |
WATER_MOLECULAR_WEIGHT = 18.01534 # kg/kmol
|
23 |
DRY_AIR_MOLECULAR_WEIGHT = 28.9645 # kg/kmol
|
24 |
UNIVERSAL_GAS_CONSTANT = 8314.462618 # J/(kmol·K)
|
25 |
+
GAS_CONSTANT_DRY_AIR = UNIVERSAL_GAS_CONSTANT / DRY_AIR_MOLECULAR_WEIGHT # J/(kg·K) = 287.058
|
26 |
+
GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K) = 461.52
|
27 |
|
28 |
+
# Constants for altitude calculation (Standard Atmosphere Model)
|
29 |
+
SEA_LEVEL_TEMP_K = 288.15 # K (15 °C)
|
30 |
+
LAPSE_RATE = 0.0065 # K/m
|
31 |
+
GRAVITY = 9.80665 # m/s²
|
32 |
|
33 |
class Psychrometrics:
|
34 |
"""Class for psychrometric calculations."""
|
35 |
+
|
36 |
+
# --- Input Validation (Preserved and slightly enhanced) --- #
|
37 |
@staticmethod
|
38 |
+
def validate_inputs(t_db: Optional[float] = None, rh: Optional[float] = None,
|
39 |
+
w: Optional[float] = None, h: Optional[float] = None,
|
40 |
+
p_atm: Optional[float] = None) -> None:
|
41 |
"""
|
42 |
Validate input parameters for psychrometric calculations.
|
|
|
43 |
Args:
|
44 |
t_db: Dry-bulb temperature in °C
|
45 |
+
rh: Relative humidity in % (0-100)
|
46 |
+
w: Humidity ratio (kg/kg)
|
47 |
+
h: Enthalpy (J/kg)
|
48 |
+
p_atm: Atmospheric pressure in Pa
|
49 |
Raises:
|
50 |
ValueError: If inputs are invalid
|
51 |
"""
|
52 |
+
if t_db is not None and not -100 <= t_db <= 200: # Wider range for intermediate calcs
|
53 |
+
raise ValueError(f"Temperature {t_db}°C must be within a reasonable range (-100°C to 200°C)")
|
54 |
if rh is not None and not 0 <= rh <= 100:
|
55 |
+
# Allow slightly > 100 due to calculation tolerances, clamp later
|
56 |
+
if rh < 0 or rh > 105:
|
57 |
+
raise ValueError(f"Relative humidity {rh}% must be between 0 and 100%")
|
58 |
+
if w is not None and w < 0:
|
59 |
+
raise ValueError(f"Humidity ratio {w} cannot be negative")
|
60 |
+
# Enthalpy can be negative relative to datum
|
61 |
+
# if h is not None and h < 0:
|
62 |
+
# raise ValueError(f"Enthalpy {h} cannot be negative")
|
63 |
+
if p_atm is not None and not 10000 <= p_atm <= 120000: # Typical atmospheric range
|
64 |
+
raise ValueError(f"Atmospheric pressure {p_atm} Pa must be within a reasonable range (10kPa to 120kPa)")
|
65 |
+
|
66 |
+
# --- Altitude/Pressure Calculation (Added based on plan) --- #
|
67 |
+
@staticmethod
|
68 |
+
def pressure_at_altitude(altitude: float, sea_level_pressure: float = ATMOSPHERIC_PRESSURE,
|
69 |
+
sea_level_temp_c: float = 15.0) -> float:
|
70 |
+
"""
|
71 |
+
Calculate atmospheric pressure at a given altitude using the standard atmosphere model.
|
72 |
+
Reference: https://en.wikipedia.org/wiki/Barometric_formula
|
73 |
+
Args:
|
74 |
+
altitude: Altitude above sea level in meters.
|
75 |
+
sea_level_pressure: Pressure at sea level in Pa (default: 101325 Pa).
|
76 |
+
sea_level_temp_c: Temperature at sea level in °C (default: 15 °C).
|
77 |
+
Returns:
|
78 |
+
Atmospheric pressure at the given altitude in Pa.
|
79 |
+
"""
|
80 |
+
if altitude < -500 or altitude > 80000: # Valid range for model
|
81 |
+
logger.warning(f"Altitude {altitude}m is outside the typical range for the standard atmosphere model.")
|
82 |
+
|
83 |
+
sea_level_temp_k = sea_level_temp_c + 273.15
|
84 |
+
r_da = GAS_CONSTANT_DRY_AIR
|
85 |
+
|
86 |
+
# Formula assumes constant lapse rate up to 11km
|
87 |
+
if altitude <= 11000:
|
88 |
+
temp_k = sea_level_temp_k - LAPSE_RATE * altitude
|
89 |
+
pressure = sea_level_pressure * (temp_k / sea_level_temp_k) ** (GRAVITY / (LAPSE_RATE * r_da))
|
90 |
+
else:
|
91 |
+
# Simplified: Use constant temperature above 11km (tropopause)
|
92 |
+
# A more complex model is needed for higher altitudes
|
93 |
+
logger.warning("Altitude > 11km. Using simplified pressure calculation.")
|
94 |
+
temp_11km = sea_level_temp_k - LAPSE_RATE * 11000
|
95 |
+
pressure_11km = sea_level_pressure * (temp_11km / sea_level_temp_k) ** (GRAVITY / (LAPSE_RATE * r_da))
|
96 |
+
pressure = pressure_11km * math.exp(-GRAVITY * (altitude - 11000) / (r_da * temp_11km))
|
97 |
|
98 |
+
return pressure
|
99 |
+
|
100 |
+
# --- Core Psychrometric Functions (Preserved from original) --- #
|
101 |
@staticmethod
|
102 |
def saturation_pressure(t_db: float) -> float:
|
103 |
"""
|
104 |
Calculate saturation pressure of water vapor.
|
105 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5 and 6.
|
|
|
106 |
Args:
|
107 |
t_db: Dry-bulb temperature in °C
|
|
|
108 |
Returns:
|
109 |
Saturation pressure in Pa
|
110 |
"""
|
111 |
+
# Input validation is implicitly handled by usage, but can be added
|
112 |
+
# Psychrometrics.validate_inputs(t_db=t_db)
|
113 |
+
|
114 |
t_k = t_db + 273.15
|
115 |
+
|
116 |
+
if t_k <= 0:
|
117 |
+
# Avoid issues with log(T) or 1/T at or below absolute zero
|
118 |
+
return 0.0
|
119 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
120 |
if t_db >= 0:
|
121 |
+
# Eq 6 (ASHRAE 2017) - Renamed from Eq 5 in older versions
|
122 |
+
C1 = -5.8002206E+03
|
123 |
+
C2 = 1.3914993E+00
|
124 |
+
C3 = -4.8640239E-02
|
125 |
+
C4 = 4.1764768E-05
|
126 |
+
C5 = -1.4452093E-08
|
127 |
+
C6 = 6.5459673E+00
|
128 |
+
ln_p_ws = C1/t_k + C2 + C3*t_k + C4*t_k**2 + C5*t_k**3 + C6*math.log(t_k)
|
129 |
else:
|
130 |
+
# Eq 5 (ASHRAE 2017) - Renamed from Eq 6 in older versions
|
131 |
+
C7 = -5.6745359E+03
|
132 |
+
C8 = 6.3925247E+00
|
133 |
+
C9 = -9.6778430E-03
|
134 |
+
C10 = 6.2215701E-07
|
135 |
+
C11 = 2.0747825E-09
|
136 |
+
C12 = -9.4840240E-13
|
137 |
+
C13 = 4.1635019E+00
|
138 |
+
ln_p_ws = C7/t_k + C8 + C9*t_k + C10*t_k**2 + C11*t_k**3 + C12*t_k**4 + C13*math.log(t_k)
|
139 |
+
|
140 |
p_ws = math.exp(ln_p_ws)
|
|
|
141 |
return p_ws
|
142 |
+
|
143 |
@staticmethod
|
144 |
def humidity_ratio(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
145 |
"""
|
146 |
Calculate humidity ratio (mass of water vapor per unit mass of dry air).
|
147 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20, 12.
|
|
|
148 |
Args:
|
149 |
t_db: Dry-bulb temperature in °C
|
150 |
rh: Relative humidity (0-100)
|
151 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
|
|
152 |
Returns:
|
153 |
Humidity ratio in kg water vapor / kg dry air
|
154 |
"""
|
155 |
+
Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm)
|
156 |
+
rh_decimal = max(0.0, min(1.0, rh / 100.0)) # Clamp RH
|
|
|
|
|
|
|
|
|
157 |
p_ws = Psychrometrics.saturation_pressure(t_db)
|
158 |
+
p_w = rh_decimal * p_ws # Eq 12
|
159 |
+
|
160 |
+
# Check if partial pressure exceeds atmospheric pressure (physically impossible)
|
|
|
161 |
if p_w >= p_atm:
|
162 |
+
# This usually indicates very high temp or incorrect pressure
|
163 |
+
logger.warning(f"Calculated partial pressure {p_w:.1f} Pa >= atmospheric pressure {p_atm:.1f} Pa at T={t_db}°C, RH={rh}%. Clamping humidity ratio.")
|
164 |
+
# Return saturation humidity ratio at p_atm (boiling point)
|
165 |
+
p_w_sat_at_p_atm = p_atm # Water boils when p_ws = p_atm
|
166 |
+
w = 0.621945 * p_w_sat_at_p_atm / (p_atm - p_w_sat_at_p_atm + 1e-9) # Add small epsilon to avoid division by zero
|
167 |
+
return w
|
168 |
+
# raise ValueError(f"Partial pressure {p_w:.1f} Pa cannot exceed atmospheric pressure {p_atm:.1f} Pa")
|
169 |
+
|
170 |
+
# Eq 20
|
171 |
w = 0.621945 * p_w / (p_atm - p_w)
|
172 |
+
return max(0.0, w) # Ensure non-negative
|
173 |
+
|
|
|
174 |
@staticmethod
|
175 |
def relative_humidity(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
176 |
"""
|
177 |
Calculate relative humidity from humidity ratio.
|
178 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 22, 12.
|
|
|
179 |
Args:
|
180 |
t_db: Dry-bulb temperature in °C
|
181 |
w: Humidity ratio in kg water vapor / kg dry air
|
182 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
|
|
183 |
Returns:
|
184 |
Relative humidity (0-100)
|
185 |
"""
|
186 |
+
Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
|
187 |
+
w = max(0.0, w) # Ensure non-negative
|
|
|
|
|
|
|
188 |
p_ws = Psychrometrics.saturation_pressure(t_db)
|
189 |
+
|
190 |
+
# Eq 22 (Rearranged from Eq 20)
|
191 |
p_w = p_atm * w / (0.621945 + w)
|
192 |
+
|
193 |
+
if p_ws <= 0:
|
194 |
+
# Avoid division by zero at very low temperatures
|
195 |
+
return 0.0
|
196 |
+
|
197 |
+
# Eq 12 (Definition of RH)
|
198 |
rh = 100.0 * p_w / p_ws
|
199 |
+
return max(0.0, min(100.0, rh)) # Clamp RH between 0 and 100
|
200 |
+
|
|
|
201 |
@staticmethod
|
202 |
+
def wet_bulb_temperature(t_db: float, rh: Optional[float] = None, w: Optional[float] = None,
|
203 |
+
p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
204 |
"""
|
205 |
+
Calculate wet-bulb temperature using an iterative method or direct formula if applicable.
|
206 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 33, 35.
|
207 |
+
Stull, R. (2011). "Wet-Bulb Temperature from Relative Humidity and Air Temperature". Journal of Applied Meteorology and Climatology.
|
208 |
+
|
209 |
Args:
|
210 |
t_db: Dry-bulb temperature in °C
|
211 |
+
rh: Relative humidity (0-100) (either rh or w must be provided)
|
212 |
+
w: Humidity ratio (kg/kg) (either rh or w must be provided)
|
213 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
|
|
214 |
Returns:
|
215 |
Wet-bulb temperature in °C
|
216 |
"""
|
217 |
+
if rh is None and w is None:
|
218 |
+
raise ValueError("Either relative humidity (rh) or humidity ratio (w) must be provided.")
|
219 |
+
if rh is not None:
|
220 |
+
Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm)
|
221 |
+
w_actual = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
|
222 |
+
elif w is not None:
|
223 |
+
Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
|
224 |
+
w_actual = w
|
225 |
+
else:
|
226 |
+
raise ValueError("Calculation error in wet_bulb_temperature input handling.") # Should not happen
|
227 |
+
|
228 |
+
# --- Using Stull's empirical formula (approximation) --- #
|
229 |
+
# Provides a good initial guess or can be used directly for moderate accuracy
|
230 |
+
try:
|
231 |
+
rh_actual = Psychrometrics.relative_humidity(t_db, w_actual, p_atm)
|
232 |
+
rh_decimal = rh_actual / 100.0
|
233 |
+
t_wb_stull = (t_db * math.atan(0.151977 * (rh_actual + 8.313659)**0.5) +
|
234 |
+
math.atan(t_db + rh_actual) -
|
235 |
+
math.atan(rh_actual - 1.676331) +
|
236 |
+
0.00391838 * (rh_actual**1.5) * math.atan(0.023101 * rh_actual) -
|
237 |
+
4.686035)
|
238 |
+
# Check if Stull's result is reasonable (e.g., t_wb <= t_db)
|
239 |
+
if t_wb_stull <= t_db and abs(t_wb_stull - t_db) < 50: # Basic sanity check
|
240 |
+
# Use Stull's value as a very good starting point for iteration
|
241 |
+
t_wb_guess = t_wb_stull
|
242 |
+
else:
|
243 |
+
t_wb_guess = t_db * 0.8 # Fallback guess
|
244 |
+
except Exception:
|
245 |
+
t_wb_guess = t_db * 0.8 # Fallback guess if Stull's formula fails
|
246 |
+
|
247 |
+
# --- Iterative solution based on ASHRAE Eq 33/35 --- #
|
248 |
+
t_wb = t_wb_guess
|
249 |
max_iterations = 100
|
250 |
+
tolerance_w = 1e-7 # Tolerance on humidity ratio
|
251 |
+
|
252 |
for i in range(max_iterations):
|
253 |
+
# Saturation humidity ratio at current guess of t_wb
|
|
|
|
|
|
|
254 |
p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
|
|
|
|
|
255 |
w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
|
256 |
+
w_s_wb = max(0.0, w_s_wb)
|
257 |
+
|
258 |
+
# Humidity ratio calculated from energy balance (Eq 33/35 rearranged)
|
259 |
+
# Using simplified specific heats for this iterative approach
|
260 |
+
c_pa = 1006 # J/(kg·K)
|
261 |
+
c_pw = 1860 # J/(kg·K)
|
262 |
+
h_fg_wb = Psychrometrics.latent_heat_of_vaporization(t_wb) # J/kg
|
263 |
+
|
264 |
+
# Eq 35 rearranged to find W based on Tdb, Twb, Ws_wb
|
265 |
+
numerator = (c_pa + w_s_wb * c_pw) * t_wb - c_pa * t_db
|
266 |
+
denominator = (c_pa + w_s_wb * c_pw) * t_wb - (c_pw * t_db + h_fg_wb)
|
267 |
+
# Avoid division by zero if denominator is close to zero
|
268 |
+
if abs(denominator) < 1e-6:
|
269 |
+
# This might happen near saturation, check if w_actual is close to w_s_wb
|
270 |
+
if abs(w_actual - w_s_wb) < tolerance_w * 10:
|
271 |
+
break # Converged near saturation
|
272 |
+
else:
|
273 |
+
# Adjust guess differently if denominator is zero
|
274 |
+
t_wb -= 0.05 * (1 if w_s_wb > w_actual else -1)
|
275 |
+
continue
|
276 |
+
|
277 |
+
w_calc_from_wb = w_s_wb + numerator / denominator
|
278 |
+
|
279 |
# Check convergence
|
280 |
+
if abs(w_actual - w_calc_from_wb) < tolerance_w:
|
281 |
break
|
282 |
+
|
283 |
+
# Adjust wet-bulb temperature guess (simple step adjustment)
|
284 |
+
# A more sophisticated root-finding method (like Newton-Raphson) could be used here
|
285 |
+
step = 0.1 # Initial step size
|
286 |
+
if i > 10: step = 0.01 # Smaller steps later
|
287 |
+
if i > 50: step = 0.001
|
288 |
+
|
289 |
+
if w_calc_from_wb > w_actual:
|
290 |
+
t_wb -= step # Calculated W is too high, need lower Twb
|
291 |
else:
|
292 |
+
t_wb += step # Calculated W is too low, need higher Twb
|
293 |
+
|
294 |
+
# Ensure t_wb doesn't exceed t_db
|
295 |
+
t_wb = min(t_wb, t_db)
|
296 |
+
|
297 |
+
else:
|
298 |
+
# If loop finishes without break, convergence failed
|
299 |
+
logger.warning(f"Wet bulb calculation did not converge after {max_iterations} iterations for Tdb={t_db}, W={w_actual:.6f}. Result: {t_wb:.3f}")
|
300 |
+
|
301 |
+
# Ensure Twb <= Tdb
|
302 |
+
return min(t_wb, t_db)
|
303 |
+
|
304 |
@staticmethod
|
305 |
+
def dew_point_temperature(t_db: Optional[float] = None, rh: Optional[float] = None,
|
306 |
+
w: Optional[float] = None, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
307 |
"""
|
308 |
Calculate dew point temperature.
|
309 |
+
Uses the relationship Tdp = T(Pw) where Pw is partial pressure.
|
310 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5, 6, 37.
|
311 |
Args:
|
312 |
+
t_db: Dry-bulb temperature in °C (required if rh is given)
|
313 |
+
rh: Relative humidity (0-100) (either rh or w must be provided)
|
314 |
+
w: Humidity ratio (kg/kg) (either rh or w must be provided)
|
315 |
+
p_atm: Atmospheric pressure in Pa (required if w is given)
|
316 |
Returns:
|
317 |
Dew point temperature in °C
|
318 |
"""
|
319 |
+
if rh is None and w is None:
|
320 |
+
raise ValueError("Either relative humidity (rh) or humidity ratio (w) must be provided.")
|
321 |
+
|
322 |
+
if rh is not None:
|
323 |
+
if t_db is None:
|
324 |
+
raise ValueError("Dry-bulb temperature (t_db) must be provided if relative humidity (rh) is given.")
|
325 |
+
Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm)
|
326 |
+
rh_decimal = max(0.0, min(1.0, rh / 100.0))
|
327 |
+
p_ws = Psychrometrics.saturation_pressure(t_db)
|
328 |
+
p_w = rh_decimal * p_ws
|
329 |
+
elif w is not None:
|
330 |
+
Psychrometrics.validate_inputs(w=w, p_atm=p_atm)
|
331 |
+
w = max(0.0, w)
|
332 |
+
# Eq 22 (Rearranged from Eq 20)
|
333 |
+
p_w = p_atm * w / (0.621945 + w)
|
334 |
+
else:
|
335 |
+
raise ValueError("Calculation error in dew_point_temperature input handling.") # Should not happen
|
336 |
+
|
337 |
+
if p_w <= 0:
|
338 |
+
# Handle case of zero humidity
|
339 |
+
return -100.0 # Or some other indicator of very dry air
|
340 |
+
|
341 |
+
# Find temperature at which saturation pressure equals partial pressure p_w
|
342 |
+
# This requires inverting the saturation pressure formula (Eq 5/6)
|
343 |
+
# Using iterative approach or approximation formula (like Magnus formula or ASHRAE Eq 37/38)
|
344 |
+
|
345 |
+
# Using ASHRAE 2017 Eq 37 & 38 (approximation)
|
346 |
+
alpha = math.log(p_w / 610.71) # Note: ASHRAE uses Pw in Pa, but older formulas used kPa. Using Pa here. Ref: Eq 3/4
|
347 |
+
|
348 |
+
# Eq 38 for Tdp >= 0
|
349 |
+
t_dp_pos = (18.678 - alpha / 234.5) * alpha / (257.14 + alpha / 234.5 * alpha)
|
350 |
+
# Eq 37 for Tdp < 0
|
351 |
+
t_dp_neg = 6.09 + 12.608 * alpha + 0.4959 * alpha**2 # This seems less accurate based on testing
|
352 |
+
|
353 |
+
# Alternative Magnus formula approximation (often used):
|
354 |
+
# Constants for Magnus formula (approximation)
|
355 |
+
# A = 17.625
|
356 |
+
# B = 243.04
|
357 |
+
# gamma = math.log(rh_decimal) + (A * t_db) / (B + t_db)
|
358 |
+
# t_dp_magnus = (B * gamma) / (A - gamma)
|
359 |
+
|
360 |
+
# Iterative approach for higher accuracy (finding T such that Pws(T) = Pw)
|
361 |
+
# Start guess near Tdb or using approximation
|
362 |
+
t_dp_guess = t_dp_pos # Use ASHRAE approximation as starting point
|
363 |
+
max_iterations = 20
|
364 |
+
tolerance_p = 0.1 # Pa tolerance
|
365 |
+
|
366 |
+
for i in range(max_iterations):
|
367 |
+
p_ws_at_guess = Psychrometrics.saturation_pressure(t_dp_guess)
|
368 |
+
error = p_w - p_ws_at_guess
|
369 |
+
|
370 |
+
if abs(error) < tolerance_p:
|
371 |
+
break
|
372 |
+
|
373 |
+
# Estimate derivative d(Pws)/dT (Clausius-Clapeyron approximation)
|
374 |
+
# L = Psychrometrics.latent_heat_of_vaporization(t_dp_guess)
|
375 |
+
# Rv = GAS_CONSTANT_WATER_VAPOR
|
376 |
+
# T_k = t_dp_guess + 273.15
|
377 |
+
# dP_dT = (p_ws_at_guess * L) / (Rv * T_k**2)
|
378 |
+
# A simpler approximation for derivative:
|
379 |
+
p_ws_plus = Psychrometrics.saturation_pressure(t_dp_guess + 0.01)
|
380 |
+
dP_dT = (p_ws_plus - p_ws_at_guess) / 0.01
|
381 |
+
|
382 |
+
if abs(dP_dT) < 1e-3: # Avoid division by small number if derivative is near zero
|
383 |
+
break
|
384 |
+
|
385 |
+
# Newton-Raphson step
|
386 |
+
t_dp_guess += error / dP_dT
|
387 |
else:
|
388 |
+
logger.debug(f"Dew point iteration did not fully converge for Pw={p_w:.2f} Pa. Result: {t_dp_guess:.3f}")
|
389 |
+
|
390 |
+
return t_dp_guess
|
391 |
+
|
392 |
+
@staticmethod
|
393 |
+
def latent_heat_of_vaporization(t_db: float) -> float:
|
394 |
+
"""
|
395 |
+
Calculate latent heat of vaporization of water at a given temperature.
|
396 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 2.
|
397 |
+
Args:
|
398 |
+
t_db: Dry-bulb temperature in °C
|
399 |
+
Returns:
|
400 |
+
Latent heat of vaporization (h_fg) in J/kg
|
401 |
+
"""
|
402 |
+
# Eq 2 (Approximation)
|
403 |
+
h_fg = (2501 - 2.361 * t_db) * 1000 # Convert kJ/kg to J/kg
|
404 |
+
return h_fg
|
405 |
+
|
406 |
@staticmethod
|
407 |
def enthalpy(t_db: float, w: float) -> float:
|
408 |
"""
|
409 |
Calculate specific enthalpy of moist air.
|
410 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30.
|
411 |
+
Datum: 0 J/kg for dry air at 0°C, 0 J/kg for saturated liquid water at 0°C.
|
412 |
Args:
|
413 |
t_db: Dry-bulb temperature in °C
|
414 |
w: Humidity ratio in kg water vapor / kg dry air
|
|
|
415 |
Returns:
|
416 |
Specific enthalpy in J/kg dry air
|
417 |
"""
|
418 |
+
Psychrometrics.validate_inputs(t_db=t_db, w=w)
|
419 |
+
w = max(0.0, w)
|
420 |
+
|
421 |
+
# Using more accurate specific heats if needed, but ASHRAE Eq 30 uses constants:
|
422 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
423 |
+
h_g0 = 2501000 # Enthalpy of water vapor at 0°C in J/kg
|
424 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
425 |
+
|
426 |
+
# Eq 30
|
427 |
+
h = c_pa * t_db + w * (h_g0 + c_pw * t_db)
|
428 |
return h
|
429 |
+
|
430 |
@staticmethod
|
431 |
def specific_volume(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
432 |
"""
|
433 |
Calculate specific volume of moist air.
|
434 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 26.
|
|
|
435 |
Args:
|
436 |
t_db: Dry-bulb temperature in °C
|
437 |
w: Humidity ratio in kg water vapor / kg dry air
|
438 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
|
|
439 |
Returns:
|
440 |
Specific volume in m³/kg dry air
|
441 |
"""
|
442 |
+
Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
|
443 |
+
w = max(0.0, w)
|
|
|
|
|
|
|
444 |
t_k = t_db + 273.15
|
445 |
+
r_da = GAS_CONSTANT_DRY_AIR
|
446 |
+
|
447 |
+
# Eq 26 (Ideal Gas Law for moist air)
|
448 |
+
# Factor 1.607858 is Ratio of MW_air / MW_water approx (28.9645 / 18.01534)
|
449 |
+
v = (r_da * t_k / p_atm) * (1 + 1.607858 * w)
|
450 |
return v
|
451 |
+
|
452 |
@staticmethod
|
453 |
def density(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
454 |
"""
|
455 |
Calculate density of moist air.
|
456 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, derived from Equation 26.
|
457 |
+
Density = Mass / Volume = (Mass Dry Air + Mass Water Vapor) / Volume
|
458 |
+
= (1 + w) / specific_volume
|
459 |
Args:
|
460 |
t_db: Dry-bulb temperature in °C
|
461 |
w: Humidity ratio in kg water vapor / kg dry air
|
462 |
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
|
|
463 |
Returns:
|
464 |
+
Density in kg moist air / m³
|
465 |
+
"""
|
466 |
+
Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
|
467 |
+
w = max(0.0, w)
|
468 |
+
v = Psychrometrics.specific_volume(t_db, w, p_atm) # m³/kg dry air
|
469 |
+
if v <= 0:
|
470 |
+
raise ValueError("Calculated specific volume is non-positive, cannot calculate density.")
|
471 |
+
# Density = mass_total / volume = (mass_dry_air + mass_water) / volume
|
472 |
+
# Since v = volume / mass_dry_air, then density = (1 + w) / v
|
|
|
473 |
rho = (1 + w) / v
|
|
|
474 |
return rho
|
475 |
+
|
476 |
+
# --- Comprehensive Property Calculation (Preserved) --- #
|
477 |
@staticmethod
|
478 |
+
def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE,
|
479 |
+
altitude: Optional[float] = None) -> Dict[str, float]:
|
480 |
"""
|
481 |
Calculate all psychrometric properties of moist air.
|
482 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
|
|
|
483 |
Args:
|
484 |
t_db: Dry-bulb temperature in °C
|
485 |
rh: Relative humidity (0-100)
|
486 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure).
|
487 |
+
If altitude is provided, p_atm is calculated and this value is ignored.
|
488 |
+
altitude: Altitude in meters (optional). If provided, calculates pressure at altitude.
|
489 |
Returns:
|
490 |
+
Dictionary with all psychrometric properties.
|
491 |
+
"""
|
492 |
+
if altitude is not None:
|
493 |
+
p_atm_calc = Psychrometrics.pressure_at_altitude(altitude)
|
494 |
+
logger.debug(f"Calculated pressure at altitude {altitude}m: {p_atm_calc:.0f} Pa")
|
495 |
+
p_atm_used = p_atm_calc
|
496 |
+
else:
|
497 |
+
p_atm_used = p_atm
|
498 |
+
|
499 |
+
Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm_used)
|
500 |
+
rh_clamped = max(0.0, min(100.0, rh))
|
501 |
+
|
502 |
+
w = Psychrometrics.humidity_ratio(t_db, rh_clamped, p_atm_used)
|
503 |
+
t_wb = Psychrometrics.wet_bulb_temperature(t_db, rh=rh_clamped, w=w, p_atm=p_atm_used)
|
504 |
+
t_dp = Psychrometrics.dew_point_temperature(t_db=t_db, rh=rh_clamped, w=w, p_atm=p_atm_used)
|
505 |
h = Psychrometrics.enthalpy(t_db, w)
|
506 |
+
v = Psychrometrics.specific_volume(t_db, w, p_atm_used)
|
507 |
+
rho = Psychrometrics.density(t_db, w, p_atm_used)
|
|
|
|
|
|
|
|
|
|
|
|
|
508 |
p_ws = Psychrometrics.saturation_pressure(t_db)
|
509 |
+
p_w = (rh_clamped / 100.0) * p_ws
|
510 |
+
|
|
|
|
|
|
|
511 |
return {
|
512 |
+
"dry_bulb_temperature_c": t_db,
|
513 |
+
"wet_bulb_temperature_c": t_wb,
|
514 |
+
"dew_point_temperature_c": t_dp,
|
515 |
+
"relative_humidity_percent": rh_clamped,
|
516 |
+
"humidity_ratio_kg_kg": w,
|
517 |
+
"enthalpy_j_kg": h,
|
518 |
+
"specific_volume_m3_kg": v,
|
519 |
+
"density_kg_m3": rho,
|
520 |
+
"saturation_pressure_pa": p_ws,
|
521 |
+
"partial_pressure_pa": p_w,
|
522 |
+
"atmospheric_pressure_pa": p_atm_used
|
523 |
}
|
524 |
+
|
525 |
+
# --- Inverse Functions (Preserved) --- #
|
526 |
@staticmethod
|
527 |
def find_humidity_ratio_for_enthalpy(t_db: float, h: float) -> float:
|
528 |
"""
|
529 |
Find humidity ratio for a given dry-bulb temperature and enthalpy.
|
530 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
|
|
|
531 |
Args:
|
532 |
t_db: Dry-bulb temperature in °C
|
533 |
h: Specific enthalpy in J/kg dry air
|
|
|
534 |
Returns:
|
535 |
Humidity ratio in kg water vapor / kg dry air
|
536 |
"""
|
537 |
+
Psychrometrics.validate_inputs(t_db=t_db, h=h)
|
538 |
+
c_pa = 1006
|
539 |
+
h_g0 = 2501000
|
540 |
+
c_pw = 1860
|
541 |
+
denominator = (h_g0 + c_pw * t_db)
|
542 |
+
if abs(denominator) < 1e-6:
|
543 |
+
# Avoid division by zero, happens at specific low temps where denominator is zero
|
544 |
+
logger.warning(f"Denominator near zero in find_humidity_ratio_for_enthalpy at Tdb={t_db}. Enthalpy {h} may be inconsistent.")
|
545 |
+
return 0.0 # Or raise error
|
546 |
+
w = (h - c_pa * t_db) / denominator
|
547 |
+
return max(0.0, w) # Humidity ratio cannot be negative
|
548 |
+
|
549 |
@staticmethod
|
550 |
def find_temperature_for_enthalpy(w: float, h: float) -> float:
|
551 |
"""
|
552 |
Find dry-bulb temperature for a given humidity ratio and enthalpy.
|
553 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
|
|
|
554 |
Args:
|
555 |
w: Humidity ratio in kg water vapor / kg dry air
|
556 |
h: Specific enthalpy in J/kg dry air
|
|
|
557 |
Returns:
|
558 |
Dry-bulb temperature in °C
|
559 |
"""
|
560 |
+
Psychrometrics.validate_inputs(w=w, h=h)
|
561 |
+
w = max(0.0, w)
|
562 |
+
c_pa = 1006
|
563 |
+
h_g0 = 2501000
|
564 |
+
c_pw = 1860
|
565 |
+
denominator = (c_pa + w * c_pw)
|
566 |
+
if abs(denominator) < 1e-6:
|
567 |
+
raise ValueError(f"Cannot calculate temperature: denominator (Cp_a + w*Cp_w) is near zero for w={w}")
|
568 |
+
t_db = (h - w * h_g0) / denominator
|
569 |
+
# Validate the result is within reasonable bounds
|
570 |
+
Psychrometrics.validate_inputs(t_db=t_db)
|
|
|
571 |
return t_db
|
572 |
+
|
573 |
+
# --- Heat Ratio and Flow Rate (Preserved) --- #
|
574 |
@staticmethod
|
575 |
def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
|
576 |
"""
|
577 |
+
Calculate sensible heat ratio (SHR).
|
578 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.5.
|
|
|
579 |
Args:
|
580 |
+
q_sensible: Sensible heat load in W (can be negative for cooling)
|
581 |
+
q_total: Total heat load in W (sensible + latent) (can be negative for cooling)
|
|
|
582 |
Returns:
|
583 |
+
Sensible heat ratio (typically 0 to 1 for cooling, can be >1 or <0 in some cases)
|
584 |
+
"""
|
585 |
+
if abs(q_total) < 1e-9: # Avoid division by zero
|
586 |
+
# If total load is zero, SHR is undefined or can be considered 1 if only sensible exists
|
587 |
+
return 1.0 if abs(q_sensible) < 1e-9 else (1.0 if q_sensible > 0 else -1.0) # Or np.nan
|
588 |
+
shr = q_sensible / q_total
|
589 |
+
return shr
|
590 |
+
|
|
|
591 |
@staticmethod
|
592 |
+
def air_flow_rate_for_load(q_sensible: float, delta_t: float,
|
593 |
+
rho: Optional[float] = None, cp: float = 1006,
|
594 |
+
altitude: Optional[float] = None) -> float:
|
595 |
"""
|
596 |
+
Calculate volumetric air flow rate required to meet a sensible load.
|
597 |
+
Formula: q_sensible = m_dot * cp * delta_t = (rho * V_dot) * cp * delta_t
|
598 |
+
V_dot = q_sensible / (rho * cp * delta_t)
|
599 |
Args:
|
600 |
+
q_sensible: Sensible heat load in W.
|
601 |
+
delta_t: Temperature difference between supply and return air in °C (or K).
|
602 |
+
rho: Density of air in kg/m³ (optional, will use standard density if None).
|
603 |
+
cp: Specific heat of air in J/(kg·K) (default: 1006).
|
604 |
+
altitude: Altitude in meters (optional, used to estimate density if rho is None).
|
|
|
605 |
Returns:
|
606 |
+
Volumetric air flow rate (V_dot) in m³/s.
|
607 |
+
"""
|
608 |
+
if abs(delta_t) < 1e-6:
|
609 |
+
raise ValueError("Delta T cannot be zero for air flow rate calculation.")
|
610 |
+
|
611 |
+
if rho is None:
|
612 |
+
# Estimate density based on typical conditions or altitude
|
613 |
+
if altitude is not None:
|
614 |
+
p_atm_alt = Psychrometrics.pressure_at_altitude(altitude)
|
615 |
+
# Assume typical indoor conditions for density calculation
|
616 |
+
rho = Psychrometrics.density(t_db=22, w=0.008, p_atm=p_atm_alt)
|
617 |
+
else:
|
618 |
+
# Use standard sea level density as approximation
|
619 |
+
rho = Psychrometrics.density(t_db=20, w=0.0075) # Approx 1.2 kg/m³
|
620 |
+
logger.debug(f"Using estimated air density: {rho:.3f} kg/m³")
|
621 |
+
|
622 |
+
if rho <= 0:
|
623 |
+
raise ValueError("Air density must be positive.")
|
624 |
+
|
625 |
+
v_dot = q_sensible / (rho * cp * delta_t)
|
626 |
+
return v_dot
|
627 |
+
|
628 |
+
# --- Air Mixing Function (Added based on plan) --- #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
629 |
@staticmethod
|
630 |
+
def mix_air_streams(stream1: Dict[str, float], stream2: Dict[str, float],
|
631 |
+
p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
|
|
|
632 |
"""
|
633 |
+
Calculate the properties of a mixture of two moist air streams.
|
634 |
+
Assumes adiabatic mixing at constant pressure.
|
635 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.4.
|
636 |
+
|
637 |
Args:
|
638 |
+
stream1: Dict for stream 1 containing keys: 'flow_rate' (m³/s), 't_db' (°C), 'rh' (%) OR 'w' (kg/kg).
|
639 |
+
stream2: Dict for stream 2 containing keys: 'flow_rate' (m³/s), 't_db' (°C), 'rh' (%) OR 'w' (kg/kg).
|
640 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure).
|
641 |
+
|
|
|
|
|
|
|
|
|
642 |
Returns:
|
643 |
+
Dictionary with properties of the mixed stream: 't_db', 'w', 'rh', 'h', 'flow_rate'.
|
644 |
+
Raises:
|
645 |
+
ValueError: If input dictionaries are missing required keys or have invalid values.
|
646 |
+
"""
|
647 |
+
|
648 |
+
# Validate inputs and get full properties for each stream
|
649 |
+
props1 = {}
|
650 |
+
props2 = {}
|
651 |
+
try:
|
652 |
+
t_db1 = stream1['t_db']
|
653 |
+
flow1 = stream1['flow_rate']
|
654 |
+
if 'rh' in stream1:
|
655 |
+
props1 = Psychrometrics.moist_air_properties(t_db1, stream1['rh'], p_atm)
|
656 |
+
elif 'w' in stream1:
|
657 |
+
w1 = stream1['w']
|
658 |
+
Psychrometrics.validate_inputs(t_db=t_db1, w=w1, p_atm=p_atm)
|
659 |
+
props1 = Psychrometrics.moist_air_properties(t_db1, Psychrometrics.relative_humidity(t_db1, w1, p_atm), p_atm)
|
660 |
+
else:
|
661 |
+
raise ValueError("Stream 1 must contain 'rh' or 'w'.")
|
662 |
+
if flow1 < 0: raise ValueError("Stream 1 flow rate cannot be negative.")
|
663 |
+
m_dot1 = flow1 * props1['density_kg_m3'] # Mass flow rate kg/s
|
664 |
+
|
665 |
+
t_db2 = stream2['t_db']
|
666 |
+
flow2 = stream2['flow_rate']
|
667 |
+
if 'rh' in stream2:
|
668 |
+
props2 = Psychrometrics.moist_air_properties(t_db2, stream2['rh'], p_atm)
|
669 |
+
elif 'w' in stream2:
|
670 |
+
w2 = stream2['w']
|
671 |
+
Psychrometrics.validate_inputs(t_db=t_db2, w=w2, p_atm=p_atm)
|
672 |
+
props2 = Psychrometrics.moist_air_properties(t_db2, Psychrometrics.relative_humidity(t_db2, w2, p_atm), p_atm)
|
673 |
+
else:
|
674 |
+
raise ValueError("Stream 2 must contain 'rh' or 'w'.")
|
675 |
+
if flow2 < 0: raise ValueError("Stream 2 flow rate cannot be negative.")
|
676 |
+
m_dot2 = flow2 * props2['density_kg_m3'] # Mass flow rate kg/s
|
677 |
+
|
678 |
+
except KeyError as e:
|
679 |
+
raise ValueError(f"Missing required key in input stream dictionary: {e}")
|
680 |
+
except ValueError as e:
|
681 |
+
raise ValueError(f"Invalid input value: {e}")
|
682 |
+
|
683 |
+
# Total mass flow rate
|
684 |
+
m_dot_mix = m_dot1 + m_dot2
|
685 |
+
|
686 |
+
if m_dot_mix <= 1e-9: # Avoid division by zero if total flow is zero
|
687 |
+
logger.warning("Total mass flow rate for mixing is zero. Returning properties of stream 1 (or empty dict if flow1 is also zero).")
|
688 |
+
if m_dot1 > 1e-9:
|
689 |
+
return {
|
690 |
+
't_db': props1['dry_bulb_temperature_c'],
|
691 |
+
'w': props1['humidity_ratio_kg_kg'],
|
692 |
+
'rh': props1['relative_humidity_percent'],
|
693 |
+
'h': props1['enthalpy_j_kg'],
|
694 |
+
'flow_rate': flow1
|
695 |
+
}
|
696 |
+
else: # Both flows are zero
|
697 |
+
return {'t_db': 0, 'w': 0, 'rh': 0, 'h': 0, 'flow_rate': 0}
|
698 |
+
|
699 |
+
# Mass balance for humidity ratio
|
700 |
+
w_mix = (m_dot1 * props1['humidity_ratio_kg_kg'] + m_dot2 * props2['humidity_ratio_kg_kg']) / m_dot_mix
|
701 |
+
|
702 |
+
# Energy balance for enthalpy
|
703 |
+
h_mix = (m_dot1 * props1['enthalpy_j_kg'] + m_dot2 * props2['enthalpy_j_kg']) / m_dot_mix
|
704 |
+
|
705 |
+
# Find mixed temperature from mixed enthalpy and humidity ratio
|
706 |
t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
|
707 |
+
|
708 |
+
# Find mixed relative humidity
|
709 |
rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
|
|
|
|
|
|
|
710 |
|
711 |
+
# Calculate mixed flow rate (volume)
|
712 |
+
# Need density at mixed conditions
|
713 |
+
rho_mix = Psychrometrics.density(t_db_mix, w_mix, p_atm)
|
714 |
+
flow_mix = m_dot_mix / rho_mix if rho_mix > 0 else 0
|
715 |
|
716 |
+
return {
|
717 |
+
't_db': t_db_mix,
|
718 |
+
'w': w_mix,
|
719 |
+
'rh': rh_mix,
|
720 |
+
'h': h_mix,
|
721 |
+
'flow_rate': flow_mix
|
722 |
+
}
|
723 |
|
724 |
+
# Example Usage (Preserved and expanded)
|
725 |
if __name__ == "__main__":
|
726 |
+
# Test basic properties
|
727 |
+
t_db_test = 25.0
|
728 |
+
rh_test = 50.0
|
729 |
+
p_atm_test = 101325.0
|
730 |
+
altitude_test = 1500 # meters
|
731 |
+
|
732 |
+
print(f"--- Properties at T={t_db_test}°C, RH={rh_test}%, P={p_atm_test} Pa ---")
|
733 |
+
props_sea_level = Psychrometrics.moist_air_properties(t_db_test, rh_test, p_atm_test)
|
734 |
+
for key, value in props_sea_level.items():
|
735 |
+
print(f"{key}: {value:.6f}")
|
736 |
+
|
737 |
+
print(f"\n--- Properties at T={t_db_test}°C, RH={rh_test}%, Altitude={altitude_test} m ---")
|
738 |
+
props_altitude = Psychrometrics.moist_air_properties(t_db_test, rh_test, altitude=altitude_test)
|
739 |
+
for key, value in props_altitude.items():
|
740 |
+
print(f"{key}: {value:.6f}")
|
741 |
+
p_calc_alt = Psychrometrics.pressure_at_altitude(altitude_test)
|
742 |
+
print(f"Calculated pressure at {altitude_test}m: {p_calc_alt:.0f} Pa (matches: {abs(p_calc_alt - props_altitude[\"atmospheric_pressure_pa\"]) < 1e-3})")
|
743 |
+
|
744 |
+
# Test air mixing
|
745 |
+
print("\n--- Air Mixing Test ---")
|
746 |
+
stream_a = {'flow_rate': 1.0, 't_db': 30.0, 'rh': 60.0} # m³/s, °C, %
|
747 |
+
stream_b = {'flow_rate': 0.5, 't_db': 15.0, 'w': 0.005} # m³/s, °C, kg/kg
|
748 |
+
p_mix = 100000.0 # Pa
|
749 |
+
|
750 |
+
print(f"Stream A: {stream_a}")
|
751 |
+
print(f"Stream B: {stream_b}")
|
752 |
+
print(f"Mixing at Pressure: {p_mix} Pa")
|
753 |
+
|
754 |
+
try:
|
755 |
+
mixed_props = Psychrometrics.mix_air_streams(stream_a, stream_b, p_atm=p_mix)
|
756 |
+
print("\nMixed Stream Properties:")
|
757 |
+
for key, value in mixed_props.items():
|
758 |
+
print(f"{key}: {value:.6f}")
|
759 |
+
except ValueError as e:
|
760 |
+
print(f"\nError during mixing calculation: {e}")
|
761 |
+
|
762 |
+
# Test edge cases
|
763 |
+
print("\n--- Edge Case Tests ---")
|
764 |
+
try:
|
765 |
+
print(f"Dew point at 5°C, 100% RH: {Psychrometrics.dew_point_temperature(t_db=5.0, rh=100.0):.3f}°C")
|
766 |
+
print(f"Dew point at -10°C, 80% RH: {Psychrometrics.dew_point_temperature(t_db=-10.0, rh=80.0):.3f}°C")
|
767 |
+
print(f"Wet bulb at 30°C, 100% RH: {Psychrometrics.wet_bulb_temperature(t_db=30.0, rh=100.0):.3f}°C")
|
768 |
+
print(f"Wet bulb at -5°C, 50% RH: {Psychrometrics.wet_bulb_temperature(t_db=-5.0, rh=50.0):.3f}°C")
|
769 |
+
# Test high temp / high humidity
|
770 |
+
props_hot_humid = Psychrometrics.moist_air_properties(t_db=50, rh=90, p_atm=101325)
|
771 |
+
print(f"Properties at 50°C, 90% RH: W={props_hot_humid[\"humidity_ratio_kg_kg\"]:.6f}, H={props_hot_humid[\"enthalpy_j_kg\"]:.0f}")
|
772 |
+
except ValueError as e:
|
773 |
+
print(f"Error during edge case test: {e}")
|
774 |
+
|
775 |
+
|