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 |
+
|