HVAC-03 / utils /heat_transfer.py
mabuseif's picture
Upload heat_transfer.py
72815ba verified
"""
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")