Spaces:
Sleeping
Sleeping
Update app/hvac_loads.py
Browse files- app/hvac_loads.py +212 -192
app/hvac_loads.py
CHANGED
@@ -11,7 +11,7 @@ from typing import Dict, List, Optional, NamedTuple, Any, Tuple
|
|
11 |
from enum import Enum
|
12 |
import streamlit as st
|
13 |
from app.materials_library import GlazingMaterial, Material, MaterialLibrary
|
14 |
-
from app.internal_loads import PEOPLE_ACTIVITY_LEVELS,
|
15 |
from datetime import datetime
|
16 |
from collections import defaultdict
|
17 |
import logging
|
@@ -64,31 +64,17 @@ class TFMCalculations:
|
|
64 |
ctf = CTFCalculator.calculate_ctf_coefficients(component)
|
65 |
|
66 |
# Initialize history terms (simplified: assume steady-state history for demonstration)
|
67 |
-
# In practice, maintain temperature and flux histories
|
68 |
load = component.u_value * component.area * delta_t
|
69 |
for i in range(len(ctf.Y)):
|
70 |
load += component.area * ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
71 |
load -= component.area * ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
72 |
-
# Note: F terms require flux history, omitted here for simplicity
|
73 |
cooling_load = load / 1000 if mode == "cooling" else 0
|
74 |
heating_load = -load / 1000 if mode == "heating" else 0
|
75 |
return cooling_load, heating_load
|
76 |
|
77 |
@staticmethod
|
78 |
def day_of_year(month: int, day: int, year: int) -> int:
|
79 |
-
"""Calculate day of the year (n) from month, day, and year, accounting for leap years.
|
80 |
-
|
81 |
-
Args:
|
82 |
-
month (int): Month of the year (1-12).
|
83 |
-
day (int): Day of the month (1-31).
|
84 |
-
year (int): Year.
|
85 |
-
|
86 |
-
Returns:
|
87 |
-
int: Day of the year (1-365 or 366 for leap years).
|
88 |
-
|
89 |
-
References:
|
90 |
-
ASHRAE Handbook—Fundamentals, Chapter 18.
|
91 |
-
"""
|
92 |
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
93 |
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
94 |
days_in_month[1] = 29
|
@@ -96,17 +82,7 @@ class TFMCalculations:
|
|
96 |
|
97 |
@staticmethod
|
98 |
def equation_of_time(n: int) -> float:
|
99 |
-
"""Calculate Equation of Time (EOT) in minutes using Spencer's formula.
|
100 |
-
|
101 |
-
Args:
|
102 |
-
n (int): Day of the year (1-365 or 366).
|
103 |
-
|
104 |
-
Returns:
|
105 |
-
float: Equation of Time in minutes.
|
106 |
-
|
107 |
-
References:
|
108 |
-
ASHRAE Handbook—Fundamentals, Chapter 18.
|
109 |
-
"""
|
110 |
B = (n - 1) * 360 / 365
|
111 |
B_rad = math.radians(B)
|
112 |
EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
|
@@ -115,24 +91,12 @@ class TFMCalculations:
|
|
115 |
|
116 |
@staticmethod
|
117 |
def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
|
118 |
-
"""Calculate dynamic SHGC based on incidence angle.
|
119 |
-
|
120 |
-
Args:
|
121 |
-
glazing_type (str): Type of glazing (e.g., 'Single Clear').
|
122 |
-
cos_theta (float): Cosine of the angle of incidence.
|
123 |
-
|
124 |
-
Returns:
|
125 |
-
float: Dynamic SHGC value.
|
126 |
-
|
127 |
-
References:
|
128 |
-
ASHRAE Handbook—Fundamentals, Chapter 15, Table 13.
|
129 |
-
"""
|
130 |
if glazing_type not in TFMCalculations.SHGC_COEFFICIENTS:
|
131 |
logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
|
132 |
glazing_type = "Single Clear"
|
133 |
|
134 |
c = TFMCalculations.SHGC_COEFFICIENTS[glazing_type]
|
135 |
-
# Incidence angle modifier: f(cos(θ)) = c_0 + c_1·cos(θ) + c_2·cos²(θ) + c_3·cos³(θ) + c_4·cos⁴(θ) + c_5·cos⁵(θ)
|
136 |
f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
|
137 |
c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
|
138 |
return f_cos_theta
|
@@ -141,60 +105,37 @@ class TFMCalculations:
|
|
141 |
def get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary,
|
142 |
project_materials: Dict, project_constructions: Dict,
|
143 |
project_glazing_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
|
144 |
-
"""
|
145 |
-
Determine surface parameters (tilt, azimuth, h_o, emissivity, absorptivity) for a component.
|
146 |
-
Uses MaterialLibrary to fetch properties from first layer for walls/roofs, and GlazingMaterial for windows/skylights.
|
147 |
-
Handles orientation and tilt based on component type:
|
148 |
-
- Walls, Windows: Azimuth = elevation base azimuth + component.rotation; Tilt = 90°.
|
149 |
-
- Roofs, Skylights: Azimuth = component.orientation; Tilt = component.tilt (default 0°).
|
150 |
-
- Floors: Tilt = 180°; Azimuth = 0°.
|
151 |
-
|
152 |
-
Args:
|
153 |
-
component: Component object with component_type, elevation, rotation, orientation, tilt,
|
154 |
-
construction, or glazing_material.
|
155 |
-
building_info (Dict): Building information containing orientation_angle for elevation mapping.
|
156 |
-
material_library: MaterialLibrary instance for accessing library materials/constructions.
|
157 |
-
project_materials: Dict of project-specific Material objects.
|
158 |
-
project_constructions: Dict of project-specific Construction objects.
|
159 |
-
project_glazing_materials: Dict of project-specific GlazingMaterial objects.
|
160 |
-
|
161 |
-
Returns:
|
162 |
-
Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°),
|
163 |
-
h_o (W/m²·K), emissivity, absorptivity.
|
164 |
-
"""
|
165 |
-
# Default parameters
|
166 |
component_name = getattr(component, 'name', 'unnamed_component')
|
167 |
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
absorptivity = 0.6 # Default
|
174 |
|
175 |
try:
|
176 |
-
# Set component-specific defaults based on type
|
177 |
if component.component_type == ComponentType.ROOF:
|
178 |
-
surface_tilt = getattr(component, 'tilt', 0.0)
|
179 |
-
h_o = 23.0
|
180 |
surface_azimuth = getattr(component, 'orientation', 0.0)
|
181 |
logger.debug(f"Roof component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
|
182 |
|
183 |
elif component.component_type == ComponentType.SKYLIGHT:
|
184 |
-
surface_tilt = getattr(component, 'tilt', 0.0)
|
185 |
-
h_o = 23.0
|
186 |
surface_azimuth = getattr(component, 'orientation', 0.0)
|
187 |
logger.debug(f"Skylight component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
|
188 |
|
189 |
elif component.component_type == ComponentType.FLOOR:
|
190 |
-
surface_tilt = 180.0
|
191 |
-
h_o = 17.0
|
192 |
-
surface_azimuth = 0.0
|
193 |
logger.debug(f"Floor component {component_name}: using default azimuth={surface_azimuth}, tilt={surface_tilt}")
|
194 |
|
195 |
else: # WALL, WINDOW
|
196 |
-
surface_tilt = 90.0
|
197 |
-
h_o = 17.0
|
198 |
elevation = getattr(component, 'elevation', None)
|
199 |
if not elevation:
|
200 |
logger.warning(f"Component {component_name} ({component.component_type.value}) is missing 'elevation' field. Using default azimuth=0.")
|
@@ -207,23 +148,17 @@ class TFMCalculations:
|
|
207 |
"C": (base_azimuth + 180.0) % 360,
|
208 |
"D": (base_azimuth + 270.0) % 360
|
209 |
}
|
210 |
-
|
211 |
if elevation not in elevation_angles:
|
212 |
-
logger.warning(f"Invalid elevation '{elevation}' for component {component_name}
|
213 |
-
f"Expected one of {list(elevation_angles.keys())}. Using default azimuth=0.")
|
214 |
surface_azimuth = 0.0
|
215 |
else:
|
216 |
surface_azimuth = (elevation_angles[elevation] + getattr(component, 'rotation', 0.0)) % 360
|
217 |
-
logger.debug(f"Component {component_name}
|
218 |
-
f"base_azimuth={elevation_angles[elevation]}, rotation={getattr(component, 'rotation', 0.0)}, "
|
219 |
-
f"total_azimuth={surface_azimuth}, tilt={surface_tilt}")
|
220 |
|
221 |
-
# Fetch material properties
|
222 |
if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
|
223 |
construction = getattr(component, 'construction', None)
|
224 |
if not construction:
|
225 |
-
logger.warning(f"No construction defined for {component_name}
|
226 |
-
f"Using defaults: absorptivity=0.6, emissivity=0.9.")
|
227 |
else:
|
228 |
construction_obj = None
|
229 |
if hasattr(construction, 'name'):
|
@@ -231,25 +166,21 @@ class TFMCalculations:
|
|
231 |
material_library.library_constructions.get(construction.name))
|
232 |
|
233 |
if not construction_obj:
|
234 |
-
logger.warning(f"Construction not found for {component_name}
|
235 |
-
f"Using defaults: absorptivity=0.6, emissivity=0.9.")
|
236 |
elif not construction_obj.layers:
|
237 |
-
logger.warning(f"No layers in construction for {component_name}
|
238 |
-
f"Using defaults: absorptivity=0.6, emissivity=0.9.")
|
239 |
else:
|
240 |
first_layer = construction_obj.layers[0]
|
241 |
material = first_layer.get("material")
|
242 |
if material:
|
243 |
absorptivity = getattr(material, 'absorptivity', 0.6)
|
244 |
emissivity = getattr(material, 'emissivity', 0.9)
|
245 |
-
logger.debug(f"Using first layer material for {component_name}
|
246 |
-
f"absorptivity={absorptivity}, emissivity={emissivity}")
|
247 |
|
248 |
elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
249 |
glazing_material = getattr(component, 'glazing_material', None)
|
250 |
if not glazing_material:
|
251 |
-
logger.warning(f"No glazing material defined for {component_name}
|
252 |
-
f"Using default SHGC=0.7, h_o={h_o}.")
|
253 |
shgc = 0.7
|
254 |
else:
|
255 |
glazing_material_obj = None
|
@@ -258,18 +189,16 @@ class TFMCalculations:
|
|
258 |
material_library.library_glazing_materials.get(glazing_material.name))
|
259 |
|
260 |
if not glazing_material_obj:
|
261 |
-
logger.warning(f"Glazing material not found for {component_name}
|
262 |
-
f"Using default SHGC=0.7, h_o={h_o}.")
|
263 |
shgc = 0.7
|
264 |
else:
|
265 |
shgc = getattr(glazing_material_obj, 'shgc', 0.7)
|
266 |
h_o = getattr(glazing_material_obj, 'h_o', h_o)
|
267 |
-
logger.debug(f"Using glazing material for {component_name}
|
268 |
-
f"shgc={shgc}, h_o={h_o}")
|
269 |
emissivity = None
|
270 |
|
271 |
except Exception as e:
|
272 |
-
logger.error(f"Error retrieving surface parameters for {component_name}
|
273 |
if component.component_type == ComponentType.ROOF:
|
274 |
surface_tilt = 0.0
|
275 |
h_o = 23.0
|
@@ -294,27 +223,12 @@ class TFMCalculations:
|
|
294 |
shgc = 0.7
|
295 |
emissivity = None
|
296 |
|
297 |
-
logger.info(f"Final surface parameters for {component_name}
|
298 |
-
f"tilt={surface_tilt:.1f}, azimuth={surface_azimuth:.1f}, h_o={h_o:.1f}")
|
299 |
return surface_tilt, surface_azimuth, h_o, emissivity, absorptivity
|
300 |
|
301 |
@staticmethod
|
302 |
def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
|
303 |
-
"""Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations.
|
304 |
-
|
305 |
-
Args:
|
306 |
-
component: Component object with area, component_type, elevation, glazing_material, shgc, iac.
|
307 |
-
hourly_data (Dict): Hourly weather data including solar radiation.
|
308 |
-
hour (int): Current hour.
|
309 |
-
building_orientation (float): Building orientation angle in degrees.
|
310 |
-
mode (str): Operating mode ('cooling', 'heating', 'none').
|
311 |
-
|
312 |
-
Returns:
|
313 |
-
float: Solar cooling load in kW. Returns 0 for non-cooling modes or non-fenestration components.
|
314 |
-
|
315 |
-
References:
|
316 |
-
ASHRAE Handbook—Fundamentals, Chapters 15 and 18.
|
317 |
-
"""
|
318 |
if mode != "cooling":
|
319 |
return 0
|
320 |
|
@@ -329,8 +243,8 @@ class TFMCalculations:
|
|
329 |
from app.material_library import MaterialLibrary
|
330 |
material_library = MaterialLibrary()
|
331 |
st.session_state.material_library = material_library
|
332 |
-
logger.info(f"Created new MaterialLibrary for {component_name}
|
333 |
-
|
334 |
project_materials = st.session_state.get("project_data", {}).get("materials", {}).get("project", {})
|
335 |
project_constructions = st.session_state.get("project_data", {}).get("constructions", {}).get("project", {})
|
336 |
project_glazing_materials = st.session_state.get("project_data", {}).get("fenestrations", {}).get("project", {})
|
@@ -338,45 +252,45 @@ class TFMCalculations:
|
|
338 |
latitude = climate_data.get("latitude", 0.0)
|
339 |
longitude = climate_data.get("longitude", 0.0)
|
340 |
timezone = climate_data.get("time_zone", 0.0)
|
341 |
-
ground_reflectivity =
|
342 |
|
343 |
if not -90 <= latitude <= 90:
|
344 |
-
logger.warning(f"Invalid latitude {latitude} for {component_name}
|
345 |
latitude = 0.0
|
346 |
if not -180 <= longitude <= 180:
|
347 |
-
logger.warning(f"Invalid longitude {longitude} for {component_name}
|
348 |
longitude = 0.0
|
349 |
if not -12 <= timezone <= 14:
|
350 |
-
logger.warning(f"Invalid timezone {timezone} for {component_name}
|
351 |
timezone = 0.0
|
352 |
if not 0 <= ground_reflectivity <= 1:
|
353 |
-
logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name}
|
354 |
ground_reflectivity = 0.2
|
355 |
|
356 |
required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
|
357 |
"diffuse_horizontal_radiation", "dry_bulb"]
|
358 |
if not all(field in hourly_data for field in required_fields):
|
359 |
-
logger.warning(f"Missing required fields in hourly_data for hour {hour} for {component_name}
|
360 |
return 0
|
361 |
|
362 |
if hourly_data["global_horizontal_radiation"] <= 0:
|
363 |
-
logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']} for {component_name}
|
364 |
return 0
|
365 |
|
366 |
month = hourly_data["month"]
|
367 |
day = hourly_data["day"]
|
368 |
hour = hourly_data["hour"]
|
369 |
ghi = hourly_data["global_horizontal_radiation"]
|
370 |
-
dni = hourly_data.get("direct_normal_radiation",
|
371 |
dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3)
|
372 |
outdoor_temp = hourly_data["dry_bulb"]
|
373 |
|
374 |
if ghi < 0 or dni < 0 or dhi < 0:
|
375 |
-
logger.error(f"Negative radiation values for {month}/{day}/{hour} for {component_name}
|
376 |
raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
|
377 |
|
378 |
logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
|
379 |
-
f"dry_bulb={outdoor_temp} for {component_name}
|
380 |
|
381 |
year = 2025
|
382 |
n = TFMCalculations.day_of_year(month, day, year)
|
@@ -404,7 +318,7 @@ class TFMCalculations:
|
|
404 |
azimuth = 360 - azimuth if azimuth > 0 else -azimuth
|
405 |
|
406 |
logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
|
407 |
-
f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f} for {component_name}
|
408 |
|
409 |
building_info = {"orientation_angle": building_orientation}
|
410 |
try:
|
@@ -446,7 +360,7 @@ class TFMCalculations:
|
|
446 |
|
447 |
cos_theta = max(min(cos_theta, 1.0), 0.0)
|
448 |
|
449 |
-
logger.info(f" Component {component_name}
|
450 |
f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
|
451 |
f"cos_theta={cos_theta:.4f}")
|
452 |
|
@@ -473,7 +387,7 @@ class TFMCalculations:
|
|
473 |
shgc = getattr(glazing_material_obj, 'shgc', 0.7)
|
474 |
h_o = getattr(glazing_material_obj, 'h_o', h_o)
|
475 |
else:
|
476 |
-
logger.warning(f"Glazing material not found for {component_name}
|
477 |
|
478 |
glazing_type = "Single Clear"
|
479 |
if hasattr(component, 'name') and component.name in TFMCalculations.GLAZING_TYPE_MAPPING:
|
@@ -485,7 +399,7 @@ class TFMCalculations:
|
|
485 |
|
486 |
solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000
|
487 |
|
488 |
-
logger.info(f"Fenestration solar heat gain for {component_name}
|
489 |
f"{solar_heat_gain:.4f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.4f}, "
|
490 |
f"I_t={I_t:.2f}, iac={iac})")
|
491 |
|
@@ -494,97 +408,203 @@ class TFMCalculations:
|
|
494 |
|
495 |
solar_heat_gain = component.area * absorptivity * I_t * surface_resistance / 1000
|
496 |
|
497 |
-
logger.info(f"Opaque surface solar heat gain for {component_name}
|
498 |
f"{solar_heat_gain:.4f} kW (area={component.area}, absorptivity={absorptivity:.2f}, "
|
499 |
f"I_t={I_t:.2f}, surface_resistance={surface_resistance:.4f})")
|
500 |
|
501 |
return solar_heat_gain
|
502 |
|
503 |
except Exception as e:
|
504 |
-
logger.error(f"Error calculating solar load for {component_name}
|
505 |
return 0
|
506 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
507 |
@staticmethod
|
508 |
def calculate_internal_load(internal_loads: Dict, hour: int, operation_hours: int, area: float) -> float:
|
509 |
-
"""Calculate total internal load in kW."""
|
510 |
-
total_load = 0
|
511 |
-
|
512 |
-
|
513 |
-
|
514 |
-
|
515 |
-
|
516 |
-
|
517 |
-
|
518 |
-
|
519 |
-
|
520 |
-
|
521 |
-
|
522 |
-
|
523 |
-
|
524 |
-
|
525 |
-
|
526 |
-
|
527 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
528 |
|
529 |
@staticmethod
|
530 |
def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
|
531 |
"""Calculate ventilation load for heating and cooling in kW based on mode."""
|
532 |
if mode == "none":
|
533 |
return 0, 0
|
534 |
-
ventilation = internal_loads.get("ventilation", {})
|
535 |
-
if not ventilation:
|
536 |
-
return 0, 0
|
537 |
-
space_rate = ventilation.get("space_rate", 0.3)
|
538 |
-
people_rate = ventilation.get("people_rate", 2.5)
|
539 |
-
num_people = sum(group["num_people"] for group in internal_loads.get("people", []))
|
540 |
-
ventilation_flow = (space_rate * area + people_rate * num_people) / 1000
|
541 |
-
air_density = 1.2
|
542 |
-
specific_heat = 1000
|
543 |
delta_t = outdoor_temp - indoor_temp
|
544 |
if mode == "cooling" and delta_t <= 0:
|
545 |
return 0, 0
|
546 |
if mode == "heating" and delta_t >= 0:
|
547 |
return 0, 0
|
548 |
-
|
549 |
-
|
550 |
-
|
551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
552 |
|
553 |
@staticmethod
|
554 |
def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
|
555 |
"""Calculate infiltration load for heating and cooling in kW based on mode."""
|
556 |
if mode == "none":
|
557 |
return 0, 0
|
558 |
-
infiltration = internal_loads.get("infiltration", {})
|
559 |
-
if not infiltration:
|
560 |
-
return 0, 0
|
561 |
-
method = infiltration.get("method", "ACH")
|
562 |
-
settings = infiltration.get("settings", {})
|
563 |
-
building_height = building_info.get("building_height", 3.0)
|
564 |
-
volume = area * building_height
|
565 |
-
air_density = 1.2
|
566 |
-
specific_heat = 1000
|
567 |
delta_t = outdoor_temp - indoor_temp
|
568 |
if mode == "cooling" and delta_t <= 0:
|
569 |
return 0, 0
|
570 |
if mode == "heating" and delta_t >= 0:
|
571 |
return 0, 0
|
572 |
-
|
573 |
-
|
574 |
-
|
575 |
-
|
576 |
-
|
577 |
-
|
578 |
-
|
579 |
-
|
580 |
-
|
581 |
-
|
582 |
-
|
583 |
-
|
584 |
-
|
585 |
-
|
586 |
-
|
587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
588 |
|
589 |
@staticmethod
|
590 |
def get_adaptive_comfort_temp(outdoor_temp: float) -> float:
|
|
|
11 |
from enum import Enum
|
12 |
import streamlit as st
|
13 |
from app.materials_library import GlazingMaterial, Material, MaterialLibrary
|
14 |
+
from app.internal_loads import PEOPLE_ACTIVITY_LEVELS, LIGHTING_FIXTURE_TYPES, DEFAULT_BUILDING_INTERNALS
|
15 |
from datetime import datetime
|
16 |
from collections import defaultdict
|
17 |
import logging
|
|
|
64 |
ctf = CTFCalculator.calculate_ctf_coefficients(component)
|
65 |
|
66 |
# Initialize history terms (simplified: assume steady-state history for demonstration)
|
|
|
67 |
load = component.u_value * component.area * delta_t
|
68 |
for i in range(len(ctf.Y)):
|
69 |
load += component.area * ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
70 |
load -= component.area * ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
|
|
71 |
cooling_load = load / 1000 if mode == "cooling" else 0
|
72 |
heating_load = -load / 1000 if mode == "heating" else 0
|
73 |
return cooling_load, heating_load
|
74 |
|
75 |
@staticmethod
|
76 |
def day_of_year(month: int, day: int, year: int) -> int:
|
77 |
+
"""Calculate day of the year (n) from month, day, and year, accounting for leap years."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
78 |
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
79 |
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
80 |
days_in_month[1] = 29
|
|
|
82 |
|
83 |
@staticmethod
|
84 |
def equation_of_time(n: int) -> float:
|
85 |
+
"""Calculate Equation of Time (EOT) in minutes using Spencer's formula."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
86 |
B = (n - 1) * 360 / 365
|
87 |
B_rad = math.radians(B)
|
88 |
EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
|
|
|
91 |
|
92 |
@staticmethod
|
93 |
def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
|
94 |
+
"""Calculate dynamic SHGC based on incidence angle."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
95 |
if glazing_type not in TFMCalculations.SHGC_COEFFICIENTS:
|
96 |
logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
|
97 |
glazing_type = "Single Clear"
|
98 |
|
99 |
c = TFMCalculations.SHGC_COEFFICIENTS[glazing_type]
|
|
|
100 |
f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
|
101 |
c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
|
102 |
return f_cos_theta
|
|
|
105 |
def get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary,
|
106 |
project_materials: Dict, project_constructions: Dict,
|
107 |
project_glazing_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
|
108 |
+
"""Determine surface parameters (tilt, azimuth, h_o, emissivity, absorptivity) for a component."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
109 |
component_name = getattr(component, 'name', 'unnamed_component')
|
110 |
|
111 |
+
surface_tilt = 90.0
|
112 |
+
surface_azimuth = 0.0
|
113 |
+
h_o = 17.0
|
114 |
+
emissivity = 0.9
|
115 |
+
absorptivity = 0.6
|
|
|
116 |
|
117 |
try:
|
|
|
118 |
if component.component_type == ComponentType.ROOF:
|
119 |
+
surface_tilt = getattr(component, 'tilt', 0.0)
|
120 |
+
h_o = 23.0
|
121 |
surface_azimuth = getattr(component, 'orientation', 0.0)
|
122 |
logger.debug(f"Roof component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
|
123 |
|
124 |
elif component.component_type == ComponentType.SKYLIGHT:
|
125 |
+
surface_tilt = getattr(component, 'tilt', 0.0)
|
126 |
+
h_o = 23.0
|
127 |
surface_azimuth = getattr(component, 'orientation', 0.0)
|
128 |
logger.debug(f"Skylight component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
|
129 |
|
130 |
elif component.component_type == ComponentType.FLOOR:
|
131 |
+
surface_tilt = 180.0
|
132 |
+
h_o = 17.0
|
133 |
+
surface_azimuth = 0.0
|
134 |
logger.debug(f"Floor component {component_name}: using default azimuth={surface_azimuth}, tilt={surface_tilt}")
|
135 |
|
136 |
else: # WALL, WINDOW
|
137 |
+
surface_tilt = 90.0
|
138 |
+
h_o = 17.0
|
139 |
elevation = getattr(component, 'elevation', None)
|
140 |
if not elevation:
|
141 |
logger.warning(f"Component {component_name} ({component.component_type.value}) is missing 'elevation' field. Using default azimuth=0.")
|
|
|
148 |
"C": (base_azimuth + 180.0) % 360,
|
149 |
"D": (base_azimuth + 270.0) % 360
|
150 |
}
|
|
|
151 |
if elevation not in elevation_angles:
|
152 |
+
logger.warning(f"Invalid elevation '{elevation}' for component {component_name}. Using default azimuth=0.")
|
|
|
153 |
surface_azimuth = 0.0
|
154 |
else:
|
155 |
surface_azimuth = (elevation_angles[elevation] + getattr(component, 'rotation', 0.0)) % 360
|
156 |
+
logger.debug(f"Component {component_name}: elevation={elevation}, base_azimuth={elevation_angles[elevation]}, rotation={getattr(component, 'rotation', 0.0)}, total_azimuth={surface_azimuth}")
|
|
|
|
|
157 |
|
|
|
158 |
if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
|
159 |
construction = getattr(component, 'construction', None)
|
160 |
if not construction:
|
161 |
+
logger.warning(f"No construction defined for {component_name}. Using defaults: absorptivity=0.6, emissivity=0.9.")
|
|
|
162 |
else:
|
163 |
construction_obj = None
|
164 |
if hasattr(construction, 'name'):
|
|
|
166 |
material_library.library_constructions.get(construction.name))
|
167 |
|
168 |
if not construction_obj:
|
169 |
+
logger.warning(f"Construction not found for {component_name}. Using defaults: absorptivity=0.6, emissivity=0.9.")
|
|
|
170 |
elif not construction_obj.layers:
|
171 |
+
logger.warning(f"No layers in construction for {component_name}. Using defaults: absorptivity=0.6, emissivity=0.9.")
|
|
|
172 |
else:
|
173 |
first_layer = construction_obj.layers[0]
|
174 |
material = first_layer.get("material")
|
175 |
if material:
|
176 |
absorptivity = getattr(material, 'absorptivity', 0.6)
|
177 |
emissivity = getattr(material, 'emissivity', 0.9)
|
178 |
+
logger.debug(f"Using first layer material for {component_name}: absorptivity={absorptivity}, emissivity={emissivity}")
|
|
|
179 |
|
180 |
elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
181 |
glazing_material = getattr(component, 'glazing_material', None)
|
182 |
if not glazing_material:
|
183 |
+
logger.warning(f"No glazing material defined for {component_name}. Using default SHGC=0.7, h_o={h_o}.")
|
|
|
184 |
shgc = 0.7
|
185 |
else:
|
186 |
glazing_material_obj = None
|
|
|
189 |
material_library.library_glazing_materials.get(glazing_material.name))
|
190 |
|
191 |
if not glazing_material_obj:
|
192 |
+
logger.warning(f"Glazing material not found for {component_name}. Using default SHGC=0.7, h_o={h_o}.")
|
|
|
193 |
shgc = 0.7
|
194 |
else:
|
195 |
shgc = getattr(glazing_material_obj, 'shgc', 0.7)
|
196 |
h_o = getattr(glazing_material_obj, 'h_o', h_o)
|
197 |
+
logger.debug(f"Using glazing material for {component_name}: shgc={shgc}, h_o={h_o}")
|
|
|
198 |
emissivity = None
|
199 |
|
200 |
except Exception as e:
|
201 |
+
logger.error(f"Error retrieving surface parameters for {component_name}: {str(e)}")
|
202 |
if component.component_type == ComponentType.ROOF:
|
203 |
surface_tilt = 0.0
|
204 |
h_o = 23.0
|
|
|
223 |
shgc = 0.7
|
224 |
emissivity = None
|
225 |
|
226 |
+
logger.info(f"Final surface parameters for {component_name}: tilt={surface_tilt:.1f}, azimuth={surface_azimuth:.1f}, h_o={h_o:.1f}")
|
|
|
227 |
return surface_tilt, surface_azimuth, h_o, emissivity, absorptivity
|
228 |
|
229 |
@staticmethod
|
230 |
def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
|
231 |
+
"""Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
232 |
if mode != "cooling":
|
233 |
return 0
|
234 |
|
|
|
243 |
from app.material_library import MaterialLibrary
|
244 |
material_library = MaterialLibrary()
|
245 |
st.session_state.material_library = material_library
|
246 |
+
logger.info(f"Created new MaterialLibrary for {component_name}")
|
247 |
+
|
248 |
project_materials = st.session_state.get("project_data", {}).get("materials", {}).get("project", {})
|
249 |
project_constructions = st.session_state.get("project_data", {}).get("constructions", {}).get("project", {})
|
250 |
project_glazing_materials = st.session_state.get("project_data", {}).get("fenestrations", {}).get("project", {})
|
|
|
252 |
latitude = climate_data.get("latitude", 0.0)
|
253 |
longitude = climate_data.get("longitude", 0.0)
|
254 |
timezone = climate_data.get("time_zone", 0.0)
|
255 |
+
ground_reflectivity = climate_data.get("ground_reflectivity", 0.2)
|
256 |
|
257 |
if not -90 <= latitude <= 90:
|
258 |
+
logger.warning(f"Invalid latitude {latitude} for {component_name}. Using default 0.0.")
|
259 |
latitude = 0.0
|
260 |
if not -180 <= longitude <= 180:
|
261 |
+
logger.warning(f"Invalid longitude {longitude} for {component_name}. Using default 0.0.")
|
262 |
longitude = 0.0
|
263 |
if not -12 <= timezone <= 14:
|
264 |
+
logger.warning(f"Invalid timezone {timezone} for {component_name}. Using default 0.0.")
|
265 |
timezone = 0.0
|
266 |
if not 0 <= ground_reflectivity <= 1:
|
267 |
+
logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name}. Using default 0.2.")
|
268 |
ground_reflectivity = 0.2
|
269 |
|
270 |
required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
|
271 |
"diffuse_horizontal_radiation", "dry_bulb"]
|
272 |
if not all(field in hourly_data for field in required_fields):
|
273 |
+
logger.warning(f"Missing required fields in hourly_data for hour {hour} for {component_name}: {hourly_data}")
|
274 |
return 0
|
275 |
|
276 |
if hourly_data["global_horizontal_radiation"] <= 0:
|
277 |
+
logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']} for {component_name}")
|
278 |
return 0
|
279 |
|
280 |
month = hourly_data["month"]
|
281 |
day = hourly_data["day"]
|
282 |
hour = hourly_data["hour"]
|
283 |
ghi = hourly_data["global_horizontal_radiation"]
|
284 |
+
dni = hourly_data.get("direct_normal_radiation", echi * 0.7)
|
285 |
dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3)
|
286 |
outdoor_temp = hourly_data["dry_bulb"]
|
287 |
|
288 |
if ghi < 0 or dni < 0 or dhi < 0:
|
289 |
+
logger.error(f"Negative radiation values for {month}/{day}/{hour} for {component_name}")
|
290 |
raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
|
291 |
|
292 |
logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
|
293 |
+
f"dry_bulb={outdoor_temp} for {component_name}")
|
294 |
|
295 |
year = 2025
|
296 |
n = TFMCalculations.day_of_year(month, day, year)
|
|
|
318 |
azimuth = 360 - azimuth if azimuth > 0 else -azimuth
|
319 |
|
320 |
logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
|
321 |
+
f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f} for {component_name}")
|
322 |
|
323 |
building_info = {"orientation_angle": building_orientation}
|
324 |
try:
|
|
|
360 |
|
361 |
cos_theta = max(min(cos_theta, 1.0), 0.0)
|
362 |
|
363 |
+
logger.info(f" Component {component_name} at {month}/{day}/{hour}: "
|
364 |
f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
|
365 |
f"cos_theta={cos_theta:.4f}")
|
366 |
|
|
|
387 |
shgc = getattr(glazing_material_obj, 'shgc', 0.7)
|
388 |
h_o = getattr(glazing_material_obj, 'h_o', h_o)
|
389 |
else:
|
390 |
+
logger.warning(f"Glazing material not found for {component_name}. Using default SHGC=0.7.")
|
391 |
|
392 |
glazing_type = "Single Clear"
|
393 |
if hasattr(component, 'name') and component.name in TFMCalculations.GLAZING_TYPE_MAPPING:
|
|
|
399 |
|
400 |
solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000
|
401 |
|
402 |
+
logger.info(f"Fenestration solar heat gain for {component_name} at {month}/{day}/{hour}: "
|
403 |
f"{solar_heat_gain:.4f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.4f}, "
|
404 |
f"I_t={I_t:.2f}, iac={iac})")
|
405 |
|
|
|
408 |
|
409 |
solar_heat_gain = component.area * absorptivity * I_t * surface_resistance / 1000
|
410 |
|
411 |
+
logger.info(f"Opaque surface solar heat gain for {component_name} at {month}/{day}/{hour}: "
|
412 |
f"{solar_heat_gain:.4f} kW (area={component.area}, absorptivity={absorptivity:.2f}, "
|
413 |
f"I_t={I_t:.2f}, surface_resistance={surface_resistance:.4f})")
|
414 |
|
415 |
return solar_heat_gain
|
416 |
|
417 |
except Exception as e:
|
418 |
+
logger.error(f"Error calculating solar load for {component_name} at hour {hour}: {str(e)}")
|
419 |
return 0
|
420 |
|
421 |
+
@staticmethod
|
422 |
+
def get_schedule_fraction(schedule_name: str, hour: int, is_weekend: bool) -> float:
|
423 |
+
"""Get the schedule fraction for the given hour and day type."""
|
424 |
+
schedules = st.session_state.project_data["internal_loads"].get("schedules", {})
|
425 |
+
schedule = schedules.get(schedule_name, {})
|
426 |
+
if not schedule:
|
427 |
+
logger.warning(f"Schedule '{schedule_name}' not found. Using fraction=1.0.")
|
428 |
+
return 1.0
|
429 |
+
values = schedule.get("weekend" if is_weekend else "weekday", [1.0] * 24)
|
430 |
+
hour_idx = hour % 24
|
431 |
+
if 0 <= hour_idx < len(values):
|
432 |
+
fraction = values[hour_idx]
|
433 |
+
logger.debug(f"Schedule '{schedule_name}' at hour {hour_idx} ({'weekend' if is_weekend else 'weekday'}): fraction={fraction:.2f}")
|
434 |
+
return fraction
|
435 |
+
logger.warning(f"Invalid hour index {hour_idx} for schedule '{schedule_name}'. Using fraction=1.0.")
|
436 |
+
return 1.0
|
437 |
+
|
438 |
@staticmethod
|
439 |
def calculate_internal_load(internal_loads: Dict, hour: int, operation_hours: int, area: float) -> float:
|
440 |
+
"""Calculate total internal load in kW, incorporating schedules."""
|
441 |
+
total_load = 0.0
|
442 |
+
is_weekend = False # Simplified; in practice, determine from date
|
443 |
+
try:
|
444 |
+
# People loads
|
445 |
+
for group in internal_loads.get("people", []):
|
446 |
+
schedule_name = group.get("schedule", "Continuous")
|
447 |
+
fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
|
448 |
+
sensible = group.get("total_sensible_heat", 0.0)
|
449 |
+
latent = group.get("total_latent_heat", 0.0)
|
450 |
+
group_load = (sensible + latent) * fraction / 1000 # Convert W to kW
|
451 |
+
total_load += group_load
|
452 |
+
logger.debug(f"People group '{group.get('name', 'unknown')}': sensible={sensible:.2f} W, latent={latent:.2f} W, fraction={fraction:.2f}, load={group_load:.3f} kW")
|
453 |
+
|
454 |
+
# Lighting loads
|
455 |
+
for light in internal_loads.get("lighting", []):
|
456 |
+
schedule_name = light.get("schedule", "Continuous")
|
457 |
+
fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
|
458 |
+
total_power = light.get("total_power", 0.0)
|
459 |
+
lighting_load = total_power * fraction / 1000 # Convert W to kW
|
460 |
+
total_load += lighting_load
|
461 |
+
logger.debug(f"Lighting system '{light.get('name', 'unknown')}': total_power={total_power:.2f} W, fraction={fraction:.2f}, load={lighting_load:.3f} kW")
|
462 |
+
|
463 |
+
# Equipment loads
|
464 |
+
for equip in internal_loads.get("equipment", []):
|
465 |
+
schedule_name = equip.get("schedule", "Continuous")
|
466 |
+
fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
|
467 |
+
sensible = equip.get("total_sensible_power", 0.0)
|
468 |
+
latent = equip.get("total_latent_power", 0.0)
|
469 |
+
equip_load = (sensible + latent) * fraction / 1000 # Convert W to kW
|
470 |
+
total_load += equip_load
|
471 |
+
logger.debug(f"Equipment '{equip.get('name', 'unknown')}': sensible={sensible:.2f} W, latent={latent:.2f} W, fraction={fraction:.2f}, load={equip_load:.3f} kW")
|
472 |
+
|
473 |
+
logger.info(f"Total internal load for hour {hour}: {total_load:.3f} kW")
|
474 |
+
return total_load
|
475 |
+
|
476 |
+
except Exception as e:
|
477 |
+
logger.error(f"Error calculating internal load for hour {hour}: {str(e)}")
|
478 |
+
return 0.0
|
479 |
|
480 |
@staticmethod
|
481 |
def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
|
482 |
"""Calculate ventilation load for heating and cooling in kW based on mode."""
|
483 |
if mode == "none":
|
484 |
return 0, 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
485 |
delta_t = outdoor_temp - indoor_temp
|
486 |
if mode == "cooling" and delta_t <= 0:
|
487 |
return 0, 0
|
488 |
if mode == "heating" and delta_t >= 0:
|
489 |
return 0, 0
|
490 |
+
|
491 |
+
total_cooling_load = 0.0
|
492 |
+
total_heating_load = 0.0
|
493 |
+
air_density = 1.2 # kg/m³
|
494 |
+
specific_heat = 1000 # J/kg·K
|
495 |
+
is_weekend = False # Simplified; determine from date in practice
|
496 |
+
|
497 |
+
try:
|
498 |
+
for system in internal_loads.get("ventilation", []):
|
499 |
+
schedule_name = system.get("schedule", "Continuous")
|
500 |
+
fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
|
501 |
+
system_type = system.get("system_type", "AirChanges/Hour")
|
502 |
+
system_area = system.get("area", area)
|
503 |
+
ventilation_flow = 0.0
|
504 |
+
|
505 |
+
if system_type == "AirChanges/Hour":
|
506 |
+
design_flow_rate = system.get("design_flow_rate", 1.0) # L/s·m²
|
507 |
+
ventilation_flow = design_flow_rate * system_area / 1000 # Convert L/s to m³/s
|
508 |
+
if system.get("ventilation_type", "Natural") == "Mechanical":
|
509 |
+
fan_power = (system.get("fan_pressure_rise", 200.0) * ventilation_flow) / system.get("fan_efficiency", 0.7) / 1000 # kW
|
510 |
+
total_cooling_load += fan_power * fraction
|
511 |
+
logger.debug(f"Ventilation '{system.get('name', 'unknown')}': fan_power={fan_power:.3f} kW, fraction={fraction:.2f}")
|
512 |
+
|
513 |
+
elif system_type == "Wind and Stack Open Area":
|
514 |
+
opening_effectiveness = system.get("opening_effectiveness", 50.0) / 100
|
515 |
+
# Assume simplified flow based on area and effectiveness
|
516 |
+
ventilation_flow = 0.001 * system_area * opening_effectiveness # m³/s (placeholder)
|
517 |
+
|
518 |
+
elif system_type in ["Balanced Flow", "Heat Recovery"]:
|
519 |
+
design_flow_rate = system.get("design_flow_rate", 1.0) # L/s·m²
|
520 |
+
ventilation_flow = design_flow_rate * system_area / 1000 # Convert L/s to m³/s
|
521 |
+
fan_power = (system.get("fan_pressure_rise", 200.0) * ventilation_flow) / system.get("fan_efficiency", 0.7) / 1000 # kW
|
522 |
+
total_cooling_load += fan_power * fraction
|
523 |
+
if system_type == "Heat Recovery":
|
524 |
+
sensible_eff = system.get("sensible_effectiveness", 0.5)
|
525 |
+
delta_t = delta_t * (1 - sensible_eff)
|
526 |
+
logger.debug(f"Heat Recovery '{system.get('name', 'unknown')}': sensible_eff={sensible_eff:.2f}, adjusted_delta_t={delta_t:.2f}")
|
527 |
+
|
528 |
+
load = ventilation_flow * air_density * specific_heat * delta_t * fraction / 1000 # kW
|
529 |
+
cooling_load = load if mode == "cooling" else 0
|
530 |
+
heating_load = -load if mode == "heating" else 0
|
531 |
+
total_cooling_load += cooling_load
|
532 |
+
total_heating_load += heating_load
|
533 |
+
logger.debug(f"Ventilation '{system.get('name', 'unknown')}': flow={ventilation_flow:.4f} m³/s, fraction={fraction:.2f}, cooling={cooling_load:.3f} kW, heating={heating_load:.3f} kW")
|
534 |
+
|
535 |
+
logger.info(f"Total ventilation load for hour {hour}: cooling={total_cooling_load:.3f} kW, heating={total_heating_load:.3f} kW")
|
536 |
+
return total_cooling_load, total_heating_load
|
537 |
+
|
538 |
+
except Exception as e:
|
539 |
+
logger.error(f"Error calculating ventilation load for hour {hour}: {str(e)}")
|
540 |
+
return 0, 0
|
541 |
|
542 |
@staticmethod
|
543 |
def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
|
544 |
"""Calculate infiltration load for heating and cooling in kW based on mode."""
|
545 |
if mode == "none":
|
546 |
return 0, 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
547 |
delta_t = outdoor_temp - indoor_temp
|
548 |
if mode == "cooling" and delta_t <= 0:
|
549 |
return 0, 0
|
550 |
if mode == "heating" and delta_t >= 0:
|
551 |
return 0, 0
|
552 |
+
|
553 |
+
total_cooling_load = 0.0
|
554 |
+
total_heating_load = 0.0
|
555 |
+
air_density = 1.2 # kg/m³
|
556 |
+
specific_heat = 1000 # J/kg·K
|
557 |
+
building_height = building_info.get("building_height", 3.0)
|
558 |
+
volume = area * building_height
|
559 |
+
is_weekend = False # Simplified; determine from date in practice
|
560 |
+
|
561 |
+
try:
|
562 |
+
for system in internal_loads.get("infiltration", []):
|
563 |
+
schedule_name = system.get("schedule", "Continuous")
|
564 |
+
fraction = TFMCalculations.get_schedule_fraction(schedule_name, hour, is_weekend)
|
565 |
+
system_type = system.get("system_type", "AirChanges/Hour")
|
566 |
+
system_area = system.get("area", area)
|
567 |
+
infiltration_flow = 0.0
|
568 |
+
|
569 |
+
if system_type == "AirChanges/Hour":
|
570 |
+
ach = system.get("design_flow_rate", 0.3)
|
571 |
+
infiltration_flow = ach * system_area * building_height / 3600 # m³/s
|
572 |
+
|
573 |
+
elif system_type == "Effective Leakage Area":
|
574 |
+
ela = system.get("effective_air_leakage_area", 100.0) / 10000 # Convert cm² to m²
|
575 |
+
stack_coeff = system.get("stack_coefficient", 0.0001)
|
576 |
+
wind_coeff = system.get("wind_coefficient", 0.0001)
|
577 |
+
delta_t_abs = abs(delta_t)
|
578 |
+
wind_speed = 4.0 # m/s, assumed
|
579 |
+
Q_stack = stack_coeff * ela * (delta_t_abs ** 0.5)
|
580 |
+
Q_wind = wind_coeff * ela * (wind_speed ** 2)
|
581 |
+
infiltration_flow = (Q_stack ** 2 + Q_wind ** 2) ** 0.5 # m³/s
|
582 |
+
|
583 |
+
elif system_type == "Flow Coefficient":
|
584 |
+
c = system.get("flow_coefficient", 0.0001) # m³/s·Paⁿ
|
585 |
+
n = system.get("pressure_exponent", 0.6)
|
586 |
+
stack_coeff = system.get("stack_coefficient", 0.0001)
|
587 |
+
wind_coeff = system.get("wind_coefficient", 0.0001)
|
588 |
+
delta_t_abs = abs(delta_t)
|
589 |
+
wind_speed = 4.0 # m/s, assumed
|
590 |
+
delta_p_stack = stack_coeff * delta_t_abs
|
591 |
+
delta_p_wind = wind_coeff * (wind_speed ** 2)
|
592 |
+
delta_p = (delta_p_stack ** 2 + delta_p_wind ** 2) ** 0.5
|
593 |
+
infiltration_flow = c * (delta_p ** n) * system_area
|
594 |
+
|
595 |
+
load = infiltration_flow * air_density * specific_heat * delta_t * fraction / 1000 # kW
|
596 |
+
cooling_load = load if mode == "cooling" else 0
|
597 |
+
heating_load = -load if mode == "heating" else 0
|
598 |
+
total_cooling_load += cooling_load
|
599 |
+
total_heating_load += heating_load
|
600 |
+
logger.debug(f"Infiltration '{system.get('name', 'unknown')}': flow={infiltration_flow:.4f} m³/s, fraction={fraction:.2f}, cooling={cooling_load:.3f} kW, heating={heating_load:.3f} kW")
|
601 |
+
|
602 |
+
logger.info(f"Total infiltration load for hour {hour}: cooling={total_cooling_load:.3f} kW, heating={total_heating_load:.3f} kW")
|
603 |
+
return total_cooling_load, total_heating_load
|
604 |
+
|
605 |
+
except Exception as e:
|
606 |
+
logger.error(f"Error calculating infiltration load for hour {hour}: {str(e)}")
|
607 |
+
return 0, 0
|
608 |
|
609 |
@staticmethod
|
610 |
def get_adaptive_comfort_temp(outdoor_temp: float) -> float:
|