HVAC-text-02 / utils /psychrometrics.py
mabuseif's picture
Upload 27 files
ca54a52 verified
"""
Psychrometric module for HVAC Load Calculator.
This module implements psychrometric calculations for air properties.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
"""
from typing import Dict, List, Any, Optional, Tuple
import math
import numpy as np
# Constants
ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure in Pa
WATER_MOLECULAR_WEIGHT = 18.01534 # kg/kmol
DRY_AIR_MOLECULAR_WEIGHT = 28.9645 # kg/kmol
UNIVERSAL_GAS_CONSTANT = 8314.462618 # J/(kmol·K)
GAS_CONSTANT_DRY_AIR = UNIVERSAL_GAS_CONSTANT / DRY_AIR_MOLECULAR_WEIGHT # J/(kg·K)
GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K)
class Psychrometrics:
"""Class for psychrometric calculations."""
@staticmethod
def validate_inputs(t_db: float, rh: Optional[float] = None, p_atm: Optional[float] = None) -> None:
"""
Validate input parameters for psychrometric calculations.
Args:
t_db: Dry-bulb temperature in °C
rh: Relative humidity in % (0-100), optional
p_atm: Atmospheric pressure in Pa, optional
Raises:
ValueError: If inputs are invalid
"""
if not -50 <= t_db <= 60:
raise ValueError(f"Temperature {t_db}°C must be between -50°C and 60°C")
if rh is not None and not 0 <= rh <= 100:
raise ValueError(f"Relative humidity {rh}% must be between 0 and 100%")
if p_atm is not None and p_atm <= 0:
raise ValueError(f"Atmospheric pressure {p_atm} Pa must be positive")
@staticmethod
def saturation_pressure(t_db: float) -> float:
"""
Calculate saturation pressure of water vapor.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5 and 6.
Args:
t_db: Dry-bulb temperature in °C
Returns:
Saturation pressure in Pa
"""
Psychrometrics.validate_inputs(t_db)
# Convert temperature to Kelvin
t_k = t_db + 273.15
# ASHRAE Fundamentals 2017 Chapter 1, Equation 5 & 6
if t_db >= 0:
# Equation 5 for temperatures above freezing
c1 = -5.8002206e3
c2 = 1.3914993
c3 = -4.8640239e-2
c4 = 4.1764768e-5
c5 = -1.4452093e-8
c6 = 6.5459673
else:
# Equation 6 for temperatures below freezing
c1 = -5.6745359e3
c2 = 6.3925247
c3 = -9.6778430e-3
c4 = 6.2215701e-7
c5 = 2.0747825e-9
c6 = -9.4840240e-13
c7 = 4.1635019
# Calculate natural log of saturation pressure in Pa
if t_db >= 0:
ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * math.log(t_k)
else:
ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * t_k**4 + c7 * math.log(t_k)
# Convert from natural log to actual pressure in Pa
p_ws = math.exp(ln_p_ws)
return p_ws
@staticmethod
def humidity_ratio(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
"""
Calculate humidity ratio (mass of water vapor per unit mass of dry air).
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20.
Args:
t_db: Dry-bulb temperature in °C
rh: Relative humidity (0-100)
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
Returns:
Humidity ratio in kg water vapor / kg dry air
"""
Psychrometrics.validate_inputs(t_db, rh, p_atm)
# Convert relative humidity to decimal
rh_decimal = rh / 100.0
# Calculate saturation pressure
p_ws = Psychrometrics.saturation_pressure(t_db)
# Calculate partial pressure of water vapor
p_w = rh_decimal * p_ws
if p_w >= p_atm:
raise ValueError("Partial pressure of water vapor exceeds atmospheric pressure")
# Calculate humidity ratio
w = 0.621945 * p_w / (p_atm - p_w)
return w
@staticmethod
def relative_humidity(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
"""
Calculate relative humidity from humidity ratio.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20 (rearranged).
Args:
t_db: Dry-bulb temperature in °C
w: Humidity ratio in kg water vapor / kg dry air
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
Returns:
Relative humidity (0-100)
"""
Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
if w < 0:
raise ValueError("Humidity ratio cannot be negative")
# Calculate saturation pressure
p_ws = Psychrometrics.saturation_pressure(t_db)
# Calculate partial pressure of water vapor
p_w = p_atm * w / (0.621945 + w)
# Calculate relative humidity
rh = 100.0 * p_w / p_ws
return rh
@staticmethod
def wet_bulb_temperature(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
"""
Calculate wet-bulb temperature using iterative method.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 35.
Args:
t_db: Dry-bulb temperature in °C
rh: Relative humidity (0-100)
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
Returns:
Wet-bulb temperature in °C
"""
Psychrometrics.validate_inputs(t_db, rh, p_atm)
# Calculate humidity ratio at given conditions
w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
# Initial guess for wet-bulb temperature
t_wb = t_db
# Iterative solution
max_iterations = 100
tolerance = 0.001 # °C
for i in range(max_iterations):
# Validate wet-bulb temperature
Psychrometrics.validate_inputs(t_wb)
# Calculate saturation pressure at wet-bulb temperature
p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
# Calculate saturation humidity ratio at wet-bulb temperature
w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
# Calculate humidity ratio from wet-bulb temperature
h_fg = 2501000 + 1840 * t_wb # Latent heat of vaporization at t_wb in J/kg
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
w_calc = ((h_fg - c_pw * (t_db - t_wb)) * w_s_wb - c_pa * (t_db - t_wb)) / (h_fg + c_pw * t_db - c_pw * t_wb)
# Check convergence
if abs(w - w_calc) < tolerance:
break
# Adjust wet-bulb temperature
if w_calc > w:
t_wb -= 0.1
else:
t_wb += 0.1
return t_wb
@staticmethod
def dew_point_temperature(t_db: float, rh: float) -> float:
"""
Calculate dew point temperature.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 39 and 40.
Args:
t_db: Dry-bulb temperature in °C
rh: Relative humidity (0-100)
Returns:
Dew point temperature in °C
"""
Psychrometrics.validate_inputs(t_db, rh)
# Convert relative humidity to decimal
rh_decimal = rh / 100.0
# Calculate saturation pressure
p_ws = Psychrometrics.saturation_pressure(t_db)
# Calculate partial pressure of water vapor
p_w = rh_decimal * p_ws
# Calculate dew point temperature
alpha = math.log(p_w / 1000.0) # Convert to kPa for the formula
if t_db >= 0:
# For temperatures above freezing
c14 = 6.54
c15 = 14.526
c16 = 0.7389
c17 = 0.09486
c18 = 0.4569
t_dp = c14 + c15 * alpha + c16 * alpha**2 + c17 * alpha**3 + c18 * p_w**(0.1984)
else:
# For temperatures below freezing
c14 = 6.09
c15 = 12.608
c16 = 0.4959
t_dp = c14 + c15 * alpha + c16 * alpha**2
return t_dp
@staticmethod
def enthalpy(t_db: float, w: float) -> float:
"""
Calculate specific enthalpy of moist air.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30.
Args:
t_db: Dry-bulb temperature in °C
w: Humidity ratio in kg water vapor / kg dry air
Returns:
Specific enthalpy in J/kg dry air
"""
Psychrometrics.validate_inputs(t_db)
if w < 0:
raise ValueError("Humidity ratio cannot be negative")
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
h = c_pa * t_db + w * (h_fg + c_pw * t_db)
return h
@staticmethod
def specific_volume(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
"""
Calculate specific volume of moist air.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 28.
Args:
t_db: Dry-bulb temperature in °C
w: Humidity ratio in kg water vapor / kg dry air
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
Returns:
Specific volume in m³/kg dry air
"""
Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
if w < 0:
raise ValueError("Humidity ratio cannot be negative")
# Convert temperature to Kelvin
t_k = t_db + 273.15
r_da = GAS_CONSTANT_DRY_AIR # Gas constant for dry air in J/(kg·K)
v = r_da * t_k * (1 + 1.607858 * w) / p_atm
return v
@staticmethod
def density(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
"""
Calculate density of moist air.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, derived from Equation 28.
Args:
t_db: Dry-bulb temperature in °C
w: Humidity ratio in kg water vapor / kg dry air
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
Returns:
Density in kg/m³
"""
Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
if w < 0:
raise ValueError("Humidity ratio cannot be negative")
# Calculate specific volume
v = Psychrometrics.specific_volume(t_db, w, p_atm)
# Density is the reciprocal of specific volume
rho = (1 + w) / v
return rho
@staticmethod
def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
"""
Calculate all psychrometric properties of moist air.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
Args:
t_db: Dry-bulb temperature in °C
rh: Relative humidity (0-100)
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
Returns:
Dictionary with all psychrometric properties
"""
Psychrometrics.validate_inputs(t_db, rh, p_atm)
# Calculate humidity ratio
w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
# Calculate wet-bulb temperature
t_wb = Psychrometrics.wet_bulb_temperature(t_db, rh, p_atm)
# Calculate dew point temperature
t_dp = Psychrometrics.dew_point_temperature(t_db, rh)
# Calculate enthalpy
h = Psychrometrics.enthalpy(t_db, w)
# Calculate specific volume
v = Psychrometrics.specific_volume(t_db, w, p_atm)
# Calculate density
rho = Psychrometrics.density(t_db, w, p_atm)
# Calculate saturation pressure
p_ws = Psychrometrics.saturation_pressure(t_db)
# Calculate partial pressure of water vapor
p_w = rh / 100.0 * p_ws
# Return all properties
return {
"dry_bulb_temperature": t_db,
"wet_bulb_temperature": t_wb,
"dew_point_temperature": t_dp,
"relative_humidity": rh,
"humidity_ratio": w,
"enthalpy": h,
"specific_volume": v,
"density": rho,
"saturation_pressure": p_ws,
"partial_pressure": p_w,
"atmospheric_pressure": p_atm
}
@staticmethod
def find_humidity_ratio_for_enthalpy(t_db: float, h: float) -> float:
"""
Find humidity ratio for a given dry-bulb temperature and enthalpy.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
Args:
t_db: Dry-bulb temperature in °C
h: Specific enthalpy in J/kg dry air
Returns:
Humidity ratio in kg water vapor / kg dry air
"""
Psychrometrics.validate_inputs(t_db)
if h < 0:
raise ValueError("Enthalpy cannot be negative")
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
w = (h - c_pa * t_db) / (h_fg + c_pw * t_db)
return max(0, w)
@staticmethod
def find_temperature_for_enthalpy(w: float, h: float) -> float:
"""
Find dry-bulb temperature for a given humidity ratio and enthalpy.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
Args:
w: Humidity ratio in kg water vapor / kg dry air
h: Specific enthalpy in J/kg dry air
Returns:
Dry-bulb temperature in °C
"""
if w < 0:
raise ValueError("Humidity ratio cannot be negative")
if h < 0:
raise ValueError("Enthalpy cannot be negative")
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
t_db = (h - w * h_fg) / (c_pa + w * c_pw)
Psychrometrics.validate_inputs(t_db)
return t_db
@staticmethod
def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
"""
Calculate sensible heat ratio.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.5.
Args:
q_sensible: Sensible heat load in W
q_total: Total heat load in W
Returns:
Sensible heat ratio (0-1)
"""
if q_total == 0:
return 1.0
if q_sensible < 0 or q_total < 0:
raise ValueError("Heat loads cannot be negative")
return q_sensible / q_total
@staticmethod
def air_flow_rate_for_load(q_sensible: float, t_supply: float, t_return: float,
rh_return: float = 50.0, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
"""
Calculate required air flow rate for a given sensible load.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.6.
Args:
q_sensible: Sensible heat load in W
t_supply: Supply air temperature in °C
t_return: Return air temperature in °C
rh_return: Return air relative humidity in % (default: 50%)
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
Returns:
Dictionary with air flow rate in different units
"""
Psychrometrics.validate_inputs(t_return, rh_return, p_atm)
Psychrometrics.validate_inputs(t_supply)
# Calculate return air properties
w_return = Psychrometrics.humidity_ratio(t_return, rh_return, p_atm)
rho_return = Psychrometrics.density(t_return, w_return, p_atm)
# Calculate specific heat of moist air
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
c_p_moist = c_pa + w_return * c_pw
# Calculate mass flow rate
delta_t = t_return - t_supply
if delta_t == 0:
raise ValueError("Supply and return temperatures cannot be equal")
m_dot = q_sensible / (c_p_moist * delta_t)
# Calculate volumetric flow rate
v_dot = m_dot / rho_return
# Convert to different units
v_dot_m3_s = v_dot
v_dot_m3_h = v_dot * 3600
v_dot_cfm = v_dot * 2118.88
v_dot_l_s = v_dot * 1000
return {
"mass_flow_rate_kg_s": m_dot,
"volumetric_flow_rate_m3_s": v_dot_m3_s,
"volumetric_flow_rate_m3_h": v_dot_m3_h,
"volumetric_flow_rate_cfm": v_dot_cfm,
"volumetric_flow_rate_l_s": v_dot_l_s
}
@staticmethod
def mixing_air_properties(m1: float, t_db1: float, rh1: float,
m2: float, t_db2: float, rh2: float,
p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
"""
Calculate properties of mixed airstreams.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.7.
Args:
m1: Mass flow rate of airstream 1 in kg/s
t_db1: Dry-bulb temperature of airstream 1 in °C
rh1: Relative humidity of airstream 1 in %
m2: Mass flow rate of airstream 2 in kg/s
t_db2: Dry-bulb temperature of airstream 2 in °C
rh2: Relative humidity of airstream 2 in %
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
Returns:
Dictionary with mixed air properties
"""
Psychrometrics.validate_inputs(t_db1, rh1, p_atm)
Psychrometrics.validate_inputs(t_db2, rh2, p_atm)
if m1 < 0 or m2 < 0:
raise ValueError("Mass flow rates cannot be negative")
# Calculate humidity ratios
w1 = Psychrometrics.humidity_ratio(t_db1, rh1, p_atm)
w2 = Psychrometrics.humidity_ratio(t_db2, rh2, p_atm)
# Calculate enthalpies
h1 = Psychrometrics.enthalpy(t_db1, w1)
h2 = Psychrometrics.enthalpy(t_db2, w2)
# Calculate mixed air properties
m_total = m1 + m2
if m_total == 0:
raise ValueError("Total mass flow rate cannot be zero")
w_mix = (m1 * w1 + m2 * w2) / m_total
h_mix = (m1 * h1 + m2 * h2) / m_total
# Find dry-bulb temperature for the mixed air
t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
# Calculate relative humidity for the mixed air
rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
# Return mixed air properties
return Psychrometrics.moist_air_properties(t_db_mix, rh_mix, p_atm)
# Create a singleton instance
psychrometrics = Psychrometrics()
# Example usage
if __name__ == "__main__":
# Calculate properties of air at 25°C and 50% RH
properties = psychrometrics.moist_air_properties(25, 50)
print("Air Properties at 25°C and 50% RH:")
print(f"Dry-bulb temperature: {properties['dry_bulb_temperature']:.2f} °C")
print(f"Wet-bulb temperature: {properties['wet_bulb_temperature']:.2f} °C")
print(f"Dew point temperature: {properties['dew_point_temperature']:.2f} °C")
print(f"Relative humidity: {properties['relative_humidity']:.2f} %")
print(f"Humidity ratio: {properties['humidity_ratio']:.6f} kg/kg")
print(f"Enthalpy: {properties['enthalpy']/1000:.2f} kJ/kg")
print(f"Specific volume: {properties['specific_volume']:.4f} m³/kg")
print(f"Density: {properties['density']:.4f} kg/m³")
print(f"Saturation pressure: {properties['saturation_pressure']/1000:.2f} kPa")
print(f"Partial pressure: {properties['partial_pressure']/1000:.2f} kPa")