mabuseif commited on
Commit
5aa5011
·
verified ·
1 Parent(s): 9511b71

Update app/hvac_loads.py

Browse files
Files changed (1) hide show
  1. 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") -> tuple[float, float]:
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
- ctf = CTFCalculator.calculate_ctf_coefficients(component)
69
-
70
- # Initialize history terms (simplified: assume steady-state history for demonstration)
71
- # In practice, maintain temperature and flux histories
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 (tilt, azimuth, h_o, emissivity, solar_absorption) for a component.
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
- # Initialize default values
173
- surface_tilt = 90.0 # Default vertical for walls, windows, doors
174
- surface_azimuth = 0.0 # Default north-facing
175
- h_o = 17.0 # Default exterior convection coefficient
176
  emissivity = 0.9 # Default for opaque components
177
- solar_absorption = 0.6 # Default
178
-
179
  try:
180
- # Set component-specific defaults based on type
181
  if component.component_type == ComponentType.ROOF:
182
- surface_tilt = getattr(component, 'tilt', 0.0) # Horizontal, upward if tilt absent
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
- surface_tilt = getattr(component, 'tilt', 0.0) # Horizontal, upward if tilt absent
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 # Horizontal, downward
197
- h_o = 17.0 # W/m²·K
198
- surface_azimuth = 0.0 # Default azimuth for floors
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
- construction = getattr(component, 'construction', None)
234
- if not construction:
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
- # Get construction from library or project
239
- construction_obj = None
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} ({component.component_type.value}). "
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} ({component.component_type.value}). "
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 = first_layer.get("material")
 
254
  if material:
255
- solar_absorption = getattr(material, 'solar_absorption', 0.6)
256
  emissivity = getattr(material, 'emissivity', 0.9)
257
- logger.debug(f"Using first layer material for {component_name} ({component.component_type.value}): "
258
- f"solar_absorption={solar_absorption}, emissivity={emissivity}")
259
 
260
  elif component.component_type == ComponentType.DOOR:
261
- door_material = getattr(component, 'door_material', None)
262
- if not door_material:
263
- logger.warning(f"No door material defined for {component_name} ({component.component_type.value}). "
264
- f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
265
  else:
266
- # Get door material from library or project
267
- door_material_obj = None
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} ({component.component_type.value}). "
274
- f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
275
  else:
276
- solar_absorption = getattr(door_material_obj, 'solar_absorption', 0.6)
277
  emissivity = getattr(door_material_obj, 'emissivity', 0.9)
278
- logger.debug(f"Using door material for {component_name} ({component.component_type.value}): "
279
- f"solar_absorption={solar_absorption}, emissivity={emissivity}")
280
 
281
  elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
282
- glazing_material = getattr(component, 'glazing_material', None)
283
- if not glazing_material:
284
- logger.warning(f"No glazing material defined for {component_name} ({component.component_type.value}). "
285
- f"Using default SHGC=0.7, h_o={h_o}.")
286
  shgc = 0.7
287
  else:
288
- # Get glazing material from library or project
289
- glazing_material_obj = None
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"Glazing material not found for {component_name} ({component.component_type.value}). "
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} ({component.component_type.value}): "
302
- f"shgc={shgc}, h_o={h_o}")
303
- emissivity = None # Not used for glazing
304
 
305
  except Exception as e:
306
- logger.error(f"Error retrieving surface parameters for {component_name} ({component.component_type.value}): {str(e)}")
307
- # Apply defaults based on component type
308
  if component.component_type == ComponentType.ROOF:
309
- surface_tilt = 0.0 # Horizontal, upward
310
- h_o = 23.0 # W/m²·K for roofs
311
- surface_azimuth = 0.0 # Default north
312
  elif component.component_type == ComponentType.SKYLIGHT:
313
- surface_tilt = 0.0 # Horizontal, upward
314
- h_o = 23.0 # W/m²·K for skylights
315
- surface_azimuth = 0.0 # Default north
316
  elif component.component_type == ComponentType.FLOOR:
317
- surface_tilt = 180.0 # Horizontal, downward
318
- h_o = 17.0 # W/m²·K
319
- surface_azimuth = 0.0 # Default north
320
- else: # WALL, DOOR, WINDOW
321
- surface_tilt = 90.0 # Vertical
322
- h_o = 17.0 # W/m²·K
323
- surface_azimuth = 0.0 # Default north
324
-
325
- # Apply material defaults
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
- return surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption
 
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
- # Ensure MaterialLibrary is properly initialized and accessible
368
- material_library = st.session_state.get("material_library")
369
- if not material_library:
370
- logger.error(f"MaterialLibrary not found in session_state for {component_name} ({component.component_type.value})")
371
- # Instead of raising an error, initialize a new MaterialLibrary
372
- from data.material_library import MaterialLibrary
373
- material_library = MaterialLibrary()
374
- st.session_state.material_library = material_library
375
- logger.info(f"Created new MaterialLibrary for {component_name} ({component.component_type.value})")
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.get("climate_data", {})
384
  latitude = climate_data.get("latitude", 0.0)
385
  longitude = climate_data.get("longitude", 0.0)
386
- timezone = climate_data.get("time_zone", 0.0)
387
-
388
- # Get ground reflectivity (default 0.2)
389
- ground_reflectivity = st.session_state.get("ground_reflectivity", 0.2)
390
 
391
- # Validate input parameters
392
  if not -90 <= latitude <= 90:
393
- logger.warning(f"Invalid latitude {latitude} for {component_name} ({component.component_type.value}). Using default 0.0.")
394
  latitude = 0.0
395
  if not -180 <= longitude <= 180:
396
- logger.warning(f"Invalid longitude {longitude} for {component_name} ({component.component_type.value}). Using default 0.0.")
397
  longitude = 0.0
398
  if not -12 <= timezone <= 14:
399
- logger.warning(f"Invalid timezone {timezone} for {component_name} ({component.component_type.value}). Using default 0.0.")
400
  timezone = 0.0
401
  if not 0 <= ground_reflectivity <= 1:
402
- logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name} ({component.component_type.value}). Using default 0.2.")
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
- "diffuse_horizontal_radiation", "dry_bulb"]
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} ({component.component_type.value}): {hourly_data}")
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} ({component.component_type.value})")
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) # Fallback: estimate DNI
423
- dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3) # Fallback: estimate DHI
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} ({component.component_type.value})")
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} ({component.component_type.value})")
433
 
434
- # Step 1: Local Solar Time (LST) with Equation of Time
435
- year = 2025 # Fixed year since not provided
436
  n = TFMCalculations.day_of_year(month, day, year)
437
  EOT = TFMCalculations.equation_of_time(n)
438
- lambda_std = 15 * timezone # Standard meridian longitude (°)
439
- standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour
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 # North at sunrise/sunset
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: # Afternoon
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} ({component.component_type.value})")
467
-
468
- # Step 5: Get surface parameters with robust error handling
469
- building_info = {"orientation_angle": building_orientation}
470
- try:
471
- surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption = \
472
- TFMCalculations.get_surface_parameters(
473
- component, building_info, material_library, project_materials,
474
- project_constructions, project_glazing_materials, project_door_materials
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
- # Log the calculated values
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
- # Calculate total incident radiation
528
- if cos_theta > 0: # Surface receives direct beam radiation
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
- # For windows/skylights, get SHGC from material
538
- shgc = 0.7 # Default
539
- glazing_material = getattr(component, 'glazing_material', None)
540
- if glazing_material:
541
- glazing_material_obj = None
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
- else:
550
- logger.warning(f"Glazing material not found for {component_name} ({component.component_type.value}). Using default SHGC=0.7.")
551
- else:
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
- # Calculate solar heat gain for fenestration
566
- solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000 # kW
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
- # For opaque surfaces, use solar absorptivity and surface resistance
574
- surface_resistance = 1/h_o # m²·K/W
575
-
576
- # Calculate absorbed solar radiation
577
- solar_heat_gain = component.area * solar_absorption * I_t * surface_resistance / 1000 # kW
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} ({component.component_type.value}) at hour {hour}: {str(e)}")
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["activity_data"]
595
- sensible = (activity_data["sensible_min_w"] + activity_data["sensible_max_w"]) / 2
596
- latent = (activity_data["latent_min_w"] + activity_data["latent_max_w"]) / 2
597
- load_per_person = sensible + latent
598
- total_load += group["num_people"] * load_per_person * group["diversity_factor"]
 
 
599
  for light in internal_loads.get("lighting", []):
600
- lpd = light["lpd"]
601
- lighting_operating_hours = light["operating_hours"]
602
- fraction = min(lighting_operating_hours, operation_hours) / operation_hours if operation_hours > 0 else 0
603
- lighting_load = lpd * area * fraction
604
  total_load += lighting_load
605
- equipment = internal_loads.get("equipment")
606
- if equipment:
607
- total_power_density = equipment.get("total_power_density", 0)
608
- equipment_load = total_power_density * area
 
 
 
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") -> tuple[float, float]:
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
- space_rate = ventilation.get("space_rate", 0.3) # L/s/m²
621
- people_rate = ventilation.get("people_rate", 2.5) # L/s/person
622
- num_people = sum(group["num_people"] for group in internal_loads.get("people", []))
623
- ventilation_flow = (space_rate * area + people_rate * num_people) / 1000 # m³/s
624
- air_density = 1.2 # kg/m³
625
- specific_heat = 1000 # J/kg·K
 
 
 
 
 
 
 
 
 
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 = ventilation_flow * air_density * specific_heat * delta_t / 1000 # kW
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") -> tuple[float, float]:
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
- method = infiltration.get("method", "ACH")
645
- settings = infiltration.get("settings", {})
646
- building_height = building_info.get("building_height", 3.0)
647
- volume = area * building_height #
648
- air_density = 1.2 # kg/m³
649
- specific_heat = 1000 # J/kg·K
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if method == "ACH":
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 # Default to standard setpoint if outside range
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, ignoring year."""
682
- if sim_period["type"] == "Full Year":
683
  return hourly_data
684
  filtered_data = []
685
- if sim_period["type"] == "From-to":
686
- start_month = sim_period["start_date"].month
687
- start_day = sim_period["start_date"].day
688
- end_month = sim_period["end_date"].month
689
- end_day = sim_period["end_date"].day
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["type"] in ["HDD", "CDD"]:
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["type"] == "Fixed":
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": indoor_conditions.get("cooling_setpoint", {}).get("temperature", 24.0),
711
- "rh": indoor_conditions.get("cooling_setpoint", {}).get("rh", 50.0)
712
  }
713
  elif mode == "heating":
714
  return {
715
- "temperature": indoor_conditions.get("heating_setpoint", {}).get("temperature", 22.0),
716
- "rh": indoor_conditions.get("heating_setpoint", {}).get("rh", 50.0)
717
  }
718
  else:
719
  return {"temperature": 24.0, "rh": 50.0}
720
- elif indoor_conditions["type"] == "Time-varying":
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 with user-defined filters and temperature threshold."""
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 for solar calculations")
745
-
746
- # Pre-calculate CTF coefficients for all components using CTFCalculator
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
- # Initialize all loads to 0
757
  conduction_cooling = conduction_heating = solar = internal = ventilation_cooling = ventilation_heating = infiltration_cooling = infiltration_heating = 0
758
- # Check if hour is within operating periods
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: # mode == "none" or not is_operating
794
- internal = 0 # No internal loads when no heating or cooling is needed or outside operating hours
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
- # Group loads by day and apply daily control
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 # Keep cooling components, zero heating total
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
- load["total_heating"] = 0 # Zero both totals, keep components
 
 
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()