Spaces:
Sleeping
Sleeping
""" | |
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) --- # | |
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) --- # | |
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) --- # | |
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 | |
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 | |
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 | |
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) | |
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 | |
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 | |
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 | |
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 | |
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) --- # | |
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) --- # | |
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 | |
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) --- # | |
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 | |
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) --- # | |
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}") | |