Spaces:
Sleeping
Sleeping
Upload heat_transfer.py
Browse files- utils/heat_transfer.py +538 -211
utils/heat_transfer.py
CHANGED
@@ -1,10 +1,16 @@
|
|
1 |
"""
|
2 |
Heat transfer calculation module for HVAC Load Calculator.
|
3 |
-
This module implements heat transfer calculations for conduction, infiltration,
|
4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
"""
|
6 |
|
7 |
-
from typing import Dict, List, Any, Optional, Tuple
|
8 |
import math
|
9 |
import numpy as np
|
10 |
import logging
|
@@ -14,314 +20,635 @@ from dataclasses import dataclass
|
|
14 |
logging.basicConfig(level=logging.INFO)
|
15 |
logger = logging.getLogger(__name__)
|
16 |
|
17 |
-
# Import utility modules
|
18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
-
|
21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
|
24 |
class SolarCalculations:
|
25 |
-
"""Class for solar geometry and radiation calculations."""
|
26 |
-
|
27 |
-
def validate_angle(self, angle: float, name: str, min_val: float, max_val: float) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
"""
|
29 |
-
|
30 |
-
|
31 |
Args:
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
raise ValueError(f"{name} {angle}° must be between {min_val}° and {max_val}°")
|
42 |
|
43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
"""
|
45 |
Calculate solar declination angle.
|
46 |
-
Reference:
|
47 |
-
|
48 |
Args:
|
49 |
-
day_of_year: Day of the year (1-365)
|
50 |
-
|
51 |
Returns:
|
52 |
-
Declination angle in degrees
|
53 |
"""
|
54 |
-
if
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
59 |
return declination
|
60 |
|
61 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
62 |
"""
|
63 |
-
Calculate solar hour angle.
|
64 |
-
Reference:
|
65 |
-
|
66 |
Args:
|
67 |
-
|
68 |
-
|
69 |
Returns:
|
70 |
-
Hour angle in degrees
|
71 |
"""
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
hour_angle = (hour - 12) * 15
|
76 |
-
self.validate_angle(hour_angle, "Hour angle", -180, 180)
|
77 |
return hour_angle
|
78 |
|
79 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
"""
|
81 |
-
|
82 |
-
Reference: ASHRAE
|
83 |
-
|
84 |
Args:
|
85 |
-
|
86 |
-
|
87 |
-
hour_angle: Hour angle in degrees
|
88 |
-
|
89 |
Returns:
|
90 |
-
|
91 |
-
"""
|
92 |
-
|
93 |
-
self.
|
94 |
-
self.
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
108 |
Args:
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
Returns:
|
115 |
-
|
116 |
-
"""
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
133 |
|
|
|
134 |
|
135 |
class HeatTransferCalculations:
|
136 |
"""Class for heat transfer calculations."""
|
137 |
-
|
138 |
-
def __init__(self):
|
139 |
"""
|
140 |
Initialize heat transfer calculations with psychrometrics and solar calculations.
|
141 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16.
|
|
|
|
|
142 |
"""
|
143 |
self.psychrometrics = Psychrometrics()
|
144 |
self.solar = SolarCalculations()
|
145 |
-
self.debug_mode =
|
146 |
-
|
147 |
-
|
|
|
|
|
|
|
148 |
"""
|
149 |
Calculate heat transfer via conduction.
|
150 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
|
151 |
-
|
152 |
Args:
|
153 |
-
u_value: U-value of the component in W/(m²·K)
|
154 |
-
area: Area of the component in m²
|
155 |
-
delta_t: Temperature difference in °C
|
156 |
-
|
157 |
Returns:
|
158 |
-
Heat transfer rate in W
|
159 |
"""
|
160 |
-
if u_value
|
|
|
|
|
|
|
|
|
|
|
|
|
161 |
raise ValueError("U-value and area must be non-negative")
|
162 |
-
|
163 |
q = u_value * area * delta_t
|
164 |
return q
|
165 |
|
166 |
-
def infiltration_heat_transfer(self, flow_rate: float, delta_t: float,
|
167 |
-
t_db: float, rh: float,
|
|
|
|
|
168 |
"""
|
169 |
Calculate sensible heat transfer due to infiltration or ventilation.
|
170 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.5.
|
171 |
-
|
172 |
Args:
|
173 |
-
flow_rate: Air flow rate in m³/s
|
174 |
-
delta_t: Temperature difference in °C
|
175 |
-
t_db: Dry-bulb temperature for air properties in °C
|
176 |
-
rh: Relative humidity in % (0-100)
|
177 |
-
p_atm: Atmospheric pressure in Pa
|
178 |
-
|
179 |
Returns:
|
180 |
-
Sensible heat transfer rate in W
|
181 |
"""
|
182 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
183 |
raise ValueError("Flow rate cannot be negative")
|
184 |
-
|
185 |
-
# Calculate air density and specific heat using psychrometrics
|
186 |
w = self.psychrometrics.humidity_ratio(t_db, rh, p_atm)
|
187 |
rho = self.psychrometrics.density(t_db, w, p_atm)
|
188 |
c_p = 1006 + 1860 * w # Specific heat of moist air in J/(kg·K)
|
189 |
-
|
190 |
q = flow_rate * rho * c_p * delta_t
|
191 |
return q
|
192 |
|
193 |
-
def infiltration_latent_heat_transfer(self, flow_rate: float, delta_w: float,
|
194 |
-
t_db: float,
|
|
|
|
|
195 |
"""
|
196 |
Calculate latent heat transfer due to infiltration or ventilation.
|
197 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.6.
|
198 |
-
|
199 |
Args:
|
200 |
-
flow_rate: Air flow rate in m³/s
|
201 |
-
delta_w: Humidity ratio difference in kg/kg
|
202 |
-
t_db: Dry-bulb temperature for air properties in °C
|
203 |
-
|
204 |
-
p_atm: Atmospheric pressure in Pa
|
205 |
-
|
206 |
Returns:
|
207 |
-
Latent heat transfer rate in W
|
208 |
"""
|
209 |
-
|
210 |
-
|
211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
212 |
# Calculate air density and latent heat
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
q = flow_rate * rho * h_fg * delta_w
|
218 |
return q
|
219 |
|
220 |
-
def wind_pressure_difference(self, wind_speed: float
|
|
|
221 |
"""
|
222 |
Calculate pressure difference due to wind.
|
223 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.3.
|
224 |
-
|
225 |
Args:
|
226 |
-
wind_speed: Wind speed in m/s
|
227 |
-
|
|
|
228 |
Returns:
|
229 |
-
Pressure difference in Pa
|
230 |
"""
|
231 |
if wind_speed < 0:
|
232 |
raise ValueError("Wind speed cannot be negative")
|
233 |
-
|
234 |
-
c_p = 0.6 # Wind pressure coefficient
|
235 |
-
rho_air = 1.2 # Air density at standard conditions in kg/m³
|
236 |
-
delta_p = 0.5 * c_p * rho_air * wind_speed**2
|
237 |
return delta_p
|
238 |
|
239 |
-
def stack_pressure_difference(self, height: float,
|
|
|
240 |
"""
|
241 |
Calculate pressure difference due to stack effect.
|
242 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.4.
|
243 |
-
|
244 |
Args:
|
245 |
-
height: Height
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
Returns:
|
250 |
-
Pressure difference in Pa
|
251 |
-
"""
|
252 |
-
if height < 0 or
|
253 |
-
raise ValueError("Height and temperatures must be positive")
|
254 |
-
|
255 |
-
g = 9.
|
256 |
-
|
257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
258 |
return delta_p
|
259 |
|
260 |
def combined_pressure_difference(self, wind_pd: float, stack_pd: float) -> float:
|
261 |
-
"""
|
262 |
-
Calculate combined pressure difference from wind and stack effects.
|
263 |
-
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Section 16.2.
|
264 |
-
|
265 |
-
Args:
|
266 |
-
wind_pd: Wind pressure difference in Pa
|
267 |
-
stack_pd: Stack pressure difference in Pa
|
268 |
-
|
269 |
-
Returns:
|
270 |
-
Combined pressure difference in Pa
|
271 |
-
"""
|
272 |
delta_p = math.sqrt(wind_pd**2 + stack_pd**2)
|
273 |
return delta_p
|
274 |
|
275 |
-
def crack_method_infiltration(self, crack_length: float, crack_width: float, delta_p: float
|
|
|
276 |
"""
|
277 |
-
Calculate infiltration flow rate using crack method.
|
278 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.5.
|
279 |
-
|
280 |
Args:
|
281 |
-
crack_length: Length of cracks in m
|
282 |
-
crack_width: Width of cracks in m
|
283 |
-
delta_p: Pressure difference across cracks in Pa
|
284 |
-
|
285 |
Returns:
|
286 |
-
Infiltration flow rate in m³/s
|
287 |
"""
|
288 |
if crack_length < 0 or crack_width < 0 or delta_p < 0:
|
289 |
-
raise ValueError("Crack
|
290 |
-
|
291 |
-
|
|
|
|
|
292 |
area = crack_length * crack_width
|
293 |
-
|
294 |
-
q = c_d * area * math.sqrt(2 * delta_p / rho_air)
|
295 |
return q
|
296 |
|
297 |
-
|
298 |
# Example usage
|
299 |
if __name__ == "__main__":
|
300 |
-
heat_transfer = HeatTransferCalculations()
|
301 |
-
|
302 |
-
|
303 |
# Example conduction calculation
|
304 |
-
u_value = 0.5
|
305 |
-
area = 20.0
|
306 |
-
delta_t =
|
307 |
q_conduction = heat_transfer.conduction_heat_transfer(u_value, area, delta_t)
|
308 |
logger.info(f"Conduction heat transfer: {q_conduction:.2f} W")
|
309 |
-
|
310 |
-
# Example infiltration calculation
|
311 |
-
flow_rate = 0.05
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
"""
|
2 |
Heat transfer calculation module for HVAC Load Calculator.
|
3 |
+
This module implements heat transfer calculations for conduction, infiltration,
|
4 |
+
and enhanced solar radiation modeling, including vectorization support.
|
5 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapters 14, 16, 18.
|
6 |
+
Duffie & Beckman, Solar Engineering of Thermal Processes (4th Ed.).
|
7 |
+
|
8 |
+
Author: Dr Majed Abuseif
|
9 |
+
Date: May 2025 (Enhanced based on plan, preserving original features)
|
10 |
+
Version: 1.3.0
|
11 |
"""
|
12 |
|
13 |
+
from typing import Dict, List, Any, Optional, Tuple, Union
|
14 |
import math
|
15 |
import numpy as np
|
16 |
import logging
|
|
|
20 |
logging.basicConfig(level=logging.INFO)
|
21 |
logger = logging.getLogger(__name__)
|
22 |
|
23 |
+
# Import utility modules (ensure these exist and are correct)
|
24 |
+
try:
|
25 |
+
from utils.psychrometrics import Psychrometrics
|
26 |
+
except ImportError:
|
27 |
+
print("Warning: Could not import Psychrometrics. Using placeholder.")
|
28 |
+
class Psychrometrics:
|
29 |
+
def humidity_ratio(self, tdb, rh, p): return 0.01
|
30 |
+
def density(self, tdb, w, p): return 1.2
|
31 |
+
def pressure_at_altitude(self, alt): return 101325
|
32 |
+
def latent_heat_of_vaporization(self, tdb): return 2501000
|
33 |
+
# Add other methods if needed by HeatTransferCalculations
|
34 |
+
|
35 |
+
# Import data modules (ensure these exist and are correct)
|
36 |
+
try:
|
37 |
+
# Assuming Orientation enum is defined elsewhere, e.g., in component selection
|
38 |
+
from app.component_selection import Orientation
|
39 |
+
except ImportError:
|
40 |
+
print("Warning: Could not import Orientation enum. Using placeholder.")
|
41 |
+
from enum import Enum
|
42 |
+
class Orientation(Enum): NORTH="N"; NORTHEAST="NE"; EAST="E"; SOUTHEAST="SE"; SOUTH="S"; SOUTHWEST="SW"; WEST="W"; NORTHWEST="NW"; HORIZONTAL="H"
|
43 |
+
|
44 |
+
# Constants
|
45 |
+
STEFAN_BOLTZMANN = 5.67e-8 # W/(m²·K⁴)
|
46 |
+
SOLAR_CONSTANT = 1367 # W/m² (Average extraterrestrial solar irradiance)
|
47 |
+
ATMOSPHERIC_PRESSURE = 101325 # Pa
|
48 |
+
GAS_CONSTANT_DRY_AIR = 287.058 # J/(kg·K)
|
49 |
|
50 |
+
@dataclass
|
51 |
+
class SolarAngles:
|
52 |
+
"""Holds calculated solar angles."""
|
53 |
+
declination: float # degrees
|
54 |
+
hour_angle: float # degrees
|
55 |
+
altitude: float # degrees
|
56 |
+
azimuth: float # degrees
|
57 |
+
incidence_angle: Optional[float] = None # degrees, for a specific surface
|
58 |
|
59 |
+
@dataclass
|
60 |
+
class SolarRadiation:
|
61 |
+
"""Holds calculated solar radiation components."""
|
62 |
+
extraterrestrial_normal: float # W/m²
|
63 |
+
direct_normal: float # W/m² (DNI)
|
64 |
+
diffuse_horizontal: float # W/m² (DHI)
|
65 |
+
global_horizontal: float # W/m² (GHI = DNI*cos(zenith) + DHI)
|
66 |
+
total_on_surface: Optional[float] = None # W/m², for a specific surface
|
67 |
+
direct_on_surface: Optional[float] = None # W/m²
|
68 |
+
diffuse_on_surface: Optional[float] = None # W/m²
|
69 |
+
reflected_on_surface: Optional[float] = None # W/m²
|
70 |
|
71 |
class SolarCalculations:
|
72 |
+
"""Class for enhanced solar geometry and radiation calculations."""
|
73 |
+
|
74 |
+
def validate_angle(self, angle: Union[float, np.ndarray], name: str, min_val: float, max_val: float) -> None:
|
75 |
+
""" Validate angle inputs for solar calculations (handles scalars and numpy arrays). """
|
76 |
+
if isinstance(angle, np.ndarray):
|
77 |
+
if np.any(angle < min_val) or np.any(angle > max_val):
|
78 |
+
logger.warning(f"{name} contains values outside range [{min_val}, {max_val}]")
|
79 |
+
# Optionally clamp values: angle = np.clip(angle, min_val, max_val)
|
80 |
+
elif not min_val <= angle <= max_val:
|
81 |
+
raise ValueError(f"{name} {angle}° must be between {min_val}° and {max_val}°")
|
82 |
+
|
83 |
+
def equation_of_time(self, day_of_year: Union[int, np.ndarray]) -> Union[float, np.ndarray]:
|
84 |
"""
|
85 |
+
Calculate the Equation of Time (EoT) in minutes.
|
86 |
+
Reference: Duffie & Beckman (4th Ed.), Eq 1.5.3a.
|
87 |
Args:
|
88 |
+
day_of_year: Day of the year (1-365 or 1-366).
|
89 |
+
Returns:
|
90 |
+
Equation of Time in minutes.
|
91 |
+
"""
|
92 |
+
if isinstance(day_of_year, np.ndarray):
|
93 |
+
if np.any(day_of_year < 1) or np.any(day_of_year > 366):
|
94 |
+
raise ValueError("Day of year must be between 1 and 366")
|
95 |
+
elif not 1 <= day_of_year <= 366:
|
96 |
+
raise ValueError("Day of year must be between 1 and 366")
|
|
|
97 |
|
98 |
+
B = (day_of_year - 1) * 360 / 365 # degrees (approximate)
|
99 |
+
B_rad = np.radians(B)
|
100 |
+
eot = 229.18 * (0.000075 + 0.001868 * np.cos(B_rad) - 0.032077 * np.sin(B_rad) \
|
101 |
+
- 0.014615 * np.cos(2 * B_rad) - 0.04089 * np.sin(2 * B_rad))
|
102 |
+
return eot
|
103 |
+
|
104 |
+
def solar_declination(self, day_of_year: Union[int, np.ndarray]) -> Union[float, np.ndarray]:
|
105 |
"""
|
106 |
Calculate solar declination angle.
|
107 |
+
Reference: Duffie & Beckman (4th Ed.), Eq 1.6.1a (more accurate than ASHRAE approx).
|
|
|
108 |
Args:
|
109 |
+
day_of_year: Day of the year (1-365 or 1-366).
|
|
|
110 |
Returns:
|
111 |
+
Declination angle in degrees.
|
112 |
"""
|
113 |
+
if isinstance(day_of_year, np.ndarray):
|
114 |
+
if np.any(day_of_year < 1) or np.any(day_of_year > 366):
|
115 |
+
raise ValueError("Day of year must be between 1 and 366")
|
116 |
+
elif not 1 <= day_of_year <= 366:
|
117 |
+
raise ValueError("Day of year must be between 1 and 366")
|
118 |
+
|
119 |
+
# Using Spencer's formula (1971) as cited in Duffie & Beckman
|
120 |
+
gamma_rad = (2 * np.pi / 365) * (day_of_year - 1)
|
121 |
+
declination_rad = (0.006918 - 0.399912 * np.cos(gamma_rad) + 0.070257 * np.sin(gamma_rad)
|
122 |
+
- 0.006758 * np.cos(2 * gamma_rad) + 0.000907 * np.sin(2 * gamma_rad)
|
123 |
+
- 0.002697 * np.cos(3 * gamma_rad) + 0.00148 * np.sin(3 * gamma_rad))
|
124 |
+
declination = np.degrees(declination_rad)
|
125 |
+
# self.validate_angle(declination, "Declination angle", -23.45, 23.45)
|
126 |
return declination
|
127 |
|
128 |
+
def solar_time(self, local_standard_time_hour: Union[float, np.ndarray], eot_minutes: Union[float, np.ndarray],
|
129 |
+
longitude_deg: float, standard_meridian_deg: float) -> Union[float, np.ndarray]:
|
130 |
+
"""
|
131 |
+
Calculate solar time (Local Apparent Time, LAT).
|
132 |
+
Reference: Duffie & Beckman (4th Ed.), Eq 1.5.2.
|
133 |
+
Args:
|
134 |
+
local_standard_time_hour: Local standard time (clock time) in hours (0-24).
|
135 |
+
eot_minutes: Equation of Time in minutes.
|
136 |
+
longitude_deg: Local longitude in degrees (East positive, West negative).
|
137 |
+
standard_meridian_deg: Standard meridian for the local time zone in degrees.
|
138 |
+
Returns:
|
139 |
+
Solar time in hours (0-24).
|
140 |
+
"""
|
141 |
+
# Time correction for longitude difference from standard meridian
|
142 |
+
longitude_correction_minutes = 4 * (standard_meridian_deg - longitude_deg)
|
143 |
+
solar_time_hour = local_standard_time_hour + (eot_minutes + longitude_correction_minutes) / 60.0
|
144 |
+
return solar_time_hour
|
145 |
+
|
146 |
+
def solar_hour_angle(self, solar_time_hour: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
|
147 |
"""
|
148 |
+
Calculate solar hour angle from solar time.
|
149 |
+
Reference: Duffie & Beckman (4th Ed.), Section 1.6.
|
|
|
150 |
Args:
|
151 |
+
solar_time_hour: Solar time in hours (0-24).
|
|
|
152 |
Returns:
|
153 |
+
Hour angle in degrees (-180 to 180, zero at solar noon).
|
154 |
"""
|
155 |
+
# Hour angle is 15 degrees per hour from solar noon (12)
|
156 |
+
hour_angle = (solar_time_hour - 12) * 15
|
157 |
+
# self.validate_angle(hour_angle, "Hour angle", -180, 180)
|
|
|
|
|
158 |
return hour_angle
|
159 |
|
160 |
+
def solar_zenith_altitude(self, latitude_deg: float, declination_deg: Union[float, np.ndarray],
|
161 |
+
hour_angle_deg: Union[float, np.ndarray]) -> Tuple[Union[float, np.ndarray], Union[float, np.ndarray]]:
|
162 |
+
"""
|
163 |
+
Calculate solar zenith and altitude angles.
|
164 |
+
Reference: Duffie & Beckman (4th Ed.), Eq 1.6.5.
|
165 |
+
Args:
|
166 |
+
latitude_deg: Latitude in degrees.
|
167 |
+
declination_deg: Declination angle in degrees.
|
168 |
+
hour_angle_deg: Hour angle in degrees.
|
169 |
+
Returns:
|
170 |
+
Tuple: (zenith angle in degrees [0-180], altitude angle in degrees [-90 to 90]).
|
171 |
+
Altitude is 90 - zenith.
|
172 |
+
"""
|
173 |
+
self.validate_angle(latitude_deg, "Latitude", -90, 90)
|
174 |
+
# self.validate_angle(declination_deg, "Declination", -23.45, 23.45)
|
175 |
+
# self.validate_angle(hour_angle_deg, "Hour angle", -180, 180)
|
176 |
+
|
177 |
+
lat_rad = np.radians(latitude_deg)
|
178 |
+
dec_rad = np.radians(declination_deg)
|
179 |
+
ha_rad = np.radians(hour_angle_deg)
|
180 |
+
|
181 |
+
cos_zenith = (np.sin(lat_rad) * np.sin(dec_rad) +
|
182 |
+
np.cos(lat_rad) * np.cos(dec_rad) * np.cos(ha_rad))
|
183 |
+
# Clamp cos_zenith to [-1, 1] due to potential floating point inaccuracies
|
184 |
+
cos_zenith = np.clip(cos_zenith, -1.0, 1.0)
|
185 |
+
|
186 |
+
zenith_rad = np.arccos(cos_zenith)
|
187 |
+
zenith_deg = np.degrees(zenith_rad)
|
188 |
+
altitude_deg = 90.0 - zenith_deg
|
189 |
+
|
190 |
+
# self.validate_angle(zenith_deg, "Zenith angle", 0, 180)
|
191 |
+
# self.validate_angle(altitude_deg, "Altitude angle", -90, 90)
|
192 |
+
return zenith_deg, altitude_deg
|
193 |
+
|
194 |
+
def solar_azimuth(self, latitude_deg: float, declination_deg: Union[float, np.ndarray],
|
195 |
+
hour_angle_deg: Union[float, np.ndarray], zenith_deg: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
|
196 |
+
"""
|
197 |
+
Calculate solar azimuth angle (measured clockwise from North = 0°).
|
198 |
+
Reference: Duffie & Beckman (4th Ed.), Eq 1.6.6 (modified for N=0, E=90, S=180, W=270).
|
199 |
+
Args:
|
200 |
+
latitude_deg: Latitude in degrees.
|
201 |
+
declination_deg: Declination angle in degrees.
|
202 |
+
hour_angle_deg: Hour angle in degrees.
|
203 |
+
zenith_deg: Zenith angle in degrees.
|
204 |
+
Returns:
|
205 |
+
Azimuth angle in degrees (0-360, clockwise from North).
|
206 |
+
"""
|
207 |
+
lat_rad = np.radians(latitude_deg)
|
208 |
+
dec_rad = np.radians(declination_deg)
|
209 |
+
ha_rad = np.radians(hour_angle_deg)
|
210 |
+
zenith_rad = np.radians(zenith_deg)
|
211 |
+
|
212 |
+
# Avoid division by zero if zenith is 0 (sun directly overhead)
|
213 |
+
sin_zenith = np.sin(zenith_rad)
|
214 |
+
azimuth_deg = np.zeros_like(zenith_deg) # Initialize with zeros
|
215 |
+
|
216 |
+
# Calculate only where sin_zenith is significantly greater than zero
|
217 |
+
valid_mask = sin_zenith > 1e-6
|
218 |
+
if isinstance(valid_mask, bool): # Handle scalar case
|
219 |
+
if valid_mask:
|
220 |
+
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
|
221 |
+
cos_azimuth_term = np.clip(cos_azimuth_term, -1.0, 1.0)
|
222 |
+
azimuth_rad_provisional = np.arccos(cos_azimuth_term)
|
223 |
+
azimuth_deg_provisional = np.degrees(azimuth_rad_provisional)
|
224 |
+
|
225 |
+
# Adjust based on hour angle
|
226 |
+
if isinstance(hour_angle_deg, np.ndarray):
|
227 |
+
azimuth_deg = np.where(hour_angle_deg > 0, 360.0 - azimuth_deg_provisional, azimuth_deg_provisional)
|
228 |
+
else:
|
229 |
+
azimuth_deg = 360.0 - azimuth_deg_provisional if hour_angle_deg > 0 else azimuth_deg_provisional
|
230 |
+
else:
|
231 |
+
azimuth_deg = 0.0 # Undefined when sun is at zenith, assign 0
|
232 |
+
else: # Handle array case
|
233 |
+
cos_azimuth_term = np.full_like(zenith_deg, 0.0)
|
234 |
+
# Calculate term only for valid entries
|
235 |
+
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]
|
236 |
+
cos_azimuth_term = np.clip(cos_azimuth_term, -1.0, 1.0)
|
237 |
+
|
238 |
+
azimuth_rad_provisional = np.arccos(cos_azimuth_term)
|
239 |
+
azimuth_deg_provisional = np.degrees(azimuth_rad_provisional)
|
240 |
+
|
241 |
+
# Adjust based on hour angle (vectorized)
|
242 |
+
azimuth_deg = np.where(hour_angle_deg > 0, 360.0 - azimuth_deg_provisional, azimuth_deg_provisional)
|
243 |
+
# Ensure invalid entries remain 0
|
244 |
+
azimuth_deg[~valid_mask] = 0.0
|
245 |
+
|
246 |
+
# self.validate_angle(azimuth_deg, "Azimuth angle", 0, 360)
|
247 |
+
return azimuth_deg
|
248 |
+
|
249 |
+
def extraterrestrial_radiation_normal(self, day_of_year: Union[int, np.ndarray]) -> Union[float, np.ndarray]:
|
250 |
+
"""
|
251 |
+
Calculate extraterrestrial solar radiation normal to the sun's rays.
|
252 |
+
Reference: Duffie & Beckman (4th Ed.), Eq 1.4.1b.
|
253 |
+
Args:
|
254 |
+
day_of_year: Day of the year (1-365 or 1-366).
|
255 |
+
Returns:
|
256 |
+
Extraterrestrial normal irradiance (G_on) in W/m².
|
257 |
+
"""
|
258 |
+
B = (day_of_year - 1) * 360 / 365 # degrees
|
259 |
+
B_rad = np.radians(B)
|
260 |
+
G_on = SOLAR_CONSTANT * (1.000110 + 0.034221 * np.cos(B_rad) + 0.001280 * np.sin(B_rad)
|
261 |
+
+ 0.000719 * np.cos(2 * B_rad) + 0.000077 * np.sin(2 * B_rad))
|
262 |
+
return G_on
|
263 |
+
|
264 |
+
def angle_of_incidence(self, latitude_deg: float, declination_deg: float, hour_angle_deg: float,
|
265 |
+
surface_tilt_deg: float, surface_azimuth_deg: float) -> float:
|
266 |
+
"""
|
267 |
+
Calculate the angle of incidence of beam radiation on a tilted surface.
|
268 |
+
Reference: Duffie & Beckman (4th Ed.), Eq 1.6.2.
|
269 |
+
Args:
|
270 |
+
latitude_deg: Latitude.
|
271 |
+
declination_deg: Solar declination.
|
272 |
+
hour_angle_deg: Solar hour angle.
|
273 |
+
surface_tilt_deg: Surface tilt angle from horizontal (0=horizontal, 90=vertical).
|
274 |
+
surface_azimuth_deg: Surface azimuth angle (0=N, 90=E, 180=S, 270=W).
|
275 |
+
Returns:
|
276 |
+
Angle of incidence in degrees (0-90). Returns > 90 if sun is behind surface.
|
277 |
+
"""
|
278 |
+
lat_rad = np.radians(latitude_deg)
|
279 |
+
dec_rad = np.radians(declination_deg)
|
280 |
+
ha_rad = np.radians(hour_angle_deg)
|
281 |
+
tilt_rad = np.radians(surface_tilt_deg)
|
282 |
+
surf_azim_rad = np.radians(surface_azimuth_deg)
|
283 |
+
|
284 |
+
# Convert surface azimuth from N=0 to S=0 convention used in formula
|
285 |
+
gamma_rad = surf_azim_rad - np.pi # S=0, E=pi/2, W=-pi/2
|
286 |
+
|
287 |
+
cos_theta = (np.sin(lat_rad) * np.sin(dec_rad) * np.cos(tilt_rad)
|
288 |
+
- np.cos(lat_rad) * np.sin(dec_rad) * np.sin(tilt_rad) * np.cos(gamma_rad)
|
289 |
+
+ np.cos(lat_rad) * np.cos(dec_rad) * np.cos(ha_rad) * np.cos(tilt_rad)
|
290 |
+
+ np.sin(lat_rad) * np.cos(dec_rad) * np.cos(ha_rad) * np.sin(tilt_rad) * np.cos(gamma_rad)
|
291 |
+
+ np.cos(dec_rad) * np.sin(ha_rad) * np.sin(tilt_rad) * np.sin(gamma_rad))
|
292 |
+
|
293 |
+
# Clamp cos_theta to [-1, 1]
|
294 |
+
cos_theta = np.clip(cos_theta, -1.0, 1.0)
|
295 |
+
theta_rad = np.arccos(cos_theta)
|
296 |
+
theta_deg = np.degrees(theta_rad)
|
297 |
+
|
298 |
+
return theta_deg
|
299 |
+
|
300 |
+
def ashrae_clear_sky_radiation(self, day_of_year: int, hour: float, latitude_deg: float,
|
301 |
+
longitude_deg: float, standard_meridian_deg: float,
|
302 |
+
altitude_m: float = 0)
|
303 |
+
-> Tuple[float, float, float]:
|
304 |
"""
|
305 |
+
Estimate DNI and DHI using ASHRAE Clear Sky model.
|
306 |
+
Reference: ASHRAE HOF 2017, Ch. 14, Eq. 14.18-14.21 (Simplified version)
|
|
|
307 |
Args:
|
308 |
+
day_of_year, hour, latitude_deg, longitude_deg, standard_meridian_deg: Location/time info.
|
309 |
+
altitude_m: Site altitude in meters.
|
|
|
|
|
310 |
Returns:
|
311 |
+
Tuple: (DNI in W/m², DHI in W/m², GHI in W/m²)
|
312 |
+
"""
|
313 |
+
# 1. Calculate Solar Angles
|
314 |
+
eot = self.equation_of_time(day_of_year)
|
315 |
+
solar_time = self.solar_time(hour, eot, longitude_deg, standard_meridian_deg)
|
316 |
+
ha = self.solar_hour_angle(solar_time)
|
317 |
+
dec = self.solar_declination(day_of_year)
|
318 |
+
zenith, alt = self.solar_zenith_altitude(latitude_deg, dec, ha)
|
319 |
+
|
320 |
+
if alt <= 0: # Sun below horizon
|
321 |
+
return 0.0, 0.0, 0.0
|
322 |
+
|
323 |
+
# 2. Extraterrestrial Radiation
|
324 |
+
G_on = self.extraterrestrial_radiation_normal(day_of_year)
|
325 |
+
G_oh = G_on * np.cos(np.radians(zenith)) # On horizontal surface
|
326 |
+
|
327 |
+
# 3. ASHRAE Clear Sky Model Parameters (simplified)
|
328 |
+
# These depend on atmospheric conditions (clearness, water vapor, etc.)
|
329 |
+
# Using typical clear day values for illustration
|
330 |
+
A = 1160 + 75 * np.sin(np.radians(360 * (day_of_year - 275) / 365)) # Apparent extraterrestrial irradiance
|
331 |
+
k = 0.174 + 0.035 * np.sin(np.radians(360 * (day_of_year - 100) / 365)) # Optical depth
|
332 |
+
C = 0.095 + 0.04 * np.sin(np.radians(360 * (day_of_year - 100) / 365)) # Sky diffuse factor
|
333 |
+
|
334 |
+
# Air mass (simplified Kasten and Young, 1989)
|
335 |
+
m = 1 / (np.cos(np.radians(zenith)) + 0.50572 * (96.07995 - zenith)**(-1.6364))
|
336 |
+
# Altitude correction for air mass (approximate)
|
337 |
+
pressure_ratio = (Psychrometrics().pressure_at_altitude(altitude_m) / ATMOSPHERIC_PRESSURE)
|
338 |
+
m *= pressure_ratio
|
339 |
+
|
340 |
+
# 4. Calculate DNI and DHI
|
341 |
+
dni = A * np.exp(-k * m)
|
342 |
+
dhi = C * dni
|
343 |
+
ghi = dni * np.cos(np.radians(zenith)) + dhi
|
344 |
+
|
345 |
+
# Ensure non-negative
|
346 |
+
dni = max(0.0, dni)
|
347 |
+
dhi = max(0.0, dhi)
|
348 |
+
ghi = max(0.0, ghi)
|
349 |
+
|
350 |
+
return dni, dhi, ghi
|
351 |
+
|
352 |
+
def total_radiation_on_surface(self, dni: float, dhi: float, ghi: float,
|
353 |
+
zenith_deg: float, solar_azimuth_deg: float,
|
354 |
+
surface_tilt_deg: float, surface_azimuth_deg: float,
|
355 |
+
ground_reflectance: float = 0.2)
|
356 |
+
-> Tuple[float, float, float, float]:
|
357 |
+
"""
|
358 |
+
Calculate total solar radiation incident on a tilted surface.
|
359 |
+
Uses isotropic sky model for diffuse radiation.
|
360 |
+
Reference: Duffie & Beckman (4th Ed.), Section 2.15, 2.16.
|
361 |
Args:
|
362 |
+
dni, dhi, ghi: Direct Normal, Diffuse Horizontal, Global Horizontal Irradiance (W/m²).
|
363 |
+
zenith_deg, solar_azimuth_deg: Solar position angles.
|
364 |
+
surface_tilt_deg, surface_azimuth_deg: Surface orientation angles.
|
365 |
+
ground_reflectance: Albedo of the ground (0-1).
|
|
|
366 |
Returns:
|
367 |
+
Tuple: (Total G_t, Direct G_b,t, Diffuse G_d,t, Reflected G_r,t) on surface (W/m²).
|
368 |
+
"""
|
369 |
+
if zenith_deg >= 90: # Sun below horizon
|
370 |
+
return 0.0, 0.0, 0.0, 0.0
|
371 |
+
|
372 |
+
# 1. Angle of Incidence (theta)
|
373 |
+
# Need latitude, declination, hour angle for precise calculation, OR use zenith/azimuth
|
374 |
+
# Using zenith/azimuth method (Duffie & Beckman Eq 1.6.3)
|
375 |
+
cos_theta = (np.cos(np.radians(zenith_deg)) * np.cos(np.radians(surface_tilt_deg)) +
|
376 |
+
np.sin(np.radians(zenith_deg)) * np.sin(np.radians(surface_tilt_deg)) *
|
377 |
+
np.cos(np.radians(solar_azimuth_deg - surface_azimuth_deg)))
|
378 |
+
cos_theta = np.clip(cos_theta, -1.0, 1.0)
|
379 |
+
theta_deg = np.degrees(np.arccos(cos_theta))
|
380 |
+
|
381 |
+
# 2. Direct Beam component on tilted surface (G_b,t)
|
382 |
+
# Only if sun is in front of the surface (theta <= 90)
|
383 |
+
G_b_t = dni * cos_theta if theta_deg <= 90.0 else 0.0
|
384 |
+
G_b_t = max(0.0, G_b_t)
|
385 |
+
|
386 |
+
# 3. Diffuse component on tilted surface (G_d,t) - Isotropic Sky Model
|
387 |
+
# View factor from surface to sky
|
388 |
+
F_sky = (1 + np.cos(np.radians(surface_tilt_deg))) / 2
|
389 |
+
G_d_t = dhi * F_sky
|
390 |
+
G_d_t = max(0.0, G_d_t)
|
391 |
+
|
392 |
+
# 4. Ground Reflected component on tilted surface (G_r,t)
|
393 |
+
# View factor from surface to ground
|
394 |
+
F_ground = (1 - np.cos(np.radians(surface_tilt_deg))) / 2
|
395 |
+
G_r_t = ghi * ground_reflectance * F_ground
|
396 |
+
G_r_t = max(0.0, G_r_t)
|
397 |
+
|
398 |
+
# 5. Total radiation on tilted surface
|
399 |
+
G_t = G_b_t + G_d_t + G_r_t
|
400 |
|
401 |
+
return G_t, G_b_t, G_d_t, G_r_t
|
402 |
|
403 |
class HeatTransferCalculations:
|
404 |
"""Class for heat transfer calculations."""
|
405 |
+
|
406 |
+
def __init__(self, debug_mode: bool = False):
|
407 |
"""
|
408 |
Initialize heat transfer calculations with psychrometrics and solar calculations.
|
409 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16.
|
410 |
+
Args:
|
411 |
+
debug_mode: Enable debug logging if True.
|
412 |
"""
|
413 |
self.psychrometrics = Psychrometrics()
|
414 |
self.solar = SolarCalculations()
|
415 |
+
self.debug_mode = debug_mode
|
416 |
+
if debug_mode:
|
417 |
+
logger.setLevel(logging.DEBUG)
|
418 |
+
|
419 |
+
def conduction_heat_transfer(self, u_value: Union[float, np.ndarray], area: Union[float, np.ndarray],
|
420 |
+
delta_t: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
|
421 |
"""
|
422 |
Calculate heat transfer via conduction.
|
423 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
|
|
|
424 |
Args:
|
425 |
+
u_value: U-value(s) of the component(s) in W/(m²·K)
|
426 |
+
area: Area(s) of the component(s) in m²
|
427 |
+
delta_t: Temperature difference(s) in °C or K
|
|
|
428 |
Returns:
|
429 |
+
Heat transfer rate(s) in W
|
430 |
"""
|
431 |
+
if isinstance(u_value, np.ndarray) or isinstance(area, np.ndarray) or isinstance(delta_t, np.ndarray):
|
432 |
+
u_value = np.asarray(u_value)
|
433 |
+
area = np.asarray(area)
|
434 |
+
delta_t = np.asarray(delta_t)
|
435 |
+
if np.any(u_value < 0) or np.any(area < 0):
|
436 |
+
raise ValueError("U-value and area must be non-negative")
|
437 |
+
elif u_value < 0 or area < 0:
|
438 |
raise ValueError("U-value and area must be non-negative")
|
439 |
+
|
440 |
q = u_value * area * delta_t
|
441 |
return q
|
442 |
|
443 |
+
def infiltration_heat_transfer(self, flow_rate: Union[float, np.ndarray], delta_t: Union[float, np.ndarray],
|
444 |
+
t_db: Union[float, np.ndarray], rh: Union[float, np.ndarray],
|
445 |
+
p_atm: Union[float, np.ndarray] = ATMOSPHERIC_PRESSURE)
|
446 |
+
-> Union[float, np.ndarray]:
|
447 |
"""
|
448 |
Calculate sensible heat transfer due to infiltration or ventilation.
|
449 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.5.
|
|
|
450 |
Args:
|
451 |
+
flow_rate: Air flow rate(s) in m³/s
|
452 |
+
delta_t: Temperature difference(s) in °C or K
|
453 |
+
t_db: Dry-bulb temperature(s) for air properties in °C
|
454 |
+
rh: Relative humidity(ies) in % (0-100)
|
455 |
+
p_atm: Atmospheric pressure(s) in Pa
|
|
|
456 |
Returns:
|
457 |
+
Sensible heat transfer rate(s) in W
|
458 |
"""
|
459 |
+
# Convert inputs to numpy arrays if any input is an array
|
460 |
+
is_array = any(isinstance(arg, np.ndarray) for arg in [flow_rate, delta_t, t_db, rh, p_atm])
|
461 |
+
if is_array:
|
462 |
+
flow_rate = np.asarray(flow_rate)
|
463 |
+
delta_t = np.asarray(delta_t)
|
464 |
+
t_db = np.asarray(t_db)
|
465 |
+
rh = np.asarray(rh)
|
466 |
+
p_atm = np.asarray(p_atm)
|
467 |
+
if np.any(flow_rate < 0):
|
468 |
+
raise ValueError("Flow rate cannot be negative")
|
469 |
+
elif flow_rate < 0:
|
470 |
raise ValueError("Flow rate cannot be negative")
|
471 |
+
|
472 |
+
# Calculate air density and specific heat using psychrometrics (vectorized if needed)
|
473 |
w = self.psychrometrics.humidity_ratio(t_db, rh, p_atm)
|
474 |
rho = self.psychrometrics.density(t_db, w, p_atm)
|
475 |
c_p = 1006 + 1860 * w # Specific heat of moist air in J/(kg·K)
|
476 |
+
|
477 |
q = flow_rate * rho * c_p * delta_t
|
478 |
return q
|
479 |
|
480 |
+
def infiltration_latent_heat_transfer(self, flow_rate: Union[float, np.ndarray], delta_w: Union[float, np.ndarray],
|
481 |
+
t_db: Union[float, np.ndarray], # Temp for density/hfg calc
|
482 |
+
p_atm: Union[float, np.ndarray] = ATMOSPHERIC_PRESSURE)
|
483 |
+
-> Union[float, np.ndarray]:
|
484 |
"""
|
485 |
Calculate latent heat transfer due to infiltration or ventilation.
|
486 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.6.
|
|
|
487 |
Args:
|
488 |
+
flow_rate: Air flow rate(s) in m³/s
|
489 |
+
delta_w: Humidity ratio difference(s) in kg/kg
|
490 |
+
t_db: Dry-bulb temperature(s) for air properties in °C
|
491 |
+
p_atm: Atmospheric pressure(s) in Pa
|
|
|
|
|
492 |
Returns:
|
493 |
+
Latent heat transfer rate(s) in W
|
494 |
"""
|
495 |
+
is_array = any(isinstance(arg, np.ndarray) for arg in [flow_rate, delta_w, t_db, p_atm])
|
496 |
+
if is_array:
|
497 |
+
flow_rate = np.asarray(flow_rate)
|
498 |
+
delta_w = np.asarray(delta_w)
|
499 |
+
t_db = np.asarray(t_db)
|
500 |
+
p_atm = np.asarray(p_atm)
|
501 |
+
if np.any(flow_rate < 0):
|
502 |
+
raise ValueError("Flow rate cannot be negative")
|
503 |
+
# Delta_w can be negative (humidification)
|
504 |
+
elif flow_rate < 0:
|
505 |
+
raise ValueError("Flow rate cannot be negative")
|
506 |
+
|
507 |
# Calculate air density and latent heat
|
508 |
+
rho = self.psychrometrics.density(t_db, w=0.008, p_atm=p_atm) # Approximate density
|
509 |
+
h_fg = self.psychrometrics.latent_heat_of_vaporization(t_db) # J/kg
|
510 |
+
|
|
|
511 |
q = flow_rate * rho * h_fg * delta_w
|
512 |
return q
|
513 |
|
514 |
+
def wind_pressure_difference(self, wind_speed: float, wind_pressure_coeff: float = 0.6,
|
515 |
+
air_density: float = 1.2) -> float:
|
516 |
"""
|
517 |
Calculate pressure difference due to wind.
|
518 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.3.
|
|
|
519 |
Args:
|
520 |
+
wind_speed: Wind speed in m/s.
|
521 |
+
wind_pressure_coeff: Wind pressure coefficient (depends on building shape, location).
|
522 |
+
air_density: Air density in kg/m³.
|
523 |
Returns:
|
524 |
+
Pressure difference in Pa.
|
525 |
"""
|
526 |
if wind_speed < 0:
|
527 |
raise ValueError("Wind speed cannot be negative")
|
528 |
+
delta_p = 0.5 * wind_pressure_coeff * air_density * wind_speed**2
|
|
|
|
|
|
|
529 |
return delta_p
|
530 |
|
531 |
+
def stack_pressure_difference(self, height: float, t_inside_k: float, t_outside_k: float,
|
532 |
+
p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
533 |
"""
|
534 |
Calculate pressure difference due to stack effect.
|
535 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.4.
|
|
|
536 |
Args:
|
537 |
+
height: Height difference in m (e.g., NPL to opening).
|
538 |
+
t_inside_k: Inside absolute temperature in K.
|
539 |
+
t_outside_k: Outside absolute temperature in K.
|
540 |
+
p_atm: Atmospheric pressure in Pa.
|
541 |
Returns:
|
542 |
+
Pressure difference in Pa.
|
543 |
+
"""
|
544 |
+
if height < 0 or t_inside_k <= 0 or t_outside_k <= 0:
|
545 |
+
raise ValueError("Height and absolute temperatures must be positive")
|
546 |
+
|
547 |
+
g = 9.80665 # Gravitational acceleration in m/s²
|
548 |
+
r_da = GAS_CONSTANT_DRY_AIR
|
549 |
+
|
550 |
+
# Calculate Density inside and outside (approximating as dry air for simplicity)
|
551 |
+
rho_inside = p_atm / (r_da * t_inside_k)
|
552 |
+
rho_outside = p_atm / (r_da * t_outside_k)
|
553 |
+
|
554 |
+
# Pressure difference = g * height * (rho_outside - rho_inside)
|
555 |
+
delta_p = g * height * (rho_outside - rho_inside)
|
556 |
return delta_p
|
557 |
|
558 |
def combined_pressure_difference(self, wind_pd: float, stack_pd: float) -> float:
|
559 |
+
""" Calculate combined pressure difference from wind and stack effects (simple superposition). """
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
560 |
delta_p = math.sqrt(wind_pd**2 + stack_pd**2)
|
561 |
return delta_p
|
562 |
|
563 |
+
def crack_method_infiltration(self, crack_length: float, crack_width: float, delta_p: float,
|
564 |
+
flow_exponent: float = 0.65) -> float:
|
565 |
"""
|
566 |
+
Calculate infiltration flow rate using the power law (crack) method.
|
567 |
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.5.
|
568 |
+
Formula: Q = flow_coeff * area * (delta_p / 0.001)^n
|
569 |
Args:
|
570 |
+
crack_length: Length of cracks in m.
|
571 |
+
crack_width: Width of cracks in m.
|
572 |
+
delta_p: Pressure difference across cracks in Pa.
|
573 |
+
flow_exponent: Flow exponent (n), typically 0.5 to 1.0 (default 0.65).
|
574 |
Returns:
|
575 |
+
Infiltration flow rate (Q) in m³/s.
|
576 |
"""
|
577 |
if crack_length < 0 or crack_width < 0 or delta_p < 0:
|
578 |
+
raise ValueError("Crack length, crack width, and pressure difference must be non-negative")
|
579 |
+
if not 0.5 <= flow_exponent <= 1.0:
|
580 |
+
logger.warning(f"Flow exponent {flow_exponent} is outside the typical range [0.5, 1.0]")
|
581 |
+
|
582 |
+
flow_coeff = 0.65 # ASHRAE default flow coefficient
|
583 |
area = crack_length * crack_width
|
584 |
+
q = flow_coeff * area * ((delta_p / 0.001) ** flow_exponent)
|
|
|
585 |
return q
|
586 |
|
|
|
587 |
# Example usage
|
588 |
if __name__ == "__main__":
|
589 |
+
heat_transfer = HeatTransferCalculations(debug_mode=True)
|
590 |
+
|
|
|
591 |
# Example conduction calculation
|
592 |
+
u_value = 0.5
|
593 |
+
area = 20.0
|
594 |
+
delta_t = 10.0
|
595 |
q_conduction = heat_transfer.conduction_heat_transfer(u_value, area, delta_t)
|
596 |
logger.info(f"Conduction heat transfer: {q_conduction:.2f} W")
|
597 |
+
|
598 |
+
# Example infiltration calculation (Sensible)
|
599 |
+
flow_rate = 0.05
|
600 |
+
delta_t_inf = 10.0
|
601 |
+
t_db_inf = 25.0
|
602 |
+
rh_inf = 50.0
|
603 |
+
q_inf_sens = heat_transfer.infiltration_heat_transfer(flow_rate, delta_t_inf, t_db_inf, rh_inf)
|
604 |
+
logger.info(f"Infiltration sensible heat transfer: {q_inf_sens:.2f} W")
|
605 |
+
|
606 |
+
# Example infiltration calculation (Latent)
|
607 |
+
delta_w_inf = 0.002 # kg/kg
|
608 |
+
q_inf_lat = heat_transfer.infiltration_latent_heat_transfer(flow_rate, delta_w_inf, t_db_inf)
|
609 |
+
logger.info(f"Infiltration latent heat transfer: {q_inf_lat:.2f} W")
|
610 |
+
|
611 |
+
# Example Solar Calculation
|
612 |
+
logger.info("--- Solar Calculation Example ---")
|
613 |
+
latitude = 34.0 # Los Angeles
|
614 |
+
longitude = -118.0
|
615 |
+
std_meridian = -120.0 # PST
|
616 |
+
day_of_year = 80 # Around March 21
|
617 |
+
hour = 13.5 # Local standard time
|
618 |
+
altitude_m = 100
|
619 |
+
|
620 |
+
# Get clear sky radiation
|
621 |
+
dni, dhi, ghi = heat_transfer.solar.ashrae_clear_sky_radiation(
|
622 |
+
day_of_year, hour, latitude, longitude, std_meridian, altitude_m
|
623 |
+
)
|
624 |
+
logger.info(f"Clear Sky Radiation (W/m²): DNI={dni:.1f}, DHI={dhi:.1f}, GHI={ghi:.1f}")
|
625 |
+
|
626 |
+
# Calculate radiation on a south-facing vertical wall
|
627 |
+
eot = heat_transfer.solar.equation_of_time(day_of_year)
|
628 |
+
solar_time = heat_transfer.solar.solar_time(hour, eot, longitude, std_meridian)
|
629 |
+
ha = heat_transfer.solar.solar_hour_angle(solar_time)
|
630 |
+
dec = heat_transfer.solar.solar_declination(day_of_year)
|
631 |
+
zenith, alt = heat_transfer.solar.solar_zenith_altitude(latitude, dec, ha)
|
632 |
+
sol_azimuth = heat_transfer.solar.solar_azimuth(latitude, dec, ha, zenith)
|
633 |
+
|
634 |
+
surface_tilt = 90.0
|
635 |
+
surface_azimuth = 180.0 # South facing
|
636 |
+
ground_reflectance = 0.2
|
637 |
+
|
638 |
+
if alt > 0:
|
639 |
+
G_t, G_b_t, G_d_t, G_r_t = heat_transfer.solar.total_radiation_on_surface(
|
640 |
+
dni, dhi, ghi, zenith, sol_azimuth, surface_tilt, surface_azimuth, ground_reflectance
|
641 |
+
)
|
642 |
+
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}")
|
643 |
+
theta = heat_transfer.solar.angle_of_incidence(latitude, dec, ha, surface_tilt, surface_azimuth)
|
644 |
+
logger.info(f"Incidence Angle: {theta:.1f} degrees")
|
645 |
+
else:
|
646 |
+
logger.info("Sun is below horizon.")
|
647 |
+
|
648 |
+
# Vectorized example (e.g., hourly conduction)
|
649 |
+
logger.info("--- Vectorized Conduction Example ---")
|
650 |
+
hours_array = np.arange(24)
|
651 |
+
delta_t_hourly = 10 * np.sin(np.pi * (hours_array - 8) / 12) + 5 # Example hourly delta T
|
652 |
+
delta_t_hourly[delta_t_hourly < 0] = 0 # Only positive delta T
|
653 |
+
q_conduction_hourly = heat_transfer.conduction_heat_transfer(u_value, area, delta_t_hourly)
|
654 |
+
logger.info(f"Peak Hourly Conduction: {np.max(q_conduction_hourly):.2f} W")
|