HVAC-03 / utils /psychrometrics.py
mabuseif's picture
Update utils/psychrometrics.py
10c760b verified
"""
Psychrometric module for HVAC Load Calculator.
This module implements psychrometric calculations for air properties,
including functions for mixing air streams and handling different altitudes.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
Author: Dr Majed Abuseif
Date: May 2025 (Enhanced based on plan, preserving original features)
Version: 1.3.0
"""
from typing import Dict, List, Any, Optional, Tuple
import math
import numpy as np
import logging
# Set up logging
logger = logging.getLogger(__name__)
# Constants (Preserved from original)
ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure at sea level 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) = 287.058
GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K) = 461.52
# Constants for altitude calculation (Standard Atmosphere Model)
SEA_LEVEL_TEMP_K = 288.15 # K (15 °C)
LAPSE_RATE = 0.0065 # K/m
GRAVITY = 9.80665 # m/s²
class Psychrometrics:
"""Class for psychrometric calculations."""
# --- Input Validation (Preserved and slightly enhanced) --- #
@staticmethod
def validate_inputs(t_db: Optional[float] = None, rh: Optional[float] = None,
w: Optional[float] = None, h: 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)
w: Humidity ratio (kg/kg)
h: Enthalpy (J/kg)
p_atm: Atmospheric pressure in Pa
Raises:
ValueError: If inputs are invalid
"""
if t_db is not None and not -100 <= t_db <= 200: # Wider range for intermediate calcs
raise ValueError(f"Temperature {t_db}°C must be within a reasonable range (-100°C to 200°C)")
if rh is not None and not 0 <= rh <= 100:
# Allow slightly > 100 due to calculation tolerances, clamp later
if rh < 0 or rh > 105:
raise ValueError(f"Relative humidity {rh}% must be between 0 and 100%")
if w is not None and w < 0:
raise ValueError(f"Humidity ratio {w} cannot be negative")
# Enthalpy can be negative relative to datum
# if h is not None and h < 0:
# raise ValueError(f"Enthalpy {h} cannot be negative")
if p_atm is not None and not 10000 <= p_atm <= 120000: # Typical atmospheric range
raise ValueError(f"Atmospheric pressure {p_atm} Pa must be within a reasonable range (10kPa to 120kPa)")
# --- Altitude/Pressure Calculation (Added based on plan) --- #
@staticmethod
def pressure_at_altitude(altitude: float, sea_level_pressure: float = ATMOSPHERIC_PRESSURE,
sea_level_temp_c: float = 15.0) -> float:
"""
Calculate atmospheric pressure at a given altitude using the standard atmosphere model.
Reference: https://en.wikipedia.org/wiki/Barometric_formula
Args:
altitude: Altitude above sea level in meters.
sea_level_pressure: Pressure at sea level in Pa (default: 101325 Pa).
sea_level_temp_c: Temperature at sea level in °C (default: 15 °C).
Returns:
Atmospheric pressure at the given altitude in Pa.
"""
if altitude < -500 or altitude > 80000: # Valid range for model
logger.warning(f"Altitude {altitude}m is outside the typical range for the standard atmosphere model.")
sea_level_temp_k = sea_level_temp_c + 273.15
r_da = GAS_CONSTANT_DRY_AIR
# Formula assumes constant lapse rate up to 11km
if altitude <= 11000:
temp_k = sea_level_temp_k - LAPSE_RATE * altitude
pressure = sea_level_pressure * (temp_k / sea_level_temp_k) ** (GRAVITY / (LAPSE_RATE * r_da))
else:
# Simplified: Use constant temperature above 11km (tropopause)
# A more complex model is needed for higher altitudes
logger.warning("Altitude > 11km. Using simplified pressure calculation.")
temp_11km = sea_level_temp_k - LAPSE_RATE * 11000
pressure_11km = sea_level_pressure * (temp_11km / sea_level_temp_k) ** (GRAVITY / (LAPSE_RATE * r_da))
pressure = pressure_11km * math.exp(-GRAVITY * (altitude - 11000) / (r_da * temp_11km))
return pressure
# --- Core Psychrometric Functions (Preserved from original) --- #
@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
"""
# Input validation is implicitly handled by usage, but can be added
# Psychrometrics.validate_inputs(t_db=t_db)
t_k = t_db + 273.15
if t_k <= 0:
# Avoid issues with log(T) or 1/T at or below absolute zero
return 0.0
if t_db >= 0:
# Eq 6 (ASHRAE 2017) - Renamed from Eq 5 in older versions
C1 = -5.8002206E+03
C2 = 1.3914993E+00
C3 = -4.8640239E-02
C4 = 4.1764768E-05
C5 = -1.4452093E-08
C6 = 6.5459673E+00
ln_p_ws = C1/t_k + C2 + C3*t_k + C4*t_k**2 + C5*t_k**3 + C6*math.log(t_k)
else:
# Eq 5 (ASHRAE 2017) - Renamed from Eq 6 in older versions
C7 = -5.6745359E+03
C8 = 6.3925247E+00
C9 = -9.6778430E-03
C10 = 6.2215701E-07
C11 = 2.0747825E-09
C12 = -9.4840240E-13
C13 = 4.1635019E+00
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)
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, 12.
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=t_db, rh=rh, p_atm=p_atm)
rh_decimal = max(0.0, min(1.0, rh / 100.0)) # Clamp RH
p_ws = Psychrometrics.saturation_pressure(t_db)
p_w = rh_decimal * p_ws # Eq 12
# Check if partial pressure exceeds atmospheric pressure (physically impossible)
if p_w >= p_atm:
# This usually indicates very high temp or incorrect pressure
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.")
# Return saturation humidity ratio at p_atm (boiling point)
p_w_sat_at_p_atm = p_atm # Water boils when p_ws = p_atm
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
return w
# raise ValueError(f"Partial pressure {p_w:.1f} Pa cannot exceed atmospheric pressure {p_atm:.1f} Pa")
# Eq 20
w = 0.621945 * p_w / (p_atm - p_w)
return max(0.0, w) # Ensure non-negative
@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 22, 12.
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=t_db, w=w, p_atm=p_atm)
w = max(0.0, w) # Ensure non-negative
p_ws = Psychrometrics.saturation_pressure(t_db)
# Eq 22 (Rearranged from Eq 20)
p_w = p_atm * w / (0.621945 + w)
if p_ws <= 0:
# Avoid division by zero at very low temperatures
return 0.0
# Eq 12 (Definition of RH)
rh = 100.0 * p_w / p_ws
return max(0.0, min(100.0, rh)) # Clamp RH between 0 and 100
@staticmethod
def wet_bulb_temperature(t_db: float, rh: Optional[float] = None, w: Optional[float] = None,
p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
"""
Calculate wet-bulb temperature using an iterative method or direct formula if applicable.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 33, 35.
Stull, R. (2011). "Wet-Bulb Temperature from Relative Humidity and Air Temperature". Journal of Applied Meteorology and Climatology.
Args:
t_db: Dry-bulb temperature in °C
rh: Relative humidity (0-100) (either rh or w must be provided)
w: Humidity ratio (kg/kg) (either rh or w must be provided)
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
Returns:
Wet-bulb temperature in °C
"""
if rh is None and w is None:
raise ValueError("Either relative humidity (rh) or humidity ratio (w) must be provided.")
if rh is not None:
Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm)
w_actual = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
elif w is not None:
Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
w_actual = w
else:
raise ValueError("Calculation error in wet_bulb_temperature input handling.") # Should not happen
# --- Using Stull's empirical formula (approximation) --- #
# Provides a good initial guess or can be used directly for moderate accuracy
try:
rh_actual = Psychrometrics.relative_humidity(t_db, w_actual, p_atm)
rh_decimal = rh_actual / 100.0
t_wb_stull = (t_db * math.atan(0.151977 * (rh_actual + 8.313659)**0.5) +
math.atan(t_db + rh_actual) -
math.atan(rh_actual - 1.676331) +
0.00391838 * (rh_actual**1.5) * math.atan(0.023101 * rh_actual) -
4.686035)
# Check if Stull's result is reasonable (e.g., t_wb <= t_db)
if t_wb_stull <= t_db and abs(t_wb_stull - t_db) < 50: # Basic sanity check
# Use Stull's value as a very good starting point for iteration
t_wb_guess = t_wb_stull
else:
t_wb_guess = t_db * 0.8 # Fallback guess
except Exception:
t_wb_guess = t_db * 0.8 # Fallback guess if Stull's formula fails
# --- Iterative solution based on ASHRAE Eq 33/35 --- #
t_wb = t_wb_guess
max_iterations = 100
tolerance_w = 1e-7 # Tolerance on humidity ratio
for i in range(max_iterations):
# Saturation humidity ratio at current guess of t_wb
p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
w_s_wb = max(0.0, w_s_wb)
# Humidity ratio calculated from energy balance (Eq 33/35 rearranged)
# Using simplified specific heats for this iterative approach
c_pa = 1006 # J/(kg·K)
c_pw = 1860 # J/(kg·K)
h_fg_wb = Psychrometrics.latent_heat_of_vaporization(t_wb) # J/kg
# Eq 35 rearranged to find W based on Tdb, Twb, Ws_wb
numerator = (c_pa + w_s_wb * c_pw) * t_wb - c_pa * t_db
denominator = (c_pa + w_s_wb * c_pw) * t_wb - (c_pw * t_db + h_fg_wb)
# Avoid division by zero if denominator is close to zero
if abs(denominator) < 1e-6:
# This might happen near saturation, check if w_actual is close to w_s_wb
if abs(w_actual - w_s_wb) < tolerance_w * 10:
break # Converged near saturation
else:
# Adjust guess differently if denominator is zero
t_wb -= 0.05 * (1 if w_s_wb > w_actual else -1)
continue
w_calc_from_wb = w_s_wb + numerator / denominator
# Check convergence
if abs(w_actual - w_calc_from_wb) < tolerance_w:
break
# Adjust wet-bulb temperature guess (simple step adjustment)
# A more sophisticated root-finding method (like Newton-Raphson) could be used here
step = 0.1 # Initial step size
if i > 10: step = 0.01 # Smaller steps later
if i > 50: step = 0.001
if w_calc_from_wb > w_actual:
t_wb -= step # Calculated W is too high, need lower Twb
else:
t_wb += step # Calculated W is too low, need higher Twb
# Ensure t_wb doesn't exceed t_db
t_wb = min(t_wb, t_db)
else:
# If loop finishes without break, convergence failed
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}")
# Ensure Twb <= Tdb
return min(t_wb, t_db)
@staticmethod
def dew_point_temperature(t_db: Optional[float] = None, rh: Optional[float] = None,
w: Optional[float] = None, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
"""
Calculate dew point temperature.
Uses the relationship Tdp = T(Pw) where Pw is partial pressure.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5, 6, 37.
Args:
t_db: Dry-bulb temperature in °C (required if rh is given)
rh: Relative humidity (0-100) (either rh or w must be provided)
w: Humidity ratio (kg/kg) (either rh or w must be provided)
p_atm: Atmospheric pressure in Pa (required if w is given)
Returns:
Dew point temperature in °C
"""
if rh is None and w is None:
raise ValueError("Either relative humidity (rh) or humidity ratio (w) must be provided.")
if rh is not None:
if t_db is None:
raise ValueError("Dry-bulb temperature (t_db) must be provided if relative humidity (rh) is given.")
Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm)
rh_decimal = max(0.0, min(1.0, rh / 100.0))
p_ws = Psychrometrics.saturation_pressure(t_db)
p_w = rh_decimal * p_ws
elif w is not None:
Psychrometrics.validate_inputs(w=w, p_atm=p_atm)
w = max(0.0, w)
# Eq 22 (Rearranged from Eq 20)
p_w = p_atm * w / (0.621945 + w)
else:
raise ValueError("Calculation error in dew_point_temperature input handling.") # Should not happen
if p_w <= 0:
# Handle case of zero humidity
return -100.0 # Or some other indicator of very dry air
# Find temperature at which saturation pressure equals partial pressure p_w
# This requires inverting the saturation pressure formula (Eq 5/6)
# Using iterative approach or approximation formula (like Magnus formula or ASHRAE Eq 37/38)
# Using ASHRAE 2017 Eq 37 & 38 (approximation)
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
# Eq 38 for Tdp >= 0
t_dp_pos = (18.678 - alpha / 234.5) * alpha / (257.14 + alpha / 234.5 * alpha)
# Eq 37 for Tdp < 0
t_dp_neg = 6.09 + 12.608 * alpha + 0.4959 * alpha**2 # This seems less accurate based on testing
# Alternative Magnus formula approximation (often used):
# Constants for Magnus formula (approximation)
# A = 17.625
# B = 243.04
# gamma = math.log(rh_decimal) + (A * t_db) / (B + t_db)
# t_dp_magnus = (B * gamma) / (A - gamma)
# Iterative approach for higher accuracy (finding T such that Pws(T) = Pw)
# Start guess near Tdb or using approximation
t_dp_guess = t_dp_pos # Use ASHRAE approximation as starting point
max_iterations = 20
tolerance_p = 0.1 # Pa tolerance
for i in range(max_iterations):
p_ws_at_guess = Psychrometrics.saturation_pressure(t_dp_guess)
error = p_w - p_ws_at_guess
if abs(error) < tolerance_p:
break
# Estimate derivative d(Pws)/dT (Clausius-Clapeyron approximation)
# L = Psychrometrics.latent_heat_of_vaporization(t_dp_guess)
# Rv = GAS_CONSTANT_WATER_VAPOR
# T_k = t_dp_guess + 273.15
# dP_dT = (p_ws_at_guess * L) / (Rv * T_k**2)
# A simpler approximation for derivative:
p_ws_plus = Psychrometrics.saturation_pressure(t_dp_guess + 0.01)
dP_dT = (p_ws_plus - p_ws_at_guess) / 0.01
if abs(dP_dT) < 1e-3: # Avoid division by small number if derivative is near zero
break
# Newton-Raphson step
t_dp_guess += error / dP_dT
else:
logger.debug(f"Dew point iteration did not fully converge for Pw={p_w:.2f} Pa. Result: {t_dp_guess:.3f}")
return t_dp_guess
@staticmethod
def latent_heat_of_vaporization(t_db: float) -> float:
"""
Calculate latent heat of vaporization of water at a given temperature.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 2.
Args:
t_db: Dry-bulb temperature in °C
Returns:
Latent heat of vaporization (h_fg) in J/kg
"""
# Eq 2 (Approximation)
h_fg = (2501 - 2.361 * t_db) * 1000 # Convert kJ/kg to J/kg
return h_fg
@staticmethod
def enthalpy(t_db: float, w: float) -> float:
"""
Calculate specific enthalpy of moist air.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30.
Datum: 0 J/kg for dry air at 0°C, 0 J/kg for saturated liquid water at 0°C.
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=t_db, w=w)
w = max(0.0, w)
# Using more accurate specific heats if needed, but ASHRAE Eq 30 uses constants:
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
h_g0 = 2501000 # Enthalpy of water vapor at 0°C in J/kg
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
# Eq 30
h = c_pa * t_db + w * (h_g0 + 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 26.
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=t_db, w=w, p_atm=p_atm)
w = max(0.0, w)
t_k = t_db + 273.15
r_da = GAS_CONSTANT_DRY_AIR
# Eq 26 (Ideal Gas Law for moist air)
# Factor 1.607858 is Ratio of MW_air / MW_water approx (28.9645 / 18.01534)
v = (r_da * t_k / p_atm) * (1 + 1.607858 * w)
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 26.
Density = Mass / Volume = (Mass Dry Air + Mass Water Vapor) / Volume
= (1 + w) / specific_volume
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 moist air / m³
"""
Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
w = max(0.0, w)
v = Psychrometrics.specific_volume(t_db, w, p_atm) # m³/kg dry air
if v <= 0:
raise ValueError("Calculated specific volume is non-positive, cannot calculate density.")
# Density = mass_total / volume = (mass_dry_air + mass_water) / volume
# Since v = volume / mass_dry_air, then density = (1 + w) / v
rho = (1 + w) / v
return rho
# --- Comprehensive Property Calculation (Preserved) --- #
@staticmethod
def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE,
altitude: Optional[float] = None) -> 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).
If altitude is provided, p_atm is calculated and this value is ignored.
altitude: Altitude in meters (optional). If provided, calculates pressure at altitude.
Returns:
Dictionary with all psychrometric properties.
"""
if altitude is not None:
p_atm_calc = Psychrometrics.pressure_at_altitude(altitude)
logger.debug(f"Calculated pressure at altitude {altitude}m: {p_atm_calc:.0f} Pa")
p_atm_used = p_atm_calc
else:
p_atm_used = p_atm
Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm_used)
rh_clamped = max(0.0, min(100.0, rh))
w = Psychrometrics.humidity_ratio(t_db, rh_clamped, p_atm_used)
t_wb = Psychrometrics.wet_bulb_temperature(t_db, rh=rh_clamped, w=w, p_atm=p_atm_used)
t_dp = Psychrometrics.dew_point_temperature(t_db=t_db, rh=rh_clamped, w=w, p_atm=p_atm_used)
h = Psychrometrics.enthalpy(t_db, w)
v = Psychrometrics.specific_volume(t_db, w, p_atm_used)
rho = Psychrometrics.density(t_db, w, p_atm_used)
p_ws = Psychrometrics.saturation_pressure(t_db)
p_w = (rh_clamped / 100.0) * p_ws
return {
"dry_bulb_temperature_c": t_db,
"wet_bulb_temperature_c": t_wb,
"dew_point_temperature_c": t_dp,
"relative_humidity_percent": rh_clamped,
"humidity_ratio_kg_kg": w,
"enthalpy_j_kg": h,
"specific_volume_m3_kg": v,
"density_kg_m3": rho,
"saturation_pressure_pa": p_ws,
"partial_pressure_pa": p_w,
"atmospheric_pressure_pa": p_atm_used
}
# --- Inverse Functions (Preserved) --- #
@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=t_db, h=h)
c_pa = 1006
h_g0 = 2501000
c_pw = 1860
denominator = (h_g0 + c_pw * t_db)
if abs(denominator) < 1e-6:
# Avoid division by zero, happens at specific low temps where denominator is zero
logger.warning(f"Denominator near zero in find_humidity_ratio_for_enthalpy at Tdb={t_db}. Enthalpy {h} may be inconsistent.")
return 0.0 # Or raise error
w = (h - c_pa * t_db) / denominator
return max(0.0, w) # Humidity ratio cannot be negative
@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
"""
Psychrometrics.validate_inputs(w=w, h=h)
w = max(0.0, w)
c_pa = 1006
h_g0 = 2501000
c_pw = 1860
denominator = (c_pa + w * c_pw)
if abs(denominator) < 1e-6:
raise ValueError(f"Cannot calculate temperature: denominator (Cp_a + w*Cp_w) is near zero for w={w}")
t_db = (h - w * h_g0) / denominator
# Validate the result is within reasonable bounds
Psychrometrics.validate_inputs(t_db=t_db)
return t_db
# --- Heat Ratio and Flow Rate (Preserved) --- #
@staticmethod
def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
"""
Calculate sensible heat ratio (SHR).
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.5.
Args:
q_sensible: Sensible heat load in W (can be negative for cooling)
q_total: Total heat load in W (sensible + latent) (can be negative for cooling)
Returns:
Sensible heat ratio (typically 0 to 1 for cooling, can be >1 or <0 in some cases)
"""
if abs(q_total) < 1e-9: # Avoid division by zero
# If total load is zero, SHR is undefined or can be considered 1 if only sensible exists
return 1.0 if abs(q_sensible) < 1e-9 else (1.0 if q_sensible > 0 else -1.0) # Or np.nan
shr = q_sensible / q_total
return shr
@staticmethod
def air_flow_rate_for_load(q_sensible: float, delta_t: float,
rho: Optional[float] = None, cp: float = 1006,
altitude: Optional[float] = None) -> float:
"""
Calculate volumetric air flow rate required to meet a sensible load.
Formula: q_sensible = m_dot * cp * delta_t = (rho * V_dot) * cp * delta_t
V_dot = q_sensible / (rho * cp * delta_t)
Args:
q_sensible: Sensible heat load in W.
delta_t: Temperature difference between supply and return air in °C (or K).
rho: Density of air in kg/m³ (optional, will use standard density if None).
cp: Specific heat of air in J/(kg·K) (default: 1006).
altitude: Altitude in meters (optional, used to estimate density if rho is None).
Returns:
Volumetric air flow rate (V_dot) in m³/s.
"""
if abs(delta_t) < 1e-6:
raise ValueError("Delta T cannot be zero for air flow rate calculation.")
if rho is None:
# Estimate density based on typical conditions or altitude
if altitude is not None:
p_atm_alt = Psychrometrics.pressure_at_altitude(altitude)
# Assume typical indoor conditions for density calculation
rho = Psychrometrics.density(t_db=22, w=0.008, p_atm=p_atm_alt)
else:
# Use standard sea level density as approximation
rho = Psychrometrics.density(t_db=20, w=0.0075) # Approx 1.2 kg/m³
logger.debug(f"Using estimated air density: {rho:.3f} kg/m³")
if rho <= 0:
raise ValueError("Air density must be positive.")
v_dot = q_sensible / (rho * cp * delta_t)
return v_dot
# --- Air Mixing Function (Added based on plan) --- #
@staticmethod
def mix_air_streams(stream1: Dict[str, float], stream2: Dict[str, float],
p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
"""
Calculate the properties of a mixture of two moist air streams.
Assumes adiabatic mixing at constant pressure.
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.4.
Args:
stream1: Dict for stream 1 containing keys: 'flow_rate' (m³/s), 't_db' (°C), 'rh' (%) OR 'w' (kg/kg).
stream2: Dict for stream 2 containing keys: 'flow_rate' (m³/s), 't_db' (°C), 'rh' (%) OR 'w' (kg/kg).
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure).
Returns:
Dictionary with properties of the mixed stream: 't_db', 'w', 'rh', 'h', 'flow_rate'.
Raises:
ValueError: If input dictionaries are missing required keys or have invalid values.
"""
# Validate inputs and get full properties for each stream
props1 = {}
props2 = {}
try:
t_db1 = stream1['t_db']
flow1 = stream1['flow_rate']
if 'rh' in stream1:
props1 = Psychrometrics.moist_air_properties(t_db1, stream1['rh'], p_atm)
elif 'w' in stream1:
w1 = stream1['w']
Psychrometrics.validate_inputs(t_db=t_db1, w=w1, p_atm=p_atm)
props1 = Psychrometrics.moist_air_properties(t_db1, Psychrometrics.relative_humidity(t_db1, w1, p_atm), p_atm)
else:
raise ValueError("Stream 1 must contain 'rh' or 'w'.")
if flow1 < 0: raise ValueError("Stream 1 flow rate cannot be negative.")
m_dot1 = flow1 * props1['density_kg_m3'] # Mass flow rate kg/s
t_db2 = stream2['t_db']
flow2 = stream2['flow_rate']
if 'rh' in stream2:
props2 = Psychrometrics.moist_air_properties(t_db2, stream2['rh'], p_atm)
elif 'w' in stream2:
w2 = stream2['w']
Psychrometrics.validate_inputs(t_db=t_db2, w=w2, p_atm=p_atm)
props2 = Psychrometrics.moist_air_properties(t_db2, Psychrometrics.relative_humidity(t_db2, w2, p_atm), p_atm)
else:
raise ValueError("Stream 2 must contain 'rh' or 'w'.")
if flow2 < 0: raise ValueError("Stream 2 flow rate cannot be negative.")
m_dot2 = flow2 * props2['density_kg_m3'] # Mass flow rate kg/s
except KeyError as e:
raise ValueError(f"Missing required key in input stream dictionary: {e}")
except ValueError as e:
raise ValueError(f"Invalid input value: {e}")
# Total mass flow rate
m_dot_mix = m_dot1 + m_dot2
if m_dot_mix <= 1e-9: # Avoid division by zero if total flow is zero
logger.warning("Total mass flow rate for mixing is zero. Returning properties of stream 1 (or empty dict if flow1 is also zero).")
if m_dot1 > 1e-9:
return {
't_db': props1['dry_bulb_temperature_c'],
'w': props1['humidity_ratio_kg_kg'],
'rh': props1['relative_humidity_percent'],
'h': props1['enthalpy_j_kg'],
'flow_rate': flow1
}
else: # Both flows are zero
return {'t_db': 0, 'w': 0, 'rh': 0, 'h': 0, 'flow_rate': 0}
# Mass balance for humidity ratio
w_mix = (m_dot1 * props1['humidity_ratio_kg_kg'] + m_dot2 * props2['humidity_ratio_kg_kg']) / m_dot_mix
# Energy balance for enthalpy
h_mix = (m_dot1 * props1['enthalpy_j_kg'] + m_dot2 * props2['enthalpy_j_kg']) / m_dot_mix
# Find mixed temperature from mixed enthalpy and humidity ratio
t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
# Find mixed relative humidity
rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
# Calculate mixed flow rate (volume)
# Need density at mixed conditions
rho_mix = Psychrometrics.density(t_db_mix, w_mix, p_atm)
flow_mix = m_dot_mix / rho_mix if rho_mix > 0 else 0
return {
't_db': t_db_mix,
'w': w_mix,
'rh': rh_mix,
'h': h_mix,
'flow_rate': flow_mix
}
# Example Usage (Preserved and expanded)
if __name__ == "__main__":
# Test basic properties
t_db_test = 25.0
rh_test = 50.0
p_atm_test = 101325.0
altitude_test = 1500 # meters
print(f"--- Properties at T={t_db_test}°C, RH={rh_test}%, P={p_atm_test} Pa ---")
props_sea_level = Psychrometrics.moist_air_properties(t_db_test, rh_test, p_atm_test)
for key, value in props_sea_level.items():
print(f"{key}: {value:.6f}")
print(f"\n--- Properties at T={t_db_test}°C, RH={rh_test}%, Altitude={altitude_test} m ---")
props_altitude = Psychrometrics.moist_air_properties(t_db_test, rh_test, altitude=altitude_test)
for key, value in props_altitude.items():
print(f"{key}: {value:.6f}")
p_calc_alt = Psychrometrics.pressure_at_altitude(altitude_test)
pressure_diff = abs(p_calc_alt - props_altitude["atmospheric_pressure_pa"]) < 1e-3
print(f"Calculated pressure at {altitude_test}m: {p_calc_alt:.0f} Pa (matches: {pressure_diff})")
# Test air mixing
print("\n--- Air Mixing Test ---")
stream_a = {'flow_rate': 1.0, 't_db': 30.0, 'rh': 60.0} # m³/s, °C, %
stream_b = {'flow_rate': 0.5, 't_db': 15.0, 'w': 0.005} # m³/s, °C, kg/kg
p_mix = 100000.0 # Pa
print(f"Stream A: {stream_a}")
print(f"Stream B: {stream_b}")
print(f"Mixing at Pressure: {p_mix} Pa")
try:
mixed_props = Psychrometrics.mix_air_streams(stream_a, stream_b, p_atm=p_mix)
print("\nMixed Stream Properties:")
for key, value in mixed_props.items():
print(f"{key}: {value:.6f}")
except ValueError as e:
print(f"\nError during mixing calculation: {e}")
# Test edge cases
print("\n--- Edge Case Tests ---")
try:
print(f"Dew point at 5°C, 100% RH: {Psychrometrics.dew_point_temperature(t_db=5.0, rh=100.0):.3f}°C")
print(f"Dew point at -10°C, 80% RH: {Psychrometrics.dew_point_temperature(t_db=-10.0, rh=80.0):.3f}°C")
print(f"Wet bulb at 30°C, 100% RH: {Psychrometrics.wet_bulb_temperature(t_db=30.0, rh=100.0):.3f}°C")
print(f"Wet bulb at -5°C, 50% RH: {Psychrometrics.wet_bulb_temperature(t_db=-5.0, rh=50.0):.3f}°C")
# Test high temp / high humidity
props_hot_humid = Psychrometrics.moist_air_properties(t_db=50, rh=90, p_atm=101325)
humidity_ratio = props_hot_humid["humidity_ratio_kg_kg"]
enthalpy = props_hot_humid["enthalpy_j_kg"]
print(f"Properties at 50°C, 90% RH: W={humidity_ratio:.6f}, H={enthalpy:.0f}")
except ValueError as e:
print(f"Error during edge case test: {e}")