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