Spaces:
Sleeping
Sleeping
Update app/hvac_loads.py
Browse files- app/hvac_loads.py +301 -456
app/hvac_loads.py
CHANGED
@@ -14,13 +14,12 @@ import pandas as pd
|
|
14 |
from typing import Dict, List, Optional, NamedTuple, Any, Tuple
|
15 |
from enum import Enum
|
16 |
import streamlit as st
|
17 |
-
from data.material_library import Construction, GlazingMaterial, DoorMaterial, Material, MaterialLibrary
|
18 |
-
from data.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS
|
19 |
from datetime import datetime
|
20 |
from collections import defaultdict
|
21 |
import logging
|
22 |
import math
|
23 |
from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
|
|
|
24 |
|
25 |
# Configure logging
|
26 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
@@ -54,7 +53,7 @@ class TFMCalculations:
|
|
54 |
}
|
55 |
|
56 |
@staticmethod
|
57 |
-
def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") ->
|
58 |
"""Calculate conduction load for heating and cooling in kW based on mode."""
|
59 |
if mode == "none":
|
60 |
return 0, 0
|
@@ -65,34 +64,21 @@ class TFMCalculations:
|
|
65 |
return 0, 0
|
66 |
|
67 |
# Get CTF coefficients using CTFCalculator
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
#
|
72 |
load = component.u_value * component.area * delta_t
|
73 |
-
for i in range(len(ctf.Y)):
|
74 |
-
load += component.area * ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
75 |
-
load -= component.area * ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
76 |
-
# Note: F terms require flux history, omitted here for simplicity
|
77 |
cooling_load = load / 1000 if mode == "cooling" else 0
|
78 |
heating_load = -load / 1000 if mode == "heating" else 0
|
79 |
return cooling_load, heating_load
|
80 |
|
81 |
@staticmethod
|
82 |
def day_of_year(month: int, day: int, year: int) -> int:
|
83 |
-
"""Calculate day of the year (n) from month, day, and year, accounting for leap years.
|
84 |
-
|
85 |
-
Args:
|
86 |
-
month (int): Month of the year (1-12).
|
87 |
-
day (int): Day of the month (1-31).
|
88 |
-
year (int): Year.
|
89 |
-
|
90 |
-
Returns:
|
91 |
-
int: Day of the year (1-365 or 366 for leap years).
|
92 |
-
|
93 |
-
References:
|
94 |
-
ASHRAE Handbook—Fundamentals, Chapter 18.
|
95 |
-
"""
|
96 |
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
97 |
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
98 |
days_in_month[1] = 29
|
@@ -100,17 +86,7 @@ class TFMCalculations:
|
|
100 |
|
101 |
@staticmethod
|
102 |
def equation_of_time(n: int) -> float:
|
103 |
-
"""Calculate Equation of Time (EOT) in minutes using Spencer's formula.
|
104 |
-
|
105 |
-
Args:
|
106 |
-
n (int): Day of the year (1-365 or 366).
|
107 |
-
|
108 |
-
Returns:
|
109 |
-
float: Equation of Time in minutes.
|
110 |
-
|
111 |
-
References:
|
112 |
-
ASHRAE Handbook—Fundamentals, Chapter 18.
|
113 |
-
"""
|
114 |
B = (n - 1) * 360 / 365
|
115 |
B_rad = math.radians(B)
|
116 |
EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
|
@@ -119,325 +95,195 @@ class TFMCalculations:
|
|
119 |
|
120 |
@staticmethod
|
121 |
def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
|
122 |
-
"""Calculate dynamic SHGC based on incidence angle.
|
123 |
-
|
124 |
-
Args:
|
125 |
-
glazing_type (str): Type of glazing (e.g., 'Single Clear').
|
126 |
-
cos_theta (float): Cosine of the angle of incidence.
|
127 |
-
|
128 |
-
Returns:
|
129 |
-
float: Dynamic SHGC value.
|
130 |
-
|
131 |
-
References:
|
132 |
-
ASHRAE Handbook—Fundamentals, Chapter 15, Table 13.
|
133 |
-
"""
|
134 |
if glazing_type not in TFMCalculations.SHGC_COEFFICIENTS:
|
135 |
logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
|
136 |
glazing_type = "Single Clear"
|
137 |
|
138 |
c = TFMCalculations.SHGC_COEFFICIENTS[glazing_type]
|
139 |
-
# Incidence angle modifier: f(cos(θ)) = c_0 + c_1·cos(θ) + c_2·cos²(θ) + c_3·cos³(θ) + c_4·cos⁴(θ) + c_5·cos⁵(θ)
|
140 |
f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
|
141 |
c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
|
142 |
-
return f_cos_theta
|
143 |
|
144 |
@staticmethod
|
145 |
-
def get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary,
|
146 |
-
project_materials: Dict, project_constructions: Dict,
|
147 |
project_glazing_materials: Dict, project_door_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
|
148 |
"""
|
149 |
-
Determine surface parameters
|
150 |
-
Uses MaterialLibrary to fetch properties from first layer for walls/roofs, DoorMaterial for doors,
|
151 |
-
and GlazingMaterial for windows/skylights. Handles orientation and tilt based on component type:
|
152 |
-
- Walls, Doors, Windows: Azimuth = elevation base azimuth + component.rotation; Tilt = 90°.
|
153 |
-
- Roofs, Skylights: Azimuth = component.orientation; Tilt = component.tilt (default 180°).
|
154 |
-
|
155 |
-
Args:
|
156 |
-
component: Component object with component_type, elevation, rotation, orientation, tilt,
|
157 |
-
construction, glazing_material, or door_material.
|
158 |
-
building_info (Dict): Building information containing orientation_angle for elevation mapping.
|
159 |
-
material_library: MaterialLibrary instance for accessing library materials/constructions.
|
160 |
-
project_materials: Dict of project-specific Material objects.
|
161 |
-
project_constructions: Dict of project-specific Construction objects.
|
162 |
-
project_glazing_materials: Dict of project-specific GlazingMaterial objects.
|
163 |
-
project_door_materials: Dict of project-specific DoorMaterial objects.
|
164 |
-
|
165 |
-
Returns:
|
166 |
-
Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°),
|
167 |
-
h_o (W/m²·K), emissivity, solar_absorption.
|
168 |
"""
|
169 |
-
# Default parameters
|
170 |
component_name = getattr(component, 'name', 'unnamed_component')
|
171 |
|
172 |
-
#
|
173 |
-
surface_tilt =
|
174 |
-
surface_azimuth = 0.0
|
175 |
-
h_o = 17.0 # Default
|
176 |
emissivity = 0.9 # Default for opaque components
|
177 |
-
|
178 |
-
|
179 |
try:
|
180 |
-
#
|
181 |
if component.component_type == ComponentType.ROOF:
|
182 |
-
|
183 |
-
h_o = 23.0 # W/m²·K for roofs
|
184 |
-
# For roofs, use orientation directly
|
185 |
-
surface_azimuth = getattr(component, 'orientation', 0.0)
|
186 |
-
logger.debug(f"Roof component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
|
187 |
-
|
188 |
elif component.component_type == ComponentType.SKYLIGHT:
|
189 |
-
|
190 |
-
h_o = 23.0 # W/m²·K for skylights
|
191 |
-
# For skylights, use orientation directly, not elevation
|
192 |
-
surface_azimuth = getattr(component, 'orientation', 0.0)
|
193 |
-
logger.debug(f"Skylight component {component_name}: using orientation={surface_azimuth}, tilt={surface_tilt}")
|
194 |
-
|
195 |
elif component.component_type == ComponentType.FLOOR:
|
196 |
-
surface_tilt = 180.0
|
197 |
-
|
198 |
-
|
199 |
-
logger.debug(f"Floor component {component_name}: using default azimuth={surface_azimuth}, tilt={surface_tilt}")
|
200 |
-
|
201 |
-
else: # WALL, DOOR, WINDOW
|
202 |
-
surface_tilt = 90.0 # Vertical
|
203 |
-
h_o = 17.0 # W/m²·K
|
204 |
-
|
205 |
-
# Check for elevation attribute
|
206 |
-
elevation = getattr(component, 'elevation', None)
|
207 |
-
if not elevation:
|
208 |
-
logger.warning(f"Component {component_name} ({component.component_type.value}) is missing 'elevation' field. Using default azimuth=0.")
|
209 |
-
surface_azimuth = 0.0 # Default to north-facing if elevation is missing
|
210 |
-
else:
|
211 |
-
# Define elevation azimuths based on building orientation_angle
|
212 |
-
base_azimuth = building_info.get("orientation_angle", 0.0)
|
213 |
-
elevation_angles = {
|
214 |
-
"A": base_azimuth,
|
215 |
-
"B": (base_azimuth + 90.0) % 360,
|
216 |
-
"C": (base_azimuth + 180.0) % 360,
|
217 |
-
"D": (base_azimuth + 270.0) % 360
|
218 |
-
}
|
219 |
-
|
220 |
-
if elevation not in elevation_angles:
|
221 |
-
logger.warning(f"Invalid elevation '{elevation}' for component {component_name} ({component.component_type.value}). "
|
222 |
-
f"Expected one of {list(elevation_angles.keys())}. Using default azimuth=0.")
|
223 |
-
surface_azimuth = 0.0 # Default to north-facing if elevation is invalid
|
224 |
-
else:
|
225 |
-
# Add component rotation to elevation azimuth
|
226 |
-
surface_azimuth = (elevation_angles[elevation] + getattr(component, 'rotation', 0.0)) % 360
|
227 |
-
logger.debug(f"Component {component_name} ({component.component_type.value}): elevation={elevation}, "
|
228 |
-
f"base_azimuth={elevation_angles[elevation]}, rotation={getattr(component, 'rotation', 0.0)}, "
|
229 |
-
f"total_azimuth={surface_azimuth}, tilt={surface_tilt}")
|
230 |
|
231 |
# Fetch material properties
|
232 |
if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
|
233 |
-
|
234 |
-
if not
|
235 |
-
logger.warning(f"No construction defined for {component_name} ({component.component_type.value}). "
|
236 |
-
f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
|
237 |
else:
|
238 |
-
|
239 |
-
|
240 |
-
if hasattr(construction, 'name'):
|
241 |
-
construction_obj = (project_constructions.get(construction.name) or
|
242 |
-
material_library.library_constructions.get(construction.name))
|
243 |
-
|
244 |
if not construction_obj:
|
245 |
-
logger.warning(f"Construction not found for {component_name}
|
246 |
-
f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
|
247 |
elif not construction_obj.layers:
|
248 |
-
logger.warning(f"No layers in construction for {component_name}
|
249 |
-
f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
|
250 |
else:
|
251 |
-
# Use first (outermost) layer's properties
|
252 |
first_layer = construction_obj.layers[0]
|
253 |
-
material =
|
|
|
254 |
if material:
|
255 |
-
|
256 |
emissivity = getattr(material, 'emissivity', 0.9)
|
257 |
-
logger.debug(f"Using first layer material for {component_name}
|
258 |
-
f"
|
259 |
|
260 |
elif component.component_type == ComponentType.DOOR:
|
261 |
-
|
262 |
-
if not
|
263 |
-
logger.warning(f"No door material defined for {component_name}
|
264 |
-
f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
|
265 |
else:
|
266 |
-
|
267 |
-
|
268 |
-
if hasattr(door_material, 'name'):
|
269 |
-
door_material_obj = (project_door_materials.get(door_material.name) or
|
270 |
-
material_library.library_door_materials.get(door_material.name))
|
271 |
-
|
272 |
if not door_material_obj:
|
273 |
-
logger.warning(f"Door material not found for {component_name}
|
274 |
-
f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
|
275 |
else:
|
276 |
-
|
277 |
emissivity = getattr(door_material_obj, 'emissivity', 0.9)
|
278 |
-
logger.debug(f"Using door material for {component_name}
|
279 |
-
f"
|
280 |
|
281 |
elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
282 |
-
|
283 |
-
if not
|
284 |
-
logger.warning(f"No
|
285 |
-
f"Using default SHGC=0.7, h_o={h_o}.")
|
286 |
shgc = 0.7
|
287 |
else:
|
288 |
-
|
289 |
-
|
290 |
-
if hasattr(glazing_material, 'name'):
|
291 |
-
glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
|
292 |
-
material_library.library_glazing_materials.get(glazing_material.name))
|
293 |
-
|
294 |
if not glazing_material_obj:
|
295 |
-
logger.warning(f"
|
296 |
-
f"Using default SHGC=0.7, h_o={h_o}.")
|
297 |
shgc = 0.7
|
298 |
else:
|
299 |
shgc = getattr(glazing_material_obj, 'shgc', 0.7)
|
300 |
h_o = getattr(glazing_material_obj, 'h_o', h_o)
|
301 |
-
logger.debug(f"Using glazing material for {component_name}
|
302 |
-
|
303 |
-
emissivity = None # Not used for glazing
|
304 |
|
305 |
except Exception as e:
|
306 |
-
logger.error(f"Error retrieving surface parameters for {component_name}
|
307 |
-
# Apply defaults based on component type
|
308 |
if component.component_type == ComponentType.ROOF:
|
309 |
-
surface_tilt = 0.0
|
310 |
-
|
311 |
-
|
312 |
elif component.component_type == ComponentType.SKYLIGHT:
|
313 |
-
surface_tilt = 0.0
|
314 |
-
|
315 |
-
|
316 |
elif component.component_type == ComponentType.FLOOR:
|
317 |
-
surface_tilt = 180.0
|
318 |
-
|
319 |
-
|
320 |
-
else:
|
321 |
-
surface_tilt = 90.0
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
|
327 |
-
solar_absorption = 0.6
|
328 |
-
emissivity = 0.9
|
329 |
-
else: # WINDOW, SKYLIGHT
|
330 |
-
shgc = 0.7
|
331 |
-
emissivity = None
|
332 |
-
|
333 |
-
# Debug output for all components
|
334 |
-
logger.info(f"Final surface parameters for {component_name} ({component.component_type.value}): "
|
335 |
-
f"tilt={surface_tilt:.1f}, azimuth={surface_azimuth:.1f}, h_o={h_o:.1f}")
|
336 |
|
337 |
-
|
|
|
338 |
|
339 |
@staticmethod
|
340 |
-
def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
|
341 |
-
"""Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations.
|
342 |
-
|
343 |
-
Args:
|
344 |
-
component: Component object with area, component_type, elevation, glazing_material, shgc, iac.
|
345 |
-
hourly_data (Dict): Hourly weather data including solar radiation.
|
346 |
-
hour (int): Current hour.
|
347 |
-
building_orientation (float): Building orientation angle in degrees.
|
348 |
-
mode (str): Operating mode ('cooling', 'heating', 'none').
|
349 |
-
|
350 |
-
Returns:
|
351 |
-
float: Solar cooling load in kW. Returns 0 for non-cooling modes or non-fenestration components.
|
352 |
-
|
353 |
-
References:
|
354 |
-
ASHRAE Handbook—Fundamentals, Chapters 15 and 18.
|
355 |
-
"""
|
356 |
-
# Only calculate solar loads in cooling mode
|
357 |
-
if mode != "cooling":
|
358 |
-
return 0
|
359 |
-
|
360 |
-
# Skip floors for solar calculation
|
361 |
-
if component.component_type == ComponentType.FLOOR:
|
362 |
return 0
|
363 |
|
364 |
component_name = getattr(component, 'name', 'unnamed_component')
|
365 |
|
366 |
try:
|
367 |
-
#
|
368 |
-
material_library
|
369 |
-
|
370 |
-
logger.
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
project_materials = st.session_state.get("project_materials", {})
|
378 |
-
project_constructions = st.session_state.get("project_constructions", {})
|
379 |
-
project_glazing_materials = st.session_state.get("project_glazing_materials", {})
|
380 |
-
project_door_materials = st.session_state.get("project_door_materials", {})
|
381 |
|
382 |
# Get location parameters from climate_data
|
383 |
-
climate_data = st.session_state.
|
384 |
latitude = climate_data.get("latitude", 0.0)
|
385 |
longitude = climate_data.get("longitude", 0.0)
|
386 |
-
timezone = climate_data.get("
|
387 |
-
|
388 |
-
# Get ground reflectivity (default 0.2)
|
389 |
-
ground_reflectivity = st.session_state.get("ground_reflectivity", 0.2)
|
390 |
|
391 |
-
# Validate
|
392 |
if not -90 <= latitude <= 90:
|
393 |
-
logger.warning(f"Invalid latitude {latitude} for {component_name}
|
394 |
latitude = 0.0
|
395 |
if not -180 <= longitude <= 180:
|
396 |
-
logger.warning(f"Invalid longitude {longitude} for {component_name}
|
397 |
longitude = 0.0
|
398 |
if not -12 <= timezone <= 14:
|
399 |
-
logger.warning(f"Invalid timezone {timezone} for {component_name}
|
400 |
timezone = 0.0
|
401 |
if not 0 <= ground_reflectivity <= 1:
|
402 |
-
logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name}
|
403 |
ground_reflectivity = 0.2
|
404 |
|
405 |
# Ensure hourly_data has required fields
|
406 |
-
required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
|
407 |
-
|
408 |
if not all(field in hourly_data for field in required_fields):
|
409 |
-
logger.warning(f"Missing required fields in hourly_data for hour {hour} for {component_name}
|
410 |
return 0
|
411 |
|
412 |
-
# Skip if GHI <= 0
|
413 |
if hourly_data["global_horizontal_radiation"] <= 0:
|
414 |
-
logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']} for {component_name}
|
415 |
return 0
|
416 |
|
417 |
-
# Extract weather data
|
418 |
month = hourly_data["month"]
|
419 |
day = hourly_data["day"]
|
420 |
hour = hourly_data["hour"]
|
421 |
ghi = hourly_data["global_horizontal_radiation"]
|
422 |
-
dni = hourly_data.get("direct_normal_radiation", ghi * 0.7)
|
423 |
-
dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3)
|
424 |
outdoor_temp = hourly_data["dry_bulb"]
|
425 |
|
426 |
if ghi < 0 or dni < 0 or dhi < 0:
|
427 |
-
logger.error(f"Negative radiation values for {month}/{day}/{hour} for {component_name}
|
428 |
raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
|
429 |
|
430 |
-
# Add detailed logging for solar calculation
|
431 |
logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
|
432 |
-
f"dry_bulb={outdoor_temp} for {component_name}
|
433 |
|
434 |
-
# Step 1: Local Solar Time (LST)
|
435 |
-
year = 2025
|
436 |
n = TFMCalculations.day_of_year(month, day, year)
|
437 |
EOT = TFMCalculations.equation_of_time(n)
|
438 |
-
lambda_std = 15 * timezone
|
439 |
-
standard_time = hour - 1 + 0.5
|
440 |
-
LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
|
441 |
|
442 |
# Step 2: Solar Declination (δ)
|
443 |
delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
|
@@ -454,136 +300,79 @@ class TFMCalculations:
|
|
454 |
alpha = math.degrees(math.asin(sin_alpha))
|
455 |
|
456 |
if abs(math.cos(math.radians(alpha))) < 0.01:
|
457 |
-
azimuth = 0
|
458 |
else:
|
459 |
sin_az = math.cos(delta_rad) * math.sin(hra_rad) / math.cos(math.radians(alpha))
|
460 |
cos_az = (sin_alpha * math.sin(phi) - math.sin(delta_rad)) / (math.cos(math.radians(alpha)) * math.cos(phi))
|
461 |
azimuth = math.degrees(math.atan2(sin_az, cos_az))
|
462 |
-
if hra > 0:
|
463 |
azimuth = 360 - azimuth if azimuth > 0 else -azimuth
|
464 |
|
465 |
logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
|
466 |
-
f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f} for {component_name}
|
467 |
-
|
468 |
-
# Step 5: Get surface parameters
|
469 |
-
building_info =
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
|
474 |
-
|
475 |
-
)
|
476 |
-
except Exception as e:
|
477 |
-
logger.error(f"Error getting surface parameters for {component_name}: {str(e)}. Using defaults.")
|
478 |
-
# Apply defaults based on component type
|
479 |
-
if component.component_type == ComponentType.ROOF:
|
480 |
-
surface_tilt = 0.0 # Horizontal, upward
|
481 |
-
surface_azimuth = 0.0 # Default north
|
482 |
-
elif component.component_type == ComponentType.SKYLIGHT:
|
483 |
-
surface_tilt = 0.0 # Horizontal, upward
|
484 |
-
surface_azimuth = 0.0 # Default north
|
485 |
-
elif component.component_type == ComponentType.FLOOR:
|
486 |
-
surface_tilt = 180.0 # Horizontal, downward
|
487 |
-
surface_azimuth = 0.0 # Default north
|
488 |
-
else: # WALL, DOOR, WINDOW
|
489 |
-
surface_tilt = 90.0 # Vertical
|
490 |
-
surface_azimuth = 0.0 # Default north
|
491 |
-
|
492 |
-
# Apply material defaults
|
493 |
-
if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
|
494 |
-
solar_absorption = 0.6
|
495 |
-
h_o = 17.0 if component.component_type == ComponentType.WALL else 23.0
|
496 |
-
else: # WINDOW, SKYLIGHT
|
497 |
-
solar_absorption = 0.0 # Not used for glazing
|
498 |
-
h_o = 17.0 if component.component_type == ComponentType.WINDOW else 23.0
|
499 |
|
500 |
# Step 6: Calculate angle of incidence (θ)
|
501 |
-
# Convert angles to radians for calculation
|
502 |
alpha_rad = math.radians(alpha)
|
503 |
surface_tilt_rad = math.radians(surface_tilt)
|
504 |
azimuth_rad = math.radians(azimuth)
|
505 |
surface_azimuth_rad = math.radians(surface_azimuth)
|
506 |
|
507 |
-
# Calculate cos(θ) using the solar position and surface orientation
|
508 |
cos_theta = (math.sin(alpha_rad) * math.cos(surface_tilt_rad) +
|
509 |
math.cos(alpha_rad) * math.sin(surface_tilt_rad) *
|
510 |
math.cos(azimuth_rad - surface_azimuth_rad))
|
511 |
-
|
512 |
-
# Clamp to [0, 1] to avoid numerical issues
|
513 |
cos_theta = max(min(cos_theta, 1.0), 0.0)
|
514 |
-
|
515 |
-
|
516 |
-
logger.info(f" Component {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
|
517 |
f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
|
518 |
f"cos_theta={cos_theta:.4f}")
|
519 |
|
520 |
# Step 7: Calculate total incident radiation (I_t)
|
521 |
-
# Calculate view factor for ground-reflected radiation
|
522 |
view_factor = (1 - math.cos(surface_tilt_rad)) / 2
|
523 |
-
|
524 |
-
# Calculate ground-reflected radiation
|
525 |
ground_reflected = ground_reflectivity * ghi * view_factor
|
526 |
-
|
527 |
-
|
528 |
-
|
529 |
-
I_t = dni * cos_theta + dhi + ground_reflected
|
530 |
-
else: # Surface in shade, only diffuse and reflected
|
531 |
-
I_t = dhi + ground_reflected
|
532 |
-
|
533 |
-
# Step 8: Calculate solar heat gain based on component type
|
534 |
solar_heat_gain = 0.0
|
535 |
-
|
536 |
if component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
537 |
-
|
538 |
-
shgc = 0.7
|
539 |
-
|
540 |
-
|
541 |
-
|
542 |
-
if hasattr(glazing_material, 'name'):
|
543 |
-
glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
|
544 |
-
material_library.library_glazing_materials.get(glazing_material.name))
|
545 |
-
|
546 |
if glazing_material_obj:
|
547 |
shgc = getattr(glazing_material_obj, 'shgc', 0.7)
|
548 |
h_o = getattr(glazing_material_obj, 'h_o', h_o)
|
549 |
-
|
550 |
-
|
551 |
-
|
552 |
-
logger.warning(f"No glazing material defined for {component_name} ({component.component_type.value}). Using default SHGC=0.7.")
|
553 |
-
|
554 |
-
# Get glazing type for dynamic SHGC calculation
|
555 |
-
glazing_type = "Single Clear" # Default
|
556 |
-
if hasattr(component, 'name') and component.name in TFMCalculations.GLAZING_TYPE_MAPPING:
|
557 |
-
glazing_type = TFMCalculations.GLAZING_TYPE_MAPPING[component.name]
|
558 |
-
|
559 |
-
# Get internal shading coefficient
|
560 |
-
iac = getattr(component, 'iac', 1.0) # Default internal shading
|
561 |
-
|
562 |
-
# Calculate dynamic SHGC based on incidence angle
|
563 |
shgc_dynamic = shgc * TFMCalculations.calculate_dynamic_shgc(glazing_type, cos_theta)
|
564 |
-
|
565 |
-
|
566 |
-
|
567 |
-
|
568 |
-
logger.info(f"Fenestration solar heat gain for {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
|
569 |
f"{solar_heat_gain:.4f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.4f}, "
|
570 |
f"I_t={I_t:.2f}, iac={iac})")
|
571 |
-
|
572 |
elif component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
|
573 |
-
|
574 |
-
|
575 |
-
|
576 |
-
|
577 |
-
|
578 |
-
|
579 |
-
logger.info(f"Opaque surface solar heat gain for {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: "
|
580 |
-
f"{solar_heat_gain:.4f} kW (area={component.area}, solar_absorption={solar_absorption:.2f}, "
|
581 |
f"I_t={I_t:.2f}, surface_resistance={surface_resistance:.4f})")
|
582 |
-
|
583 |
return solar_heat_gain
|
584 |
|
585 |
except Exception as e:
|
586 |
-
logger.error(f"Error calculating solar load for {component_name}
|
587 |
return 0
|
588 |
|
589 |
@staticmethod
|
@@ -591,80 +380,99 @@ class TFMCalculations:
|
|
591 |
"""Calculate total internal load in kW."""
|
592 |
total_load = 0
|
593 |
for group in internal_loads.get("people", []):
|
594 |
-
activity_data = group
|
595 |
-
sensible = (activity_data
|
596 |
-
latent = (activity_data
|
597 |
-
|
598 |
-
|
|
|
|
|
599 |
for light in internal_loads.get("lighting", []):
|
600 |
-
lpd = light
|
601 |
-
|
602 |
-
fraction =
|
603 |
-
lighting_load = lpd * area * fraction
|
604 |
total_load += lighting_load
|
605 |
-
|
606 |
-
|
607 |
-
|
608 |
-
|
|
|
|
|
|
|
609 |
total_load += equipment_load
|
|
|
610 |
return total_load / 1000
|
611 |
|
612 |
@staticmethod
|
613 |
-
def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") ->
|
614 |
"""Calculate ventilation load for heating and cooling in kW based on mode."""
|
615 |
if mode == "none":
|
616 |
return 0, 0
|
617 |
-
ventilation = internal_loads.get("ventilation")
|
618 |
if not ventilation:
|
619 |
return 0, 0
|
620 |
-
|
621 |
-
|
622 |
-
|
623 |
-
|
624 |
-
|
625 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
626 |
delta_t = outdoor_temp - indoor_temp
|
627 |
if mode == "cooling" and delta_t <= 0:
|
628 |
return 0, 0
|
629 |
if mode == "heating" and delta_t >= 0:
|
630 |
return 0, 0
|
631 |
-
load =
|
632 |
cooling_load = load if mode == "cooling" else 0
|
633 |
heating_load = -load if mode == "heating" else 0
|
634 |
return cooling_load, heating_load
|
635 |
|
636 |
@staticmethod
|
637 |
-
def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") ->
|
638 |
"""Calculate infiltration load for heating and cooling in kW based on mode."""
|
639 |
if mode == "none":
|
640 |
return 0, 0
|
641 |
-
infiltration = internal_loads.get("infiltration")
|
642 |
if not infiltration:
|
643 |
return 0, 0
|
644 |
-
|
645 |
-
|
646 |
-
|
647 |
-
|
648 |
-
|
649 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
650 |
delta_t = outdoor_temp - indoor_temp
|
651 |
if mode == "cooling" and delta_t <= 0:
|
652 |
return 0, 0
|
653 |
if mode == "heating" and delta_t >= 0:
|
654 |
return 0, 0
|
655 |
-
|
656 |
-
ach = settings.get("rate", 0.5)
|
657 |
-
infiltration_flow = ach * volume / 3600 # m³/s
|
658 |
-
elif method == "Crack Flow":
|
659 |
-
ela = settings.get("ela", 0.0001) # m²/m²
|
660 |
-
wind_speed = 4.0 # m/s (assumed)
|
661 |
-
infiltration_flow = ela * area * wind_speed / 2 # m³/s
|
662 |
-
else: # Empirical Equations
|
663 |
-
c = settings.get("c", 0.1)
|
664 |
-
n = settings.get("n", 0.65)
|
665 |
-
delta_t_abs = abs(delta_t)
|
666 |
-
infiltration_flow = c * (delta_t_abs ** n) * area / 3600 # m³/s
|
667 |
-
load = infiltration_flow * air_density * specific_heat * delta_t / 1000 # kW
|
668 |
cooling_load = load if mode == "cooling" else 0
|
669 |
heating_load = -load if mode == "heating" else 0
|
670 |
return cooling_load, heating_load
|
@@ -674,25 +482,25 @@ class TFMCalculations:
|
|
674 |
"""Calculate adaptive comfort temperature per ASHRAE 55."""
|
675 |
if 10 <= outdoor_temp <= 33.5:
|
676 |
return 0.31 * outdoor_temp + 17.8
|
677 |
-
return 24.0
|
678 |
|
679 |
@staticmethod
|
680 |
def filter_hourly_data(hourly_data: List[Dict], sim_period: Dict, climate_data: Dict) -> List[Dict]:
|
681 |
-
"""Filter hourly data based on simulation period
|
682 |
-
if sim_period
|
683 |
return hourly_data
|
684 |
filtered_data = []
|
685 |
-
if sim_period
|
686 |
-
start_month = sim_period
|
687 |
-
start_day = sim_period
|
688 |
-
end_month = sim_period
|
689 |
-
end_day = sim_period
|
690 |
for data in hourly_data:
|
691 |
month, day = data["month"], data["day"]
|
692 |
if (month > start_month or (month == start_month and day >= start_day)) and \
|
693 |
(month < end_month or (month == end_month and day <= end_day)):
|
694 |
filtered_data.append(data)
|
695 |
-
elif sim_period
|
696 |
base_temp = sim_period.get("base_temp", 18.3 if sim_period["type"] == "HDD" else 23.9)
|
697 |
for data in hourly_data:
|
698 |
temp = data["dry_bulb"]
|
@@ -701,23 +509,23 @@ class TFMCalculations:
|
|
701 |
return filtered_data
|
702 |
|
703 |
@staticmethod
|
704 |
-
def get_indoor_conditions(indoor_conditions: Dict, hour: int, outdoor_temp: float) -> Dict:
|
705 |
"""Determine indoor conditions based on user settings."""
|
706 |
-
if indoor_conditions
|
707 |
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
|
708 |
if mode == "cooling":
|
709 |
return {
|
710 |
-
"temperature":
|
711 |
-
"rh":
|
712 |
}
|
713 |
elif mode == "heating":
|
714 |
return {
|
715 |
-
"temperature":
|
716 |
-
"rh":
|
717 |
}
|
718 |
else:
|
719 |
return {"temperature": 24.0, "rh": 50.0}
|
720 |
-
elif indoor_conditions
|
721 |
schedule = indoor_conditions.get("schedule", [])
|
722 |
if schedule:
|
723 |
hour_idx = hour % 24
|
@@ -730,20 +538,18 @@ class TFMCalculations:
|
|
730 |
|
731 |
@staticmethod
|
732 |
def calculate_tfm_loads(components: Dict, hourly_data: List[Dict], indoor_conditions: Dict, internal_loads: Dict, building_info: Dict, sim_period: Dict, hvac_settings: Dict) -> List[Dict]:
|
733 |
-
"""Calculate TFM loads for heating and cooling
|
734 |
filtered_data = TFMCalculations.filter_hourly_data(hourly_data, sim_period, building_info)
|
735 |
temp_loads = []
|
736 |
building_orientation = building_info.get("orientation_angle", 0.0)
|
737 |
operating_periods = hvac_settings.get("operating_hours", [{"start": 8, "end": 18}])
|
738 |
area = building_info.get("floor_area", 100.0)
|
739 |
-
|
740 |
-
# Ensure MaterialLibrary is properly initialized
|
741 |
if "material_library" not in st.session_state:
|
742 |
-
from data.material_library import MaterialLibrary
|
743 |
st.session_state.material_library = MaterialLibrary()
|
744 |
-
logger.info("Initialized MaterialLibrary in session_state
|
745 |
-
|
746 |
-
# Pre-calculate CTF coefficients
|
747 |
for comp_list in components.values():
|
748 |
for comp in comp_list:
|
749 |
comp.ctf = CTFCalculator.calculate_ctf_coefficients(comp)
|
@@ -751,34 +557,19 @@ class TFMCalculations:
|
|
751 |
for hour_data in filtered_data:
|
752 |
hour = hour_data["hour"]
|
753 |
outdoor_temp = hour_data["dry_bulb"]
|
754 |
-
indoor_cond = TFMCalculations.get_indoor_conditions(indoor_conditions, hour, outdoor_temp)
|
755 |
indoor_temp = indoor_cond["temperature"]
|
756 |
-
|
757 |
conduction_cooling = conduction_heating = solar = internal = ventilation_cooling = ventilation_heating = infiltration_cooling = infiltration_heating = 0
|
758 |
-
|
759 |
-
is_operating = False
|
760 |
-
for period in operating_periods:
|
761 |
-
start_hour = period.get("start", 8)
|
762 |
-
end_hour = period.get("end", 18)
|
763 |
-
if start_hour <= hour % 24 <= end_hour:
|
764 |
-
is_operating = True
|
765 |
-
break
|
766 |
-
# Determine mode based on temperature threshold (18°C)
|
767 |
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
|
|
|
768 |
if is_operating and mode == "cooling":
|
769 |
-
# Calculate solar load for each component and accumulate
|
770 |
for comp_list in components.values():
|
771 |
for comp in comp_list:
|
772 |
cool_load, _ = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="cooling")
|
773 |
conduction_cooling += cool_load
|
774 |
-
|
775 |
-
# Calculate solar load for each component and accumulate
|
776 |
-
component_solar_load = TFMCalculations.calculate_solar_load(comp, hour_data, hour, building_orientation, mode="cooling")
|
777 |
-
solar += component_solar_load
|
778 |
-
|
779 |
-
# Add detailed logging for solar load accumulation
|
780 |
-
logger.info(f"Component {comp.name} ({comp.component_type.value}) solar load: {component_solar_load:.3f} kW, accumulated solar: {solar:.3f} kW")
|
781 |
-
|
782 |
internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
|
783 |
ventilation_cooling, _ = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
|
784 |
infiltration_cooling, _ = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
|
@@ -790,20 +581,18 @@ class TFMCalculations:
|
|
790 |
internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
|
791 |
_, ventilation_heating = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
|
792 |
_, infiltration_heating = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
|
793 |
-
else:
|
794 |
-
internal = 0
|
795 |
-
|
796 |
-
# Add detailed logging for total loads
|
797 |
logger.info(f"Hour {hour} total loads - conduction: {conduction_cooling:.3f} kW, solar: {solar:.3f} kW, internal: {internal:.3f} kW")
|
798 |
|
799 |
-
# Calculate total loads, subtracting internal load for heating
|
800 |
total_cooling = conduction_cooling + solar + internal + ventilation_cooling + infiltration_cooling
|
801 |
total_heating = max(conduction_heating + ventilation_heating + infiltration_heating - internal, 0)
|
802 |
-
# Enforce mutual exclusivity within hour
|
803 |
if mode == "cooling":
|
804 |
total_heating = 0
|
805 |
elif mode == "heating":
|
806 |
total_cooling = 0
|
|
|
807 |
temp_loads.append({
|
808 |
"hour": hour,
|
809 |
"month": hour_data["month"],
|
@@ -819,24 +608,80 @@ class TFMCalculations:
|
|
819 |
"total_cooling": total_cooling,
|
820 |
"total_heating": total_heating
|
821 |
})
|
822 |
-
|
823 |
loads_by_day = defaultdict(list)
|
824 |
for load in temp_loads:
|
825 |
day_key = (load["month"], load["day"])
|
826 |
loads_by_day[day_key].append(load)
|
827 |
final_loads = []
|
828 |
for day_key, day_loads in loads_by_day.items():
|
829 |
-
# Count hours with non-zero cooling and heating loads
|
830 |
cooling_hours = sum(1 for load in day_loads if load["total_cooling"] > 0)
|
831 |
heating_hours = sum(1 for load in day_loads if load["total_heating"] > 0)
|
832 |
-
# Apply daily control
|
833 |
for load in day_loads:
|
834 |
if cooling_hours > heating_hours:
|
835 |
-
load["total_heating"] = 0
|
836 |
elif heating_hours > cooling_hours:
|
837 |
-
load["total_cooling"] = 0 # Keep heating components, zero cooling total
|
838 |
-
else: # Equal hours
|
839 |
load["total_cooling"] = 0
|
840 |
-
|
|
|
|
|
841 |
final_loads.append(load)
|
842 |
-
return final_loads
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
14 |
from typing import Dict, List, Optional, NamedTuple, Any, Tuple
|
15 |
from enum import Enum
|
16 |
import streamlit as st
|
|
|
|
|
17 |
from datetime import datetime
|
18 |
from collections import defaultdict
|
19 |
import logging
|
20 |
import math
|
21 |
from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
|
22 |
+
from data.material_library import Construction, GlazingMaterial, DoorMaterial, Material, MaterialLibrary
|
23 |
|
24 |
# Configure logging
|
25 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
|
53 |
}
|
54 |
|
55 |
@staticmethod
|
56 |
+
def calculate_conduction_load(component: Any, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> Tuple[float, float]:
|
57 |
"""Calculate conduction load for heating and cooling in kW based on mode."""
|
58 |
if mode == "none":
|
59 |
return 0, 0
|
|
|
64 |
return 0, 0
|
65 |
|
66 |
# Get CTF coefficients using CTFCalculator
|
67 |
+
if not hasattr(component, 'ctf'):
|
68 |
+
component.ctf = CTFCalculator.calculate_ctf_coefficients(component)
|
69 |
+
|
70 |
+
# Initialize history terms (simplified: assume steady-state history)
|
71 |
load = component.u_value * component.area * delta_t
|
72 |
+
for i in range(len(component.ctf.Y)):
|
73 |
+
load += component.area * component.ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
74 |
+
load -= component.area * component.ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
|
|
75 |
cooling_load = load / 1000 if mode == "cooling" else 0
|
76 |
heating_load = -load / 1000 if mode == "heating" else 0
|
77 |
return cooling_load, heating_load
|
78 |
|
79 |
@staticmethod
|
80 |
def day_of_year(month: int, day: int, year: int) -> int:
|
81 |
+
"""Calculate day of the year (n) from month, day, and year, accounting for leap years."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
82 |
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
83 |
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
84 |
days_in_month[1] = 29
|
|
|
86 |
|
87 |
@staticmethod
|
88 |
def equation_of_time(n: int) -> float:
|
89 |
+
"""Calculate Equation of Time (EOT) in minutes using Spencer's formula."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
90 |
B = (n - 1) * 360 / 365
|
91 |
B_rad = math.radians(B)
|
92 |
EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
|
|
|
95 |
|
96 |
@staticmethod
|
97 |
def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
|
98 |
+
"""Calculate dynamic SHGC based on incidence angle."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
99 |
if glazing_type not in TFMCalculations.SHGC_COEFFICIENTS:
|
100 |
logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
|
101 |
glazing_type = "Single Clear"
|
102 |
|
103 |
c = TFMCalculations.SHGC_COEFFICIENTS[glazing_type]
|
|
|
104 |
f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
|
105 |
c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
|
106 |
+
return max(min(f_cos_theta, 1.0), 0.0)
|
107 |
|
108 |
@staticmethod
|
109 |
+
def get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary,
|
110 |
+
project_materials: Dict, project_constructions: Dict,
|
111 |
project_glazing_materials: Dict, project_door_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
|
112 |
"""
|
113 |
+
Determine surface parameters for a component using new component structure.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
114 |
"""
|
|
|
115 |
component_name = getattr(component, 'name', 'unnamed_component')
|
116 |
|
117 |
+
# Use component's pre-calculated surface properties
|
118 |
+
surface_tilt = getattr(component, 'tilt', 90.0)
|
119 |
+
surface_azimuth = getattr(component, 'surface_azimuth', 0.0)
|
120 |
+
h_o = 17.0 # Default for walls/windows/doors
|
121 |
emissivity = 0.9 # Default for opaque components
|
122 |
+
absorptivity = 0.6 # Default for opaque components
|
123 |
+
|
124 |
try:
|
125 |
+
# Adjust defaults based on component type
|
126 |
if component.component_type == ComponentType.ROOF:
|
127 |
+
h_o = 23.0
|
|
|
|
|
|
|
|
|
|
|
128 |
elif component.component_type == ComponentType.SKYLIGHT:
|
129 |
+
h_o = 23.0
|
|
|
|
|
|
|
|
|
|
|
130 |
elif component.component_type == ComponentType.FLOOR:
|
131 |
+
surface_tilt = 180.0
|
132 |
+
surface_azimuth = 0.0
|
133 |
+
h_o = 17.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
134 |
|
135 |
# Fetch material properties
|
136 |
if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
|
137 |
+
construction_name = getattr(component, 'construction', None)
|
138 |
+
if not construction_name:
|
139 |
+
logger.warning(f"No construction defined for {component_name} ({component.component_type.value}). Using defaults.")
|
|
|
140 |
else:
|
141 |
+
construction_obj = (project_constructions.get(construction_name) or
|
142 |
+
material_library.library_constructions.get(construction_name))
|
|
|
|
|
|
|
|
|
143 |
if not construction_obj:
|
144 |
+
logger.warning(f"Construction {construction_name} not found for {component_name}. Using defaults.")
|
|
|
145 |
elif not construction_obj.layers:
|
146 |
+
logger.warning(f"No layers in construction {construction_name} for {component_name}. Using defaults.")
|
|
|
147 |
else:
|
|
|
148 |
first_layer = construction_obj.layers[0]
|
149 |
+
material = (project_materials.get(first_layer['material']) or
|
150 |
+
material_library.library_materials.get(first_layer['material']))
|
151 |
if material:
|
152 |
+
absorptivity = getattr(material, 'absorptivity', 0.6)
|
153 |
emissivity = getattr(material, 'emissivity', 0.9)
|
154 |
+
logger.debug(f"Using first layer material for {component_name}: "
|
155 |
+
f"absorptivity={absorptivity}, emissivity={emissivity}")
|
156 |
|
157 |
elif component.component_type == ComponentType.DOOR:
|
158 |
+
door_material_name = getattr(component, 'door_material', None)
|
159 |
+
if not door_material_name:
|
160 |
+
logger.warning(f"No door material defined for {component_name}. Using defaults.")
|
|
|
161 |
else:
|
162 |
+
door_material_obj = (project_door_materials.get(door_material_name) or
|
163 |
+
material_library.library_door_materials.get(door_material_name))
|
|
|
|
|
|
|
|
|
164 |
if not door_material_obj:
|
165 |
+
logger.warning(f"Door material {door_material_name} not found for {component_name}. Using defaults.")
|
|
|
166 |
else:
|
167 |
+
absorptivity = getattr(door_material_obj, 'absorptivity', 0.6)
|
168 |
emissivity = getattr(door_material_obj, 'emissivity', 0.9)
|
169 |
+
logger.debug(f"Using door material for {component_name}: "
|
170 |
+
f"absorptivity={absorptivity}, emissivity={emissivity}")
|
171 |
|
172 |
elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
173 |
+
fenestration_name = getattr(component, 'fenestration', None)
|
174 |
+
if not fenestration_name:
|
175 |
+
logger.warning(f"No fenestration defined for {component_name}. Using default SHGC=0.7.")
|
|
|
176 |
shgc = 0.7
|
177 |
else:
|
178 |
+
glazing_material_obj = (project_glazing_materials.get(fenestration_name) or
|
179 |
+
material_library.library_glazing_materials.get(fenestration_name))
|
|
|
|
|
|
|
|
|
180 |
if not glazing_material_obj:
|
181 |
+
logger.warning(f"Fenestration {fenestration_name} not found for {component_name}. Using default SHGC=0.7.")
|
|
|
182 |
shgc = 0.7
|
183 |
else:
|
184 |
shgc = getattr(glazing_material_obj, 'shgc', 0.7)
|
185 |
h_o = getattr(glazing_material_obj, 'h_o', h_o)
|
186 |
+
logger.debug(f"Using glazing material for {component_name}: shgc={shgc}, h_o={h_o}")
|
187 |
+
emissivity = None
|
|
|
188 |
|
189 |
except Exception as e:
|
190 |
+
logger.error(f"Error retrieving surface parameters for {component_name}: {str(e)}")
|
|
|
191 |
if component.component_type == ComponentType.ROOF:
|
192 |
+
surface_tilt = 0.0
|
193 |
+
surface_azimuth = 0.0
|
194 |
+
h_o = 23.0
|
195 |
elif component.component_type == ComponentType.SKYLIGHT:
|
196 |
+
surface_tilt = 0.0
|
197 |
+
surface_azimuth = 0.0
|
198 |
+
h_o = 23.0
|
199 |
elif component.component_type == ComponentType.FLOOR:
|
200 |
+
surface_tilt = 180.0
|
201 |
+
surface_azimuth = 0.0
|
202 |
+
h_o = 17.0
|
203 |
+
else:
|
204 |
+
surface_tilt = 90.0
|
205 |
+
surface_azimuth = 0.0
|
206 |
+
h_o = 17.0
|
207 |
+
absorptivity = 0.6
|
208 |
+
emissivity = 0.9 if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR] else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
209 |
|
210 |
+
logger.info(f"Surface parameters for {component_name}: tilt={surface_tilt:.1f}, azimuth={surface_azimuth:.1f}, h_o={h_o:.1f}")
|
211 |
+
return surface_tilt, surface_azimuth, h_o, emissivity, absorptivity
|
212 |
|
213 |
@staticmethod
|
214 |
+
def calculate_solar_load(component: Any, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
|
215 |
+
"""Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations."""
|
216 |
+
if mode != "cooling" or component.component_type == ComponentType.FLOOR:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
217 |
return 0
|
218 |
|
219 |
component_name = getattr(component, 'name', 'unnamed_component')
|
220 |
|
221 |
try:
|
222 |
+
# Initialize MaterialLibrary if not present
|
223 |
+
if "material_library" not in st.session_state:
|
224 |
+
st.session_state.material_library = MaterialLibrary()
|
225 |
+
logger.info(f"Initialized MaterialLibrary for {component_name}")
|
226 |
+
|
227 |
+
material_library = st.session_state.material_library
|
228 |
+
project_materials = st.session_state.project_data["materials"]["project"]
|
229 |
+
project_constructions = st.session_state.project_data["constructions"]["project"]
|
230 |
+
project_glazing_materials = st.session_state.project_data["fenestrations"]["project"]
|
231 |
+
project_door_materials = st.session_state.project_data.get("door_materials", {})
|
|
|
|
|
|
|
|
|
232 |
|
233 |
# Get location parameters from climate_data
|
234 |
+
climate_data = st.session_state.project_data["climate_data"]
|
235 |
latitude = climate_data.get("latitude", 0.0)
|
236 |
longitude = climate_data.get("longitude", 0.0)
|
237 |
+
timezone = climate_data.get("timezone", 0.0)
|
238 |
+
ground_reflectivity = climate_data.get("ground_reflectivity", 0.2)
|
|
|
|
|
239 |
|
240 |
+
# Validate inputs
|
241 |
if not -90 <= latitude <= 90:
|
242 |
+
logger.warning(f"Invalid latitude {latitude} for {component_name}. Using default 0.0.")
|
243 |
latitude = 0.0
|
244 |
if not -180 <= longitude <= 180:
|
245 |
+
logger.warning(f"Invalid longitude {longitude} for {component_name}. Using default 0.0.")
|
246 |
longitude = 0.0
|
247 |
if not -12 <= timezone <= 14:
|
248 |
+
logger.warning(f"Invalid timezone {timezone} for {component_name}. Using default 0.0.")
|
249 |
timezone = 0.0
|
250 |
if not 0 <= ground_reflectivity <= 1:
|
251 |
+
logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name}. Using default 0.2.")
|
252 |
ground_reflectivity = 0.2
|
253 |
|
254 |
# Ensure hourly_data has required fields
|
255 |
+
required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
|
256 |
+
"diffuse_horizontal_radiation", "dry_bulb"]
|
257 |
if not all(field in hourly_data for field in required_fields):
|
258 |
+
logger.warning(f"Missing required fields in hourly_data for hour {hour} for {component_name}: {hourly_data}")
|
259 |
return 0
|
260 |
|
|
|
261 |
if hourly_data["global_horizontal_radiation"] <= 0:
|
262 |
+
logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']} for {component_name}")
|
263 |
return 0
|
264 |
|
|
|
265 |
month = hourly_data["month"]
|
266 |
day = hourly_data["day"]
|
267 |
hour = hourly_data["hour"]
|
268 |
ghi = hourly_data["global_horizontal_radiation"]
|
269 |
+
dni = hourly_data.get("direct_normal_radiation", ghi * 0.7)
|
270 |
+
dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3)
|
271 |
outdoor_temp = hourly_data["dry_bulb"]
|
272 |
|
273 |
if ghi < 0 or dni < 0 or dhi < 0:
|
274 |
+
logger.error(f"Negative radiation values for {month}/{day}/{hour} for {component_name}")
|
275 |
raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
|
276 |
|
|
|
277 |
logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
|
278 |
+
f"dry_bulb={outdoor_temp} for {component_name}")
|
279 |
|
280 |
+
# Step 1: Local Solar Time (LST)
|
281 |
+
year = 2025
|
282 |
n = TFMCalculations.day_of_year(month, day, year)
|
283 |
EOT = TFMCalculations.equation_of_time(n)
|
284 |
+
lambda_std = 15 * timezone
|
285 |
+
standard_time = hour - 1 + 0.5
|
286 |
+
LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
|
287 |
|
288 |
# Step 2: Solar Declination (δ)
|
289 |
delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
|
|
|
300 |
alpha = math.degrees(math.asin(sin_alpha))
|
301 |
|
302 |
if abs(math.cos(math.radians(alpha))) < 0.01:
|
303 |
+
azimuth = 0
|
304 |
else:
|
305 |
sin_az = math.cos(delta_rad) * math.sin(hra_rad) / math.cos(math.radians(alpha))
|
306 |
cos_az = (sin_alpha * math.sin(phi) - math.sin(delta_rad)) / (math.cos(math.radians(alpha)) * math.cos(phi))
|
307 |
azimuth = math.degrees(math.atan2(sin_az, cos_az))
|
308 |
+
if hra > 0:
|
309 |
azimuth = 360 - azimuth if azimuth > 0 else -azimuth
|
310 |
|
311 |
logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
|
312 |
+
f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f} for {component_name}")
|
313 |
+
|
314 |
+
# Step 5: Get surface parameters
|
315 |
+
building_info = st.session_state.project_data["building_info"]
|
316 |
+
surface_tilt, surface_azimuth, h_o, emissivity, absorptivity = \
|
317 |
+
TFMCalculations.get_surface_parameters(
|
318 |
+
component, building_info, material_library, project_materials,
|
319 |
+
project_constructions, project_glazing_materials, project_door_materials
|
320 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
321 |
|
322 |
# Step 6: Calculate angle of incidence (θ)
|
|
|
323 |
alpha_rad = math.radians(alpha)
|
324 |
surface_tilt_rad = math.radians(surface_tilt)
|
325 |
azimuth_rad = math.radians(azimuth)
|
326 |
surface_azimuth_rad = math.radians(surface_azimuth)
|
327 |
|
|
|
328 |
cos_theta = (math.sin(alpha_rad) * math.cos(surface_tilt_rad) +
|
329 |
math.cos(alpha_rad) * math.sin(surface_tilt_rad) *
|
330 |
math.cos(azimuth_rad - surface_azimuth_rad))
|
|
|
|
|
331 |
cos_theta = max(min(cos_theta, 1.0), 0.0)
|
332 |
+
|
333 |
+
logger.info(f"Component {component_name} at {month}/{day}/{hour}: "
|
|
|
334 |
f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
|
335 |
f"cos_theta={cos_theta:.4f}")
|
336 |
|
337 |
# Step 7: Calculate total incident radiation (I_t)
|
|
|
338 |
view_factor = (1 - math.cos(surface_tilt_rad)) / 2
|
|
|
|
|
339 |
ground_reflected = ground_reflectivity * ghi * view_factor
|
340 |
+
I_t = dni * cos_theta + dhi + ground_reflected if cos_theta > 0 else dhi + ground_reflected
|
341 |
+
|
342 |
+
# Step 8: Calculate solar heat gain
|
|
|
|
|
|
|
|
|
|
|
343 |
solar_heat_gain = 0.0
|
344 |
+
|
345 |
if component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
346 |
+
fenestration_name = getattr(component, 'fenestration', None)
|
347 |
+
shgc = 0.7
|
348 |
+
if fenestration_name:
|
349 |
+
glazing_material_obj = (project_glazing_materials.get(fenestration_name) or
|
350 |
+
material_library.library_glazing_materials.get(fenestration_name))
|
|
|
|
|
|
|
|
|
351 |
if glazing_material_obj:
|
352 |
shgc = getattr(glazing_material_obj, 'shgc', 0.7)
|
353 |
h_o = getattr(glazing_material_obj, 'h_o', h_o)
|
354 |
+
|
355 |
+
glazing_type = TFMCalculations.GLAZING_TYPE_MAPPING.get(fenestration_name, "Single Clear")
|
356 |
+
iac = getattr(component, 'shading_coefficient', 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
357 |
shgc_dynamic = shgc * TFMCalculations.calculate_dynamic_shgc(glazing_type, cos_theta)
|
358 |
+
solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000
|
359 |
+
|
360 |
+
logger.info(f"Fenestration solar heat gain for {component_name} at {month}/{day}/{hour}: "
|
|
|
|
|
361 |
f"{solar_heat_gain:.4f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.4f}, "
|
362 |
f"I_t={I_t:.2f}, iac={iac})")
|
363 |
+
|
364 |
elif component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
|
365 |
+
surface_resistance = 1 / h_o
|
366 |
+
solar_heat_gain = component.area * absorptivity * I_t * surface_resistance / 1000
|
367 |
+
|
368 |
+
logger.info(f"Opaque surface solar heat gain for {component_name} at {month}/{day}/{hour}: "
|
369 |
+
f"{solar_heat_gain:.4f} kW (area={component.area}, absorptivity={absorptivity:.2f}, "
|
|
|
|
|
|
|
370 |
f"I_t={I_t:.2f}, surface_resistance={surface_resistance:.4f})")
|
371 |
+
|
372 |
return solar_heat_gain
|
373 |
|
374 |
except Exception as e:
|
375 |
+
logger.error(f"Error calculating solar load for {component_name} at hour {hour}: {str(e)}")
|
376 |
return 0
|
377 |
|
378 |
@staticmethod
|
|
|
380 |
"""Calculate total internal load in kW."""
|
381 |
total_load = 0
|
382 |
for group in internal_loads.get("people", []):
|
383 |
+
activity_data = group.get("activity_data", {})
|
384 |
+
sensible = (activity_data.get("sensible_min_w", 0) + activity_data.get("sensible_max_w", 0)) / 2
|
385 |
+
latent = (activity_data.get("latent_min_w", 0) + activity_data.get("latent_max_w", 0)) / 2
|
386 |
+
schedule = internal_loads["schedules"].get(group.get("schedule", ""), {})
|
387 |
+
diversity_factor = schedule.get("weekday", [1.0]*24)[hour % 24] if schedule else 1.0
|
388 |
+
total_load += group.get("num_people", 0) * (sensible + latent) * diversity_factor
|
389 |
+
|
390 |
for light in internal_loads.get("lighting", []):
|
391 |
+
lpd = light.get("lpd", 0)
|
392 |
+
schedule = internal_loads["schedules"].get(light.get("schedule", ""), {})
|
393 |
+
fraction = schedule.get("weekday", [1.0]*24)[hour % 24] if schedule else 1.0
|
394 |
+
lighting_load = lpd * light.get("area", area) * fraction
|
395 |
total_load += lighting_load
|
396 |
+
|
397 |
+
for equip in internal_loads.get("equipment", []):
|
398 |
+
sensible_gain = equip.get("sensible_gain", 0)
|
399 |
+
latent_gain = equip.get("latent_gain", 0)
|
400 |
+
schedule = internal_loads["schedules"].get(equip.get("schedule", ""), {})
|
401 |
+
fraction = schedule.get("weekday", [1.0]*24)[hour % 24] if schedule else 1.0
|
402 |
+
equipment_load = (sensible_gain + latent_gain) * equip.get("area", area) * fraction
|
403 |
total_load += equipment_load
|
404 |
+
|
405 |
return total_load / 1000
|
406 |
|
407 |
@staticmethod
|
408 |
+
def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> Tuple[float, float]:
|
409 |
"""Calculate ventilation load for heating and cooling in kW based on mode."""
|
410 |
if mode == "none":
|
411 |
return 0, 0
|
412 |
+
ventilation = internal_loads.get("ventilation", [])
|
413 |
if not ventilation:
|
414 |
return 0, 0
|
415 |
+
total_ventilation_flow = 0
|
416 |
+
for vent in ventilation:
|
417 |
+
if vent.get("system_type") == "AirChanges/Hour":
|
418 |
+
design_flow_rate = vent.get("design_flow_rate", 0.3) # ACH
|
419 |
+
building_height = building_info.get("building_height", 3.0)
|
420 |
+
volume = area * building_height
|
421 |
+
flow = design_flow_rate * volume / 3600 # m³/s
|
422 |
+
else:
|
423 |
+
flow = vent.get("design_flow_rate", 0.0) / 1000 # L/s to m³/s
|
424 |
+
schedule = internal_loads["schedules"].get(vent.get("schedule", ""), {})
|
425 |
+
fraction = schedule.get("weekday", [1.0]*24)[hour % 24] if schedule else 1.0
|
426 |
+
total_ventilation_flow += flow * fraction
|
427 |
+
|
428 |
+
air_density = 1.2
|
429 |
+
specific_heat = 1000
|
430 |
delta_t = outdoor_temp - indoor_temp
|
431 |
if mode == "cooling" and delta_t <= 0:
|
432 |
return 0, 0
|
433 |
if mode == "heating" and delta_t >= 0:
|
434 |
return 0, 0
|
435 |
+
load = total_ventilation_flow * air_density * specific_heat * delta_t / 1000
|
436 |
cooling_load = load if mode == "cooling" else 0
|
437 |
heating_load = -load if mode == "heating" else 0
|
438 |
return cooling_load, heating_load
|
439 |
|
440 |
@staticmethod
|
441 |
+
def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> Tuple[float, float]:
|
442 |
"""Calculate infiltration load for heating and cooling in kW based on mode."""
|
443 |
if mode == "none":
|
444 |
return 0, 0
|
445 |
+
infiltration = internal_loads.get("infiltration", [])
|
446 |
if not infiltration:
|
447 |
return 0, 0
|
448 |
+
total_infiltration_flow = 0
|
449 |
+
for inf in infiltration:
|
450 |
+
if inf.get("system_type") == "AirChanges/Hour":
|
451 |
+
design_flow_rate = inf.get("design_flow_rate", 0.5) # ACH
|
452 |
+
building_height = building_info.get("building_height", 3.0)
|
453 |
+
volume = area * building_height
|
454 |
+
flow = design_flow_rate * volume / 3600 # m³/s
|
455 |
+
elif inf.get("system_type") == "Crack Flow":
|
456 |
+
ela = inf.get("effective_air_leakage_area", 0.0001) / 10000 # cm² to m²
|
457 |
+
wind_speed = 4.0
|
458 |
+
flow = ela * area * wind_speed / 2
|
459 |
+
else: # Empirical Equations
|
460 |
+
c = inf.get("flow_coefficient", 0.0001)
|
461 |
+
n = inf.get("pressure_exponent", 0.65)
|
462 |
+
delta_t_abs = abs(outdoor_temp - indoor_temp)
|
463 |
+
flow = c * (delta_t_abs ** n) * area / 3600
|
464 |
+
schedule = internal_loads["schedules"].get(inf.get("schedule", ""), {})
|
465 |
+
fraction = schedule.get("weekday", [1.0]*24)[hour % 24] if schedule else 1.0
|
466 |
+
total_infiltration_flow += flow * fraction
|
467 |
+
|
468 |
+
air_density = 1.2
|
469 |
+
specific_heat = 1000
|
470 |
delta_t = outdoor_temp - indoor_temp
|
471 |
if mode == "cooling" and delta_t <= 0:
|
472 |
return 0, 0
|
473 |
if mode == "heating" and delta_t >= 0:
|
474 |
return 0, 0
|
475 |
+
load = total_infiltration_flow * air_density * specific_heat * delta_t / 1000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
476 |
cooling_load = load if mode == "cooling" else 0
|
477 |
heating_load = -load if mode == "heating" else 0
|
478 |
return cooling_load, heating_load
|
|
|
482 |
"""Calculate adaptive comfort temperature per ASHRAE 55."""
|
483 |
if 10 <= outdoor_temp <= 33.5:
|
484 |
return 0.31 * outdoor_temp + 17.8
|
485 |
+
return 24.0
|
486 |
|
487 |
@staticmethod
|
488 |
def filter_hourly_data(hourly_data: List[Dict], sim_period: Dict, climate_data: Dict) -> List[Dict]:
|
489 |
+
"""Filter hourly data based on simulation period."""
|
490 |
+
if sim_period.get("type") == "Full Year":
|
491 |
return hourly_data
|
492 |
filtered_data = []
|
493 |
+
if sim_period.get("type") == "From-to":
|
494 |
+
start_month = sim_period.get("start_date", {}).get("month", 1)
|
495 |
+
start_day = sim_period.get("start_date", {}).get("day", 1)
|
496 |
+
end_month = sim_period.get("end_date", {}).get("month", 12)
|
497 |
+
end_day = sim_period.get("end_date", {}).get("day", 31)
|
498 |
for data in hourly_data:
|
499 |
month, day = data["month"], data["day"]
|
500 |
if (month > start_month or (month == start_month and day >= start_day)) and \
|
501 |
(month < end_month or (month == end_month and day <= end_day)):
|
502 |
filtered_data.append(data)
|
503 |
+
elif sim_period.get("type") in ["HDD", "CDD"]:
|
504 |
base_temp = sim_period.get("base_temp", 18.3 if sim_period["type"] == "HDD" else 23.9)
|
505 |
for data in hourly_data:
|
506 |
temp = data["dry_bulb"]
|
|
|
509 |
return filtered_data
|
510 |
|
511 |
@staticmethod
|
512 |
+
def get_indoor_conditions(indoor_conditions: Dict, hour: int, outdoor_temp: float, building_info: Dict) -> Dict:
|
513 |
"""Determine indoor conditions based on user settings."""
|
514 |
+
if indoor_conditions.get("type") == "Fixed":
|
515 |
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
|
516 |
if mode == "cooling":
|
517 |
return {
|
518 |
+
"temperature": building_info.get("summer_indoor_design_temp", 24.0),
|
519 |
+
"rh": building_info.get("summer_indoor_design_rh", 50.0)
|
520 |
}
|
521 |
elif mode == "heating":
|
522 |
return {
|
523 |
+
"temperature": building_info.get("winter_indoor_design_temp", 22.0),
|
524 |
+
"rh": building_info.get("winter_indoor_design_rh", 50.0)
|
525 |
}
|
526 |
else:
|
527 |
return {"temperature": 24.0, "rh": 50.0}
|
528 |
+
elif indoor_conditions.get("type") == "Time-varying":
|
529 |
schedule = indoor_conditions.get("schedule", [])
|
530 |
if schedule:
|
531 |
hour_idx = hour % 24
|
|
|
538 |
|
539 |
@staticmethod
|
540 |
def calculate_tfm_loads(components: Dict, hourly_data: List[Dict], indoor_conditions: Dict, internal_loads: Dict, building_info: Dict, sim_period: Dict, hvac_settings: Dict) -> List[Dict]:
|
541 |
+
"""Calculate TFM loads for heating and cooling."""
|
542 |
filtered_data = TFMCalculations.filter_hourly_data(hourly_data, sim_period, building_info)
|
543 |
temp_loads = []
|
544 |
building_orientation = building_info.get("orientation_angle", 0.0)
|
545 |
operating_periods = hvac_settings.get("operating_hours", [{"start": 8, "end": 18}])
|
546 |
area = building_info.get("floor_area", 100.0)
|
547 |
+
|
|
|
548 |
if "material_library" not in st.session_state:
|
|
|
549 |
st.session_state.material_library = MaterialLibrary()
|
550 |
+
logger.info("Initialized MaterialLibrary in session_state")
|
551 |
+
|
552 |
+
# Pre-calculate CTF coefficients
|
553 |
for comp_list in components.values():
|
554 |
for comp in comp_list:
|
555 |
comp.ctf = CTFCalculator.calculate_ctf_coefficients(comp)
|
|
|
557 |
for hour_data in filtered_data:
|
558 |
hour = hour_data["hour"]
|
559 |
outdoor_temp = hour_data["dry_bulb"]
|
560 |
+
indoor_cond = TFMCalculations.get_indoor_conditions(indoor_conditions, hour, outdoor_temp, building_info)
|
561 |
indoor_temp = indoor_cond["temperature"]
|
562 |
+
|
563 |
conduction_cooling = conduction_heating = solar = internal = ventilation_cooling = ventilation_heating = infiltration_cooling = infiltration_heating = 0
|
564 |
+
is_operating = any(period["start"] <= hour % 24 <= period["end"] for period in operating_periods)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
565 |
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
|
566 |
+
|
567 |
if is_operating and mode == "cooling":
|
|
|
568 |
for comp_list in components.values():
|
569 |
for comp in comp_list:
|
570 |
cool_load, _ = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="cooling")
|
571 |
conduction_cooling += cool_load
|
572 |
+
solar += TFMCalculations.calculate_solar_load(comp, hour_data, hour, building_orientation, mode="cooling")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
573 |
internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
|
574 |
ventilation_cooling, _ = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
|
575 |
infiltration_cooling, _ = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
|
|
|
581 |
internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
|
582 |
_, ventilation_heating = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
|
583 |
_, infiltration_heating = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
|
584 |
+
else:
|
585 |
+
internal = 0
|
586 |
+
|
|
|
587 |
logger.info(f"Hour {hour} total loads - conduction: {conduction_cooling:.3f} kW, solar: {solar:.3f} kW, internal: {internal:.3f} kW")
|
588 |
|
|
|
589 |
total_cooling = conduction_cooling + solar + internal + ventilation_cooling + infiltration_cooling
|
590 |
total_heating = max(conduction_heating + ventilation_heating + infiltration_heating - internal, 0)
|
|
|
591 |
if mode == "cooling":
|
592 |
total_heating = 0
|
593 |
elif mode == "heating":
|
594 |
total_cooling = 0
|
595 |
+
|
596 |
temp_loads.append({
|
597 |
"hour": hour,
|
598 |
"month": hour_data["month"],
|
|
|
608 |
"total_cooling": total_cooling,
|
609 |
"total_heating": total_heating
|
610 |
})
|
611 |
+
|
612 |
loads_by_day = defaultdict(list)
|
613 |
for load in temp_loads:
|
614 |
day_key = (load["month"], load["day"])
|
615 |
loads_by_day[day_key].append(load)
|
616 |
final_loads = []
|
617 |
for day_key, day_loads in loads_by_day.items():
|
|
|
618 |
cooling_hours = sum(1 for load in day_loads if load["total_cooling"] > 0)
|
619 |
heating_hours = sum(1 for load in day_loads if load["total_heating"] > 0)
|
|
|
620 |
for load in day_loads:
|
621 |
if cooling_hours > heating_hours:
|
622 |
+
load["total_heating"] = 0
|
623 |
elif heating_hours > cooling_hours:
|
|
|
|
|
624 |
load["total_cooling"] = 0
|
625 |
+
else:
|
626 |
+
load["total_cooling"] = 0
|
627 |
+
load["total_heating"] = 0
|
628 |
final_loads.append(load)
|
629 |
+
return final_loads
|
630 |
+
|
631 |
+
def display_hvac_loads_page():
|
632 |
+
"""Display the HVAC Loads page and manage calculations."""
|
633 |
+
st.title("HVAC Loads")
|
634 |
+
st.markdown("Calculate cooling and heating loads based on ASHRAE methodology.")
|
635 |
+
|
636 |
+
project_data = st.session_state.project_data
|
637 |
+
components = project_data.get("components", {})
|
638 |
+
climate_data = project_data.get("climate_data", {})
|
639 |
+
internal_loads = project_data.get("internal_loads", {})
|
640 |
+
building_info = project_data.get("building_info", {})
|
641 |
+
sim_period = climate_data.get("typical_extreme_periods", {"type": "Full Year"})
|
642 |
+
indoor_conditions = {"type": "Fixed"} # Default, can be extended with UI
|
643 |
+
hvac_settings = project_data.get("hvac_loads", {}).get("settings", {"operating_hours": [{"start": 8, "end": 18}]})
|
644 |
+
|
645 |
+
if not climate_data.get("hourly_data"):
|
646 |
+
st.warning("Please upload climate data in the Climate Data page.")
|
647 |
+
return
|
648 |
+
|
649 |
+
if not any(components.values()):
|
650 |
+
st.warning("Please define building components in the Building Components page.")
|
651 |
+
return
|
652 |
+
|
653 |
+
if st.button("Calculate HVAC Loads"):
|
654 |
+
loads = TFMCalculations.calculate_tfm_loads(
|
655 |
+
components, climate_data["hourly_data"], indoor_conditions,
|
656 |
+
internal_loads, building_info, sim_period, hvac_settings
|
657 |
+
)
|
658 |
+
|
659 |
+
# Update session state
|
660 |
+
project_data["hvac_loads"]["cooling"]["hourly"] = [load for load in loads if load["total_cooling"] > 0]
|
661 |
+
project_data["hvac_loads"]["heating"]["hourly"] = [load for load in loads if load["total_heating"] > 0]
|
662 |
+
project_data["hvac_loads"]["cooling"]["peak"] = max((load["total_cooling"] for load in loads), default=0)
|
663 |
+
project_data["hvac_loads"]["heating"]["peak"] = max((load["total_heating"] for load in loads), default=0)
|
664 |
+
|
665 |
+
# Generate summary tables and charts
|
666 |
+
cooling_df = pd.DataFrame(project_data["hvac_loads"]["cooling"]["hourly"])
|
667 |
+
heating_df = pd.DataFrame(project_data["hvac_loads"]["heating"]["hourly"])
|
668 |
+
|
669 |
+
if not cooling_df.empty:
|
670 |
+
st.subheader("Cooling Loads")
|
671 |
+
st.dataframe(cooling_df[["month", "day", "hour", "total_cooling", "conduction_cooling", "solar", "internal", "ventilation_cooling", "infiltration_cooling"]])
|
672 |
+
project_data["hvac_loads"]["cooling"]["summary_tables"] = {"hourly_summary": cooling_df.to_dict()}
|
673 |
+
|
674 |
+
if not heating_df.empty:
|
675 |
+
st.subheader("Heating Loads")
|
676 |
+
st.dataframe(heating_df[["month", "day", "hour", "total_heating", "conduction_heating", "internal", "ventilation_heating", "infiltration_heating"]])
|
677 |
+
project_data["hvac_loads"]["heating"]["summary_tables"] = {"hourly_summary": heating_df.to_dict()}
|
678 |
+
|
679 |
+
# Trigger rerun if needed
|
680 |
+
if "hvac_loads_rerun_pending" not in st.session_state:
|
681 |
+
st.session_state.hvac_loads_rerun_pending = False
|
682 |
+
if st.session_state.hvac_loads_rerun_pending:
|
683 |
+
st.session_state.hvac_loads_rerun_pending = False
|
684 |
+
st.rerun()
|
685 |
+
|
686 |
+
if __name__ == "__main__":
|
687 |
+
display_hvac_loads_page()
|