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}") | |