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