""" Heat transfer calculation module for HVAC Load Calculator. This module implements heat transfer calculations for conduction, infiltration, and enhanced solar radiation modeling, including vectorization support. Reference: ASHRAE Handbook—Fundamentals (2017), Chapters 14, 16, 18. Duffie & Beckman, Solar Engineering of Thermal Processes (4th Ed.). 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, Union import math import numpy as np import logging from dataclasses import dataclass # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Import utility modules (ensure these exist and are correct) try: from utils.psychrometrics import Psychrometrics except ImportError: print("Warning: Could not import Psychrometrics. Using placeholder.") class Psychrometrics: def humidity_ratio(self, tdb, rh, p): return 0.01 def density(self, tdb, w, p): return 1.2 def pressure_at_altitude(self, alt): return 101325 def latent_heat_of_vaporization(self, tdb): return 2501000 # Add other methods if needed by HeatTransferCalculations # Import data modules (ensure these exist and are correct) try: # Assuming Orientation enum is defined elsewhere, e.g., in component selection from app.component_selection import Orientation except ImportError: print("Warning: Could not import Orientation enum. Using placeholder.") from enum import Enum class Orientation(Enum): NORTH="N"; NORTHEAST="NE"; EAST="E"; SOUTHEAST="SE"; SOUTH="S"; SOUTHWEST="SW"; WEST="W"; NORTHWEST="NW"; HORIZONTAL="H" # Constants STEFAN_BOLTZMANN = 5.67e-8 # W/(m²·K⁴) SOLAR_CONSTANT = 1367 # W/m² (Average extraterrestrial solar irradiance) ATMOSPHERIC_PRESSURE = 101325 # Pa GAS_CONSTANT_DRY_AIR = 287.058 # J/(kg·K) @dataclass class SolarAngles: """Holds calculated solar angles.""" declination: float # degrees hour_angle: float # degrees altitude: float # degrees azimuth: float # degrees incidence_angle: Optional[float] = None # degrees, for a specific surface @dataclass class SolarRadiation: """Holds calculated solar radiation components.""" extraterrestrial_normal: float # W/m² direct_normal: float # W/m² (DNI) diffuse_horizontal: float # W/m² (DHI) global_horizontal: float # W/m² (GHI = DNI*cos(zenith) + DHI) total_on_surface: Optional[float] = None # W/m², for a specific surface direct_on_surface: Optional[float] = None # W/m² diffuse_on_surface: Optional[float] = None # W/m² reflected_on_surface: Optional[float] = None # W/m² class SolarCalculations: """Class for enhanced solar geometry and radiation calculations.""" def validate_angle(self, angle: Union[float, np.ndarray], name: str, min_val: float, max_val: float) -> None: """ Validate angle inputs for solar calculations (handles scalars and numpy arrays). """ if isinstance(angle, np.ndarray): if np.any(angle < min_val) or np.any(angle > max_val): logger.warning(f"{name} contains values outside range [{min_val}, {max_val}]") # Optionally clamp values: angle = np.clip(angle, min_val, max_val) elif not min_val <= angle <= max_val: raise ValueError(f"{name} {angle}° must be between {min_val}° and {max_val}°") def equation_of_time(self, day_of_year: Union[int, np.ndarray]) -> Union[float, np.ndarray]: """ Calculate the Equation of Time (EoT) in minutes. Reference: Duffie & Beckman (4th Ed.), Eq 1.5.3a. Args: day_of_year: Day of the year (1-365 or 1-366). Returns: Equation of Time in minutes. """ if isinstance(day_of_year, np.ndarray): if np.any(day_of_year < 1) or np.any(day_of_year > 366): raise ValueError("Day of year must be between 1 and 366") elif not 1 <= day_of_year <= 366: raise ValueError("Day of year must be between 1 and 366") B = (day_of_year - 1) * 360 / 365 # degrees (approximate) B_rad = np.radians(B) eot = 229.18 * (0.000075 + 0.001868 * np.cos(B_rad) - 0.032077 * np.sin(B_rad) \ - 0.014615 * np.cos(2 * B_rad) - 0.04089 * np.sin(2 * B_rad)) return eot def solar_declination(self, day_of_year: Union[int, np.ndarray]) -> Union[float, np.ndarray]: """ Calculate solar declination angle. Reference: Duffie & Beckman (4th Ed.), Eq 1.6.1a (more accurate than ASHRAE approx). Args: day_of_year: Day of the year (1-365 or 1-366). Returns: Declination angle in degrees. """ if isinstance(day_of_year, np.ndarray): if np.any(day_of_year < 1) or np.any(day_of_year > 366): raise ValueError("Day of year must be between 1 and 366") elif not 1 <= day_of_year <= 366: raise ValueError("Day of year must be between 1 and 366") # Using Spencer's formula (1971) as cited in Duffie & Beckman gamma_rad = (2 * np.pi / 365) * (day_of_year - 1) declination_rad = (0.006918 - 0.399912 * np.cos(gamma_rad) + 0.070257 * np.sin(gamma_rad) - 0.006758 * np.cos(2 * gamma_rad) + 0.000907 * np.sin(2 * gamma_rad) - 0.002697 * np.cos(3 * gamma_rad) + 0.00148 * np.sin(3 * gamma_rad)) declination = np.degrees(declination_rad) # self.validate_angle(declination, "Declination angle", -23.45, 23.45) return declination def solar_time(self, local_standard_time_hour: Union[float, np.ndarray], eot_minutes: Union[float, np.ndarray], longitude_deg: float, standard_meridian_deg: float) -> Union[float, np.ndarray]: """ Calculate solar time (Local Apparent Time, LAT). Reference: Duffie & Beckman (4th Ed.), Eq 1.5.2. Args: local_standard_time_hour: Local standard time (clock time) in hours (0-24). eot_minutes: Equation of Time in minutes. longitude_deg: Local longitude in degrees (East positive, West negative). standard_meridian_deg: Standard meridian for the local time zone in degrees. Returns: Solar time in hours (0-24). """ # Time correction for longitude difference from standard meridian longitude_correction_minutes = 4 * (standard_meridian_deg - longitude_deg) solar_time_hour = local_standard_time_hour + (eot_minutes + longitude_correction_minutes) / 60.0 return solar_time_hour def solar_hour_angle(self, solar_time_hour: Union[float, np.ndarray]) -> Union[float, np.ndarray]: """ Calculate solar hour angle from solar time. Reference: Duffie & Beckman (4th Ed.), Section 1.6. Args: solar_time_hour: Solar time in hours (0-24). Returns: Hour angle in degrees (-180 to 180, zero at solar noon). """ # Hour angle is 15 degrees per hour from solar noon (12) hour_angle = (solar_time_hour - 12) * 15 # self.validate_angle(hour_angle, "Hour angle", -180, 180) return hour_angle def solar_zenith_altitude(self, latitude_deg: float, declination_deg: Union[float, np.ndarray], hour_angle_deg: Union[float, np.ndarray]) -> Tuple[Union[float, np.ndarray], Union[float, np.ndarray]]: """ Calculate solar zenith and altitude angles. Reference: Duffie & Beckman (4th Ed.), Eq 1.6.5. Args: latitude_deg: Latitude in degrees. declination_deg: Declination angle in degrees. hour_angle_deg: Hour angle in degrees. Returns: Tuple: (zenith angle in degrees [0-180], altitude angle in degrees [-90 to 90]). Altitude is 90 - zenith. """ self.validate_angle(latitude_deg, "Latitude", -90, 90) # self.validate_angle(declination_deg, "Declination", -23.45, 23.45) # self.validate_angle(hour_angle_deg, "Hour angle", -180, 180) lat_rad = np.radians(latitude_deg) dec_rad = np.radians(declination_deg) ha_rad = np.radians(hour_angle_deg) cos_zenith = (np.sin(lat_rad) * np.sin(dec_rad) + np.cos(lat_rad) * np.cos(dec_rad) * np.cos(ha_rad)) # Clamp cos_zenith to [-1, 1] due to potential floating point inaccuracies cos_zenith = np.clip(cos_zenith, -1.0, 1.0) zenith_rad = np.arccos(cos_zenith) zenith_deg = np.degrees(zenith_rad) altitude_deg = 90.0 - zenith_deg # self.validate_angle(zenith_deg, "Zenith angle", 0, 180) # self.validate_angle(altitude_deg, "Altitude angle", -90, 90) return zenith_deg, altitude_deg def solar_azimuth(self, latitude_deg: float, declination_deg: Union[float, np.ndarray], hour_angle_deg: Union[float, np.ndarray], zenith_deg: Union[float, np.ndarray]) -> Union[float, np.ndarray]: """ Calculate solar azimuth angle (measured clockwise from North = 0°). Reference: Duffie & Beckman (4th Ed.), Eq 1.6.6 (modified for N=0, E=90, S=180, W=270). Args: latitude_deg: Latitude in degrees. declination_deg: Declination angle in degrees. hour_angle_deg: Hour angle in degrees. zenith_deg: Zenith angle in degrees. Returns: Azimuth angle in degrees (0-360, clockwise from North). """ lat_rad = np.radians(latitude_deg) dec_rad = np.radians(declination_deg) ha_rad = np.radians(hour_angle_deg) zenith_rad = np.radians(zenith_deg) # Avoid division by zero if zenith is 0 (sun directly overhead) sin_zenith = np.sin(zenith_rad) azimuth_deg = np.zeros_like(zenith_deg) # Initialize with zeros # Calculate only where sin_zenith is significantly greater than zero valid_mask = sin_zenith > 1e-6 if isinstance(valid_mask, bool): # Handle scalar case if valid_mask: cos_azimuth_term = (np.sin(dec_rad) * np.cos(lat_rad) - np.cos(dec_rad) * np.sin(lat_rad) * np.cos(ha_rad)) / sin_zenith cos_azimuth_term = np.clip(cos_azimuth_term, -1.0, 1.0) azimuth_rad_provisional = np.arccos(cos_azimuth_term) azimuth_deg_provisional = np.degrees(azimuth_rad_provisional) # Adjust based on hour angle if isinstance(hour_angle_deg, np.ndarray): azimuth_deg = np.where(hour_angle_deg > 0, 360.0 - azimuth_deg_provisional, azimuth_deg_provisional) else: azimuth_deg = 360.0 - azimuth_deg_provisional if hour_angle_deg > 0 else azimuth_deg_provisional else: azimuth_deg = 0.0 # Undefined when sun is at zenith, assign 0 else: # Handle array case cos_azimuth_term = np.full_like(zenith_deg, 0.0) # Calculate term only for valid entries cos_azimuth_term[valid_mask] = (np.sin(dec_rad[valid_mask]) * np.cos(lat_rad) - np.cos(dec_rad[valid_mask]) * np.sin(lat_rad) * np.cos(ha_rad[valid_mask])) / sin_zenith[valid_mask] cos_azimuth_term = np.clip(cos_azimuth_term, -1.0, 1.0) azimuth_rad_provisional = np.arccos(cos_azimuth_term) azimuth_deg_provisional = np.degrees(azimuth_rad_provisional) # Adjust based on hour angle (vectorized) azimuth_deg = np.where(hour_angle_deg > 0, 360.0 - azimuth_deg_provisional, azimuth_deg_provisional) # Ensure invalid entries remain 0 azimuth_deg[~valid_mask] = 0.0 # self.validate_angle(azimuth_deg, "Azimuth angle", 0, 360) return azimuth_deg def extraterrestrial_radiation_normal(self, day_of_year: Union[int, np.ndarray]) -> Union[float, np.ndarray]: """ Calculate extraterrestrial solar radiation normal to the sun's rays. Reference: Duffie & Beckman (4th Ed.), Eq 1.4.1b. Args: day_of_year: Day of the year (1-365 or 1-366). Returns: Extraterrestrial normal irradiance (G_on) in W/m². """ B = (day_of_year - 1) * 360 / 365 # degrees B_rad = np.radians(B) G_on = SOLAR_CONSTANT * (1.000110 + 0.034221 * np.cos(B_rad) + 0.001280 * np.sin(B_rad) + 0.000719 * np.cos(2 * B_rad) + 0.000077 * np.sin(2 * B_rad)) return G_on def angle_of_incidence(self, latitude_deg: float, declination_deg: float, hour_angle_deg: float, surface_tilt_deg: float, surface_azimuth_deg: float) -> float: """ Calculate the angle of incidence of beam radiation on a tilted surface. Reference: Duffie & Beckman (4th Ed.), Eq 1.6.2. Args: latitude_deg: Latitude. declination_deg: Solar declination. hour_angle_deg: Solar hour angle. surface_tilt_deg: Surface tilt angle from horizontal (0=horizontal, 90=vertical). surface_azimuth_deg: Surface azimuth angle (0=N, 90=E, 180=S, 270=W). Returns: Angle of incidence in degrees (0-90). Returns > 90 if sun is behind surface. """ lat_rad = np.radians(latitude_deg) dec_rad = np.radians(declination_deg) ha_rad = np.radians(hour_angle_deg) tilt_rad = np.radians(surface_tilt_deg) surf_azim_rad = np.radians(surface_azimuth_deg) # Convert surface azimuth from N=0 to S=0 convention used in formula gamma_rad = surf_azim_rad - np.pi # S=0, E=pi/2, W=-pi/2 cos_theta = (np.sin(lat_rad) * np.sin(dec_rad) * np.cos(tilt_rad) - np.cos(lat_rad) * np.sin(dec_rad) * np.sin(tilt_rad) * np.cos(gamma_rad) + np.cos(lat_rad) * np.cos(dec_rad) * np.cos(ha_rad) * np.cos(tilt_rad) + np.sin(lat_rad) * np.cos(dec_rad) * np.cos(ha_rad) * np.sin(tilt_rad) * np.cos(gamma_rad) + np.cos(dec_rad) * np.sin(ha_rad) * np.sin(tilt_rad) * np.sin(gamma_rad)) # Clamp cos_theta to [-1, 1] cos_theta = np.clip(cos_theta, -1.0, 1.0) theta_rad = np.arccos(cos_theta) theta_deg = np.degrees(theta_rad) return theta_deg def ashrae_clear_sky_radiation(self, day_of_year: int, hour: float, latitude_deg: float, longitude_deg: float, standard_meridian_deg: float, altitude_m: float = 0) -> Tuple[float, float, float]: """ Estimate DNI and DHI using ASHRAE Clear Sky model. Reference: ASHRAE HOF 2017, Ch. 14, Eq. 14.18-14.21 (Simplified version) Args: day_of_year, hour, latitude_deg, longitude_deg, standard_meridian_deg: Location/time info. altitude_m: Site altitude in meters. Returns: Tuple: (DNI in W/m², DHI in W/m², GHI in W/m²) """ # 1. Calculate Solar Angles eot = self.equation_of_time(day_of_year) solar_time = self.solar_time(hour, eot, longitude_deg, standard_meridian_deg) ha = self.solar_hour_angle(solar_time) dec = self.solar_declination(day_of_year) zenith, alt = self.solar_zenith_altitude(latitude_deg, dec, ha) if alt <= 0: # Sun below horizon return 0.0, 0.0, 0.0 # 2. Extraterrestrial Radiation G_on = self.extraterrestrial_radiation_normal(day_of_year) G_oh = G_on * np.cos(np.radians(zenith)) # On horizontal surface # 3. ASHRAE Clear Sky Model Parameters (simplified) # These depend on atmospheric conditions (clearness, water vapor, etc.) # Using typical clear day values for illustration A = 1160 + 75 * np.sin(np.radians(360 * (day_of_year - 275) / 365)) # Apparent extraterrestrial irradiance k = 0.174 + 0.035 * np.sin(np.radians(360 * (day_of_year - 100) / 365)) # Optical depth C = 0.095 + 0.04 * np.sin(np.radians(360 * (day_of_year - 100) / 365)) # Sky diffuse factor # Air mass (simplified Kasten and Young, 1989) m = 1 / (np.cos(np.radians(zenith)) + 0.50572 * (96.07995 - zenith)**(-1.6364)) # Altitude correction for air mass (approximate) pressure_ratio = (Psychrometrics().pressure_at_altitude(altitude_m) / ATMOSPHERIC_PRESSURE) m *= pressure_ratio # 4. Calculate DNI and DHI dni = A * np.exp(-k * m) dhi = C * dni ghi = dni * np.cos(np.radians(zenith)) + dhi # Ensure non-negative dni = max(0.0, dni) dhi = max(0.0, dhi) ghi = max(0.0, ghi) return dni, dhi, ghi def total_radiation_on_surface(self, dni: float, dhi: float, ghi: float, zenith_deg: float, solar_azimuth_deg: float, surface_tilt_deg: float, surface_azimuth_deg: float, ground_reflectance: float = 0.2) -> Tuple[float, float, float, float]: """ Calculate total solar radiation incident on a tilted surface. Uses isotropic sky model for diffuse radiation. Reference: Duffie & Beckman (4th Ed.), Section 2.15, 2.16. Args: dni, dhi, ghi: Direct Normal, Diffuse Horizontal, Global Horizontal Irradiance (W/m²). zenith_deg, solar_azimuth_deg: Solar position angles. surface_tilt_deg, surface_azimuth_deg: Surface orientation angles. ground_reflectance: Albedo of the ground (0-1). Returns: Tuple: (Total G_t, Direct G_b,t, Diffuse G_d,t, Reflected G_r,t) on surface (W/m²). """ if zenith_deg >= 90: # Sun below horizon return 0.0, 0.0, 0.0, 0.0 # 1. Angle of Incidence (theta) # Need latitude, declination, hour angle for precise calculation, OR use zenith/azimuth # Using zenith/azimuth method (Duffie & Beckman Eq 1.6.3) cos_theta = (np.cos(np.radians(zenith_deg)) * np.cos(np.radians(surface_tilt_deg)) + np.sin(np.radians(zenith_deg)) * np.sin(np.radians(surface_tilt_deg)) * np.cos(np.radians(solar_azimuth_deg - surface_azimuth_deg))) cos_theta = np.clip(cos_theta, -1.0, 1.0) theta_deg = np.degrees(np.arccos(cos_theta)) # 2. Direct Beam component on tilted surface (G_b,t) # Only if sun is in front of the surface (theta <= 90) G_b_t = dni * cos_theta if theta_deg <= 90.0 else 0.0 G_b_t = max(0.0, G_b_t) # 3. Diffuse component on tilted surface (G_d,t) - Isotropic Sky Model # View factor from surface to sky F_sky = (1 + np.cos(np.radians(surface_tilt_deg))) / 2 G_d_t = dhi * F_sky G_d_t = max(0.0, G_d_t) # 4. Ground Reflected component on tilted surface (G_r,t) # View factor from surface to ground F_ground = (1 - np.cos(np.radians(surface_tilt_deg))) / 2 G_r_t = ghi * ground_reflectance * F_ground G_r_t = max(0.0, G_r_t) # 5. Total radiation on tilted surface G_t = G_b_t + G_d_t + G_r_t return G_t, G_b_t, G_d_t, G_r_t class HeatTransferCalculations: """Class for heat transfer calculations.""" def __init__(self, debug_mode: bool = False): """ Initialize heat transfer calculations with psychrometrics and solar calculations. Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16. Args: debug_mode: Enable debug logging if True. """ self.psychrometrics = Psychrometrics() self.solar = SolarCalculations() self.debug_mode = debug_mode if debug_mode: logger.setLevel(logging.DEBUG) def conduction_heat_transfer(self, u_value: Union[float, np.ndarray], area: Union[float, np.ndarray], delta_t: Union[float, np.ndarray]) -> Union[float, np.ndarray]: """ Calculate heat transfer via conduction. Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1. Args: u_value: U-value(s) of the component(s) in W/(m²·K) area: Area(s) of the component(s) in m² delta_t: Temperature difference(s) in °C or K Returns: Heat transfer rate(s) in W """ if isinstance(u_value, np.ndarray) or isinstance(area, np.ndarray) or isinstance(delta_t, np.ndarray): u_value = np.asarray(u_value) area = np.asarray(area) delta_t = np.asarray(delta_t) if np.any(u_value < 0) or np.any(area < 0): raise ValueError("U-value and area must be non-negative") elif u_value < 0 or area < 0: raise ValueError("U-value and area must be non-negative") q = u_value * area * delta_t return q def infiltration_heat_transfer(self, flow_rate: Union[float, np.ndarray], delta_t: Union[float, np.ndarray], t_db: Union[float, np.ndarray], rh: Union[float, np.ndarray], p_atm: Union[float, np.ndarray] = ATMOSPHERIC_PRESSURE) -> Union[float, np.ndarray]: """ Calculate sensible heat transfer due to infiltration or ventilation. Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.5. Args: flow_rate: Air flow rate(s) in m³/s delta_t: Temperature difference(s) in °C or K t_db: Dry-bulb temperature(s) for air properties in °C rh: Relative humidity(ies) in % (0-100) p_atm: Atmospheric pressure(s) in Pa Returns: Sensible heat transfer rate(s) in W """ # Convert inputs to numpy arrays if any input is an array is_array = any(isinstance(arg, np.ndarray) for arg in [flow_rate, delta_t, t_db, rh, p_atm]) if is_array: flow_rate = np.asarray(flow_rate) delta_t = np.asarray(delta_t) t_db = np.asarray(t_db) rh = np.asarray(rh) p_atm = np.asarray(p_atm) if np.any(flow_rate < 0): raise ValueError("Flow rate cannot be negative") elif flow_rate < 0: raise ValueError("Flow rate cannot be negative") # Calculate air density and specific heat using psychrometrics (vectorized if needed) w = self.psychrometrics.humidity_ratio(t_db, rh, p_atm) rho = self.psychrometrics.density(t_db, w, p_atm) c_p = 1006 + 1860 * w # Specific heat of moist air in J/(kg·K) q = flow_rate * rho * c_p * delta_t return q def infiltration_latent_heat_transfer(self, flow_rate: Union[float, np.ndarray], delta_w: Union[float, np.ndarray], t_db: Union[float, np.ndarray], rh: Union[float, np.ndarray] = 40.0, p_atm: Union[float, np.ndarray] = ATMOSPHERIC_PRESSURE) -> Union[float, np.ndarray]: """ Calculate latent heat transfer due to infiltration or ventilation. Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.6. Args: flow_rate: Air flow rate(s) in m³/s delta_w: Humidity ratio difference(s) in kg/kg t_db: Dry-bulb temperature(s) for air properties in °C rh: Relative humidity(ies) in % (0-100), default 40% p_atm: Atmospheric pressure(s) in Pa, default 101325 Pa Returns: Latent heat transfer rate(s) in W """ is_array = any(isinstance(arg, np.ndarray) for arg in [flow_rate, delta_w, t_db, rh, p_atm]) if is_array: flow_rate = np.asarray(flow_rate) delta_w = np.asarray(delta_w) t_db = np.asarray(t_db) rh = np.asarray(rh) p_atm = np.asarray(p_atm) if np.any(flow_rate < 0): raise ValueError("Flow rate cannot be negative") if np.any(rh < 0) or np.any(rh > 100): raise ValueError("Relative humidity must be between 0 and 100%") # Delta_w can be negative (humidification) else: if flow_rate < 0: raise ValueError("Flow rate cannot be negative") if not 0 <= rh <= 100: raise ValueError("Relative humidity must be between 0 and 100%") # Calculate air density and latent heat w = self.psychrometrics.humidity_ratio(t_db, rh, p_atm) # Calculate humidity ratio from rh rho = self.psychrometrics.density(t_db, w, p_atm) # Use actual humidity ratio for density h_fg = self.psychrometrics.latent_heat_of_vaporization(t_db) # J/kg q = flow_rate * rho * h_fg * delta_w return q def wind_pressure_difference(self, wind_speed: float, wind_pressure_coeff: float = 0.6, air_density: float = 1.2) -> float: """ Calculate pressure difference due to wind. Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.3. Args: wind_speed: Wind speed in m/s. wind_pressure_coeff: Wind pressure coefficient (depends on building shape, location). air_density: Air density in kg/m³. Returns: Pressure difference in Pa. """ if wind_speed < 0: raise ValueError("Wind speed cannot be negative") delta_p = 0.5 * wind_pressure_coeff * air_density * wind_speed**2 return delta_p def stack_pressure_difference(self, height: float, t_inside_k: float, t_outside_k: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float: """ Calculate pressure difference due to stack effect. Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.4. Args: height: Height difference in m (e.g., NPL to opening). t_inside_k: Inside absolute temperature in K. t_outside_k: Outside absolute temperature in K. p_atm: Atmospheric pressure in Pa. Returns: Pressure difference in Pa. """ if height < 0 or t_inside_k <= 0 or t_outside_k <= 0: raise ValueError("Height and absolute temperatures must be positive") g = 9.80665 # Gravitational acceleration in m/s² r_da = GAS_CONSTANT_DRY_AIR # Calculate Density inside and outside (approximating as dry air for simplicity) rho_inside = p_atm / (r_da * t_inside_k) rho_outside = p_atm / (r_da * t_outside_k) # Pressure difference = g * height * (rho_outside - rho_inside) delta_p = g * height * (rho_outside - rho_inside) return delta_p def combined_pressure_difference(self, wind_pd: float, stack_pd: float) -> float: """ Calculate combined pressure difference from wind and stack effects (simple superposition). """ delta_p = math.sqrt(wind_pd**2 + stack_pd**2) return delta_p def crack_method_infiltration(self, crack_length: float, crack_width: float, delta_p: float, flow_exponent: float = 0.65) -> float: """ Calculate infiltration flow rate using the power law (crack) method. Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.5. Formula: Q = flow_coeff * area * (delta_p / 0.001)^n Args: crack_length: Length of cracks in m. crack_width: Width of cracks in m. delta_p: Pressure difference across cracks in Pa. flow_exponent: Flow exponent (n), typically 0.5 to 1.0 (default 0.65). Returns: Infiltration flow rate (Q) in m³/s. """ if crack_length < 0 or crack_width < 0 or delta_p < 0: raise ValueError("Crack length, crack width, and pressure difference must be non-negative") if not 0.5 <= flow_exponent <= 1.0: logger.warning(f"Flow exponent {flow_exponent} is outside the typical range [0.5, 1.0]") flow_coeff = 0.65 # ASHRAE default flow coefficient area = crack_length * crack_width q = flow_coeff * area * ((delta_p / 0.001) ** flow_exponent) return q # Example usage if __name__ == "__main__": heat_transfer = HeatTransferCalculations(debug_mode=True) # Example conduction calculation u_value = 0.5 area = 20.0 delta_t = 10.0 q_conduction = heat_transfer.conduction_heat_transfer(u_value, area, delta_t) logger.info(f"Conduction heat transfer: {q_conduction:.2f} W") # Example infiltration calculation (Sensible) flow_rate = 0.05 delta_t_inf = 10.0 t_db_inf = 25.0 rh_inf = 50.0 q_inf_sens = heat_transfer.infiltration_heat_transfer(flow_rate, delta_t_inf, t_db_inf, rh_inf) logger.info(f"Infiltration sensible heat transfer: {q_inf_sens:.2f} W") # Example infiltration calculation (Latent) delta_w_inf = 0.002 # kg/kg q_inf_lat = heat_transfer.infiltration_latent_heat_transfer(flow_rate, delta_w_inf, t_db_inf, rh_inf) logger.info(f"Infiltration latent heat transfer: {q_inf_lat:.2f} W") # Example Solar Calculation logger.info("--- Solar Calculation Example ---") latitude = 34.0 # Los Angeles longitude = -118.0 std_meridian = -120.0 # PST day_of_year = 80 # Around March 21 hour = 13.5 # Local standard time altitude_m = 100 # Get clear sky radiation dni, dhi, ghi = heat_transfer.solar.ashrae_clear_sky_radiation( day_of_year, hour, latitude, longitude, std_meridian, altitude_m ) logger.info(f"Clear Sky Radiation (W/m²): DNI={dni:.1f}, DHI={dhi:.1f}, GHI={ghi:.1f}") # Calculate radiation on a south-facing vertical wall eot = heat_transfer.solar.equation_of_time(day_of_year) solar_time = heat_transfer.solar.solar_time(hour, eot, longitude, std_meridian) ha = heat_transfer.solar.solar_hour_angle(solar_time) dec = heat_transfer.solar.solar_declination(day_of_year) zenith, alt = heat_transfer.solar.solar_zenith_altitude(latitude, dec, ha) sol_azimuth = heat_transfer.solar.solar_azimuth(latitude, dec, ha, zenith) surface_tilt = 90.0 surface_azimuth = 180.0 # South facing ground_reflectance = 0.2 if alt > 0: G_t, G_b_t, G_d_t, G_r_t = heat_transfer.solar.total_radiation_on_surface( dni, dhi, ghi, zenith, sol_azimuth, surface_tilt, surface_azimuth, ground_reflectance ) logger.info(f"Radiation on South Vertical Wall (W/m²): Total={G_t:.1f}, Beam={G_b_t:.1f}, Diffuse={G_d_t:.1f}, Reflected={G_r_t:.1f}") theta = heat_transfer.solar.angle_of_incidence(latitude, dec, ha, surface_tilt, surface_azimuth) logger.info(f"Incidence Angle: {theta:.1f} degrees") else: logger.info("Sun is below horizon.") # Vectorized example (e.g., hourly conduction) logger.info("--- Vectorized Conduction Example ---") hours_array = np.arange(24) delta_t_hourly = 10 * np.sin(np.pi * (hours_array - 8) / 12) + 5 # Example hourly delta T delta_t_hourly[delta_t_hourly < 0] = 0 # Only positive delta T q_conduction_hourly = heat_transfer.conduction_heat_transfer(u_value, area, delta_t_hourly) logger.info(f"Peak Hourly Conduction: {np.max(q_conduction_hourly):.2f} W")