Spaces:
Running
Running
""" | |
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.""" | |
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") | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
} | |
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) | |
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 | |
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 | |
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 | |
} | |
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") |