mabuseif commited on
Commit
46d1490
·
verified ·
1 Parent(s): 6131762

Update data/calculation.py

Browse files
Files changed (1) hide show
  1. data/calculation.py +363 -42
data/calculation.py CHANGED
@@ -7,22 +7,48 @@ Developed by: Dr Majed Abuseif, Deakin University
7
 
8
  import numpy as np
9
  import pandas as pd
10
- from typing import Dict, List, Optional, NamedTuple
11
  from enum import Enum
12
  import streamlit as st
13
- from data.material_library import Construction, GlazingMaterial, DoorMaterial
14
  from data.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS
15
  from datetime import datetime
16
  from collections import defaultdict
17
  import logging
 
18
  from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
19
- from utils.solar import SolarCalculations
20
 
21
  # Configure logging
22
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
23
  logger = logging.getLogger(__name__)
24
 
25
  class TFMCalculations:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  @staticmethod
27
  def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> tuple[float, float]:
28
  """Calculate conduction load for heating and cooling in kW based on mode."""
@@ -48,6 +74,222 @@ class TFMCalculations:
48
  heating_load = -load / 1000 if mode == "heating" else 0
49
  return cooling_load, heating_load
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  @staticmethod
52
  def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
53
  """Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations.
@@ -69,19 +311,16 @@ class TFMCalculations:
69
  return 0
70
 
71
  try:
72
- # Initialize SolarCalculations with MaterialLibrary from session state
73
  material_library = st.session_state.get("material_library")
74
  if not material_library:
75
  logger.error("MaterialLibrary not found in session_state")
76
  raise ValueError("MaterialLibrary not found in session_state")
77
 
78
- solar_calc = SolarCalculations(
79
- material_library=material_library,
80
- project_materials=st.session_state.get("project_materials", {}),
81
- project_constructions=st.session_state.get("project_constructions", {}),
82
- project_glazing_materials=st.session_state.get("project_glazing_materials", {}),
83
- project_door_materials=st.session_state.get("project_door_materials", {})
84
- )
85
 
86
  # Get location parameters from climate_data
87
  climate_data = st.session_state.get("climate_data", {})
@@ -89,16 +328,22 @@ class TFMCalculations:
89
  longitude = climate_data.get("longitude", 0.0)
90
  timezone = climate_data.get("time_zone", 0.0)
91
 
92
- # Get ground reflectivity (default 0.2, to be added to session_state later)
93
  ground_reflectivity = st.session_state.get("ground_reflectivity", 0.2)
94
 
95
- # Prepare components dictionary for SolarCalculations
96
- components = {
97
- "fenestration": [component], # Single component for this call
98
- "_building_info": {
99
- "A": {"azimuth": building_orientation, "tilt_angle": 0.0}
100
- }
101
- }
 
 
 
 
 
 
102
 
103
  # Ensure hourly_data has required fields
104
  required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
@@ -112,33 +357,109 @@ class TFMCalculations:
112
  logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']}")
113
  return 0
114
 
115
- # Call calculate_solar_parameters for this hour
116
- results = solar_calc.calculate_solar_parameters(
117
- hourly_data=[hourly_data], # Single record as list
118
- latitude=latitude,
119
- longitude=longitude,
120
- timezone=timezone,
121
- ground_reflectivity=ground_reflectivity,
122
- components=components
123
- )
124
-
125
- # Extract solar heat gain for the component
126
- if not results:
127
- logger.warning(f"No solar results for hour {hour}")
128
- return 0
129
 
130
- result = results[0] # Single hour
131
- for comp_result in result.get("component_results", []):
132
- if comp_result["component_id"] == getattr(component, 'id', 'unknown_component'):
133
- solar_heat_gain = comp_result.get("solar_heat_gain", 0.0)
134
- logger.info(f"Solar load for component {comp_result['component_id']} at hour {hour}: {solar_heat_gain:.2f} kW")
135
- return solar_heat_gain
136
 
137
- logger.warning(f"No matching component found in solar results for hour {hour}")
138
- return 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
  except Exception as e:
141
- logger.error(f"Error calculating solar load for component {getattr(component, 'id', 'unknown_component')} at hour {hour}: {str(e)}")
 
142
  return 0
143
 
144
  @staticmethod
 
7
 
8
  import numpy as np
9
  import pandas as pd
10
+ from typing import Dict, List, Optional, NamedTuple, Any, Tuple
11
  from enum import Enum
12
  import streamlit as st
13
+ from data.material_library import Construction, GlazingMaterial, DoorMaterial, Material, MaterialLibrary
14
  from data.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS
15
  from datetime import datetime
16
  from collections import defaultdict
17
  import logging
18
+ import math
19
  from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
 
20
 
21
  # Configure logging
22
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
23
  logger = logging.getLogger(__name__)
24
 
25
  class TFMCalculations:
26
+ # Solar calculation constants (from solar.py)
27
+ SHGC_COEFFICIENTS = {
28
+ "Single Clear": [0.1, -0.0, 0.0, -0.0, 0.0, 0.87],
29
+ "Single Tinted": [0.12, -0.0, 0.0, -0.0, 0.8, -0.0],
30
+ "Double Clear": [0.14, -0.0, 0.0, -0.0, 0.78, -0.0],
31
+ "Double Low-E": [0.2, -0.0, 0.0, 0.7, 0.0, -0.0],
32
+ "Double Tinted": [0.15, -0.0, 0.0, -0.0, 0.65, -0.0],
33
+ "Double Low-E with Argon": [0.18, -0.0, 0.0, 0.68, 0.0, -0.0],
34
+ "Single Low-E Reflective": [0.22, -0.0, 0.0, 0.6, 0.0, -0.0],
35
+ "Double Reflective": [0.24, -0.0, 0.0, 0.58, 0.0, -0.0],
36
+ "Electrochromic": [0.25, -0.0, 0.5, -0.0, 0.0, -0.0]
37
+ }
38
+
39
+ GLAZING_TYPE_MAPPING = {
40
+ "Single Clear 3mm": "Single Clear",
41
+ "Single Clear 6mm": "Single Clear",
42
+ "Single Tinted 6mm": "Single Tinted",
43
+ "Double Clear 6mm/13mm Air": "Double Clear",
44
+ "Double Low-E 6mm/13mm Air": "Double Low-E",
45
+ "Double Tinted 6mm/13mm Air": "Double Tinted",
46
+ "Double Low-E 6mm/13mm Argon": "Double Low-E with Argon",
47
+ "Single Low-E Reflective 6mm": "Single Low-E Reflective",
48
+ "Double Reflective 6mm/13mm Air": "Double Reflective",
49
+ "Electrochromic 6mm/13mm Air": "Electrochromic"
50
+ }
51
+
52
  @staticmethod
53
  def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> tuple[float, float]:
54
  """Calculate conduction load for heating and cooling in kW based on mode."""
 
74
  heating_load = -load / 1000 if mode == "heating" else 0
75
  return cooling_load, heating_load
76
 
77
+ @staticmethod
78
+ def day_of_year(month: int, day: int, year: int) -> int:
79
+ """Calculate day of the year (n) from month, day, and year, accounting for leap years.
80
+
81
+ Args:
82
+ month (int): Month of the year (1-12).
83
+ day (int): Day of the month (1-31).
84
+ year (int): Year.
85
+
86
+ Returns:
87
+ int: Day of the year (1-365 or 366 for leap years).
88
+
89
+ References:
90
+ ASHRAE Handbook—Fundamentals, Chapter 18.
91
+ """
92
+ days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
93
+ if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
94
+ days_in_month[1] = 29
95
+ return sum(days_in_month[:month-1]) + day
96
+
97
+ @staticmethod
98
+ def equation_of_time(n: int) -> float:
99
+ """Calculate Equation of Time (EOT) in minutes using Spencer's formula.
100
+
101
+ Args:
102
+ n (int): Day of the year (1-365 or 366).
103
+
104
+ Returns:
105
+ float: Equation of Time in minutes.
106
+
107
+ References:
108
+ ASHRAE Handbook—Fundamentals, Chapter 18.
109
+ """
110
+ B = (n - 1) * 360 / 365
111
+ B_rad = math.radians(B)
112
+ EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
113
+ 0.014615 * math.cos(2 * B_rad) - 0.04089 * math.sin(2 * B_rad))
114
+ return EOT
115
+
116
+ @staticmethod
117
+ def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
118
+ """Calculate dynamic SHGC based on incidence angle.
119
+
120
+ Args:
121
+ glazing_type (str): Type of glazing (e.g., 'Single Clear').
122
+ cos_theta (float): Cosine of the angle of incidence.
123
+
124
+ Returns:
125
+ float: Dynamic SHGC value.
126
+
127
+ References:
128
+ ASHRAE Handbook—Fundamentals, Chapter 15, Table 13.
129
+ """
130
+ if glazing_type not in TFMCalculations.SHGC_COEFFICIENTS:
131
+ logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
132
+ glazing_type = "Single Clear"
133
+
134
+ c = TFMCalculations.SHGC_COEFFICIENTS[glazing_type]
135
+ # Incidence angle modifier: f(cos(θ)) = c_0 + c_1·cos(θ) + c_2·cos²(θ) + c_3·cos³(θ) + c_4·cos⁴(θ) + c_5·cos⁵(θ)
136
+ f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
137
+ c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
138
+ return f_cos_theta
139
+
140
+ @staticmethod
141
+ def get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary,
142
+ project_materials: Dict, project_constructions: Dict,
143
+ project_glazing_materials: Dict, project_door_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
144
+ """
145
+ Determine surface parameters (tilt, azimuth, h_o, emissivity, solar_absorption) for a component.
146
+ Uses MaterialLibrary to fetch properties from first layer for walls/roofs, DoorMaterial for doors,
147
+ and GlazingMaterial for windows/skylights. Handles orientation and tilt based on component type:
148
+ - Walls, Doors, Windows: Azimuth = facade base azimuth + component.rotation; Tilt = 90°.
149
+ - Roofs, Skylights: Azimuth = component.orientation; Tilt = component.tilt (default 180°).
150
+
151
+ Args:
152
+ component: Component object with component_type, facade, rotation, orientation, tilt,
153
+ construction, glazing_material, or door_material.
154
+ building_info (Dict): Building information containing orientation_angle for facade mapping.
155
+ material_library: MaterialLibrary instance for accessing library materials/constructions.
156
+ project_materials: Dict of project-specific Material objects.
157
+ project_constructions: Dict of project-specific Construction objects.
158
+ project_glazing_materials: Dict of project-specific GlazingMaterial objects.
159
+ project_door_materials: Dict of project-specific DoorMaterial objects.
160
+
161
+ Returns:
162
+ Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°),
163
+ h_o (W/m²·K), emissivity, solar_absorption.
164
+
165
+ Raises:
166
+ ValueError: If facade is missing or invalid for walls, doors, or windows.
167
+ """
168
+ # Default parameters
169
+ if component.component_type == ComponentType.ROOF:
170
+ surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent
171
+ h_o = 23.0 # W/m²·K for roofs
172
+ elif component.component_type == ComponentType.SKYLIGHT:
173
+ surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent
174
+ h_o = 23.0 # W/m²·K for skylights
175
+ elif component.component_type == ComponentType.FLOOR:
176
+ surface_tilt = 0.0 # Horizontal, upward
177
+ h_o = 17.0 # W/m²·K
178
+ else: # WALL, DOOR, WINDOW
179
+ surface_tilt = 90.0 # Vertical
180
+ h_o = 17.0 # W/m²·K
181
+
182
+ emissivity = 0.9 # Default for opaque components
183
+ solar_absorption = 0.6 # Default
184
+ shgc = None # Only for windows/skylights
185
+
186
+ try:
187
+ # Determine surface azimuth
188
+ if component.component_type in [ComponentType.ROOF, ComponentType.SKYLIGHT]:
189
+ # Use component's orientation attribute directly, ignoring facade
190
+ surface_azimuth = getattr(component, 'orientation', 0.0)
191
+ logger.debug(f"Using component orientation for {component.id}: "
192
+ f"azimuth={surface_azimuth}, tilt={surface_tilt}")
193
+ else: # WALL, DOOR, WINDOW
194
+ # Check for facade attribute
195
+ facade = getattr(component, 'facade', None)
196
+ if not facade:
197
+ component_id = getattr(component, 'id', 'unknown_component')
198
+ raise ValueError(f"Component {component_id} is missing 'facade' field")
199
+
200
+ # Define facade azimuths based on building orientation_angle
201
+ base_azimuth = building_info.get("orientation_angle", 0.0)
202
+ facade_angles = {
203
+ "A": base_azimuth,
204
+ "B": (base_azimuth + 90.0) % 360,
205
+ "C": (base_azimuth + 180.0) % 360,
206
+ "D": (base_azimuth + 270.0) % 360
207
+ }
208
+
209
+ if facade not in facade_angles:
210
+ component_id = getattr(component, 'id', 'unknown_component')
211
+ raise ValueError(f"Invalid facade '{facade}' for component {component_id}. "
212
+ f"Expected one of {list(facade_angles.keys())}")
213
+
214
+ # Add component rotation to facade azimuth
215
+ surface_azimuth = (facade_angles[facade] + getattr(component, 'rotation', 0.0)) % 360
216
+ logger.debug(f"Component {component.id}: facade={facade}, "
217
+ f"base_azimuth={facade_angles[facade]}, rotation={getattr(component, 'rotation', 0.0)}, "
218
+ f"total_azimuth={surface_azimuth}, tilt={surface_tilt}")
219
+
220
+ # Fetch material properties
221
+ if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
222
+ construction = getattr(component, 'construction', None)
223
+ if not construction:
224
+ logger.warning(f"No construction defined for {component.id}. "
225
+ f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
226
+ else:
227
+ # Get construction from library or project
228
+ construction_obj = (project_constructions.get(construction.name) or
229
+ material_library.library_constructions.get(construction.name))
230
+ if not construction_obj:
231
+ logger.error(f"Construction '{construction.name}' not found for {component.id}.")
232
+ elif not construction_obj.layers:
233
+ logger.warning(f"No layers in construction '{construction.name}' for {component.id}.")
234
+ else:
235
+ # Use first (outermost) layer's properties
236
+ first_layer = construction_obj.layers[0]
237
+ material = first_layer["material"]
238
+ solar_absorption = material.solar_absorption
239
+ emissivity = material.emissivity
240
+ logger.debug(f"Using first layer material '{material.name}' for {component.id}: "
241
+ f"solar_absorption={solar_absorption}, emissivity={emissivity}")
242
+
243
+ elif component.component_type == ComponentType.DOOR:
244
+ door_material = getattr(component, 'door_material', None)
245
+ if not door_material:
246
+ logger.warning(f"No door material defined for {component.id}. "
247
+ f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
248
+ else:
249
+ # Get door material from library or project
250
+ door_material_obj = (project_door_materials.get(door_material.name) or
251
+ material_library.library_door_materials.get(door_material.name))
252
+ if not door_material_obj:
253
+ logger.error(f"Door material '{door_material.name}' not found for {component.id}.")
254
+ else:
255
+ solar_absorption = door_material_obj.solar_absorption
256
+ emissivity = door_material_obj.emissivity
257
+ logger.debug(f"Using door material '{door_material_obj.name}' for {component.id}: "
258
+ f"solar_absorption={solar_absorption}, emissivity={emissivity}")
259
+
260
+ elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
261
+ glazing_material = getattr(component, 'glazing_material', None)
262
+ if not glazing_material:
263
+ logger.warning(f"No glazing material defined for {component.id}. "
264
+ f"Using default SHGC=0.7, h_o={h_o}.")
265
+ shgc = 0.7
266
+ else:
267
+ # Get glazing material from library or project
268
+ glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
269
+ material_library.library_glazing_materials.get(glazing_material.name))
270
+ if not glazing_material_obj:
271
+ logger.error(f"Glazing material '{glazing_material.name}' not found for {component.id}.")
272
+ shgc = 0.7
273
+ else:
274
+ shgc = glazing_material_obj.shgc
275
+ h_o = glazing_material_obj.h_o
276
+ logger.debug(f"Using glazing material '{glazing_material_obj.name}' for {component.id}: "
277
+ f"shgc={shgc}, h_o={h_o}")
278
+ emissivity = None # Not used for glazing
279
+
280
+ except Exception as e:
281
+ component_id = getattr(component, 'id', 'unknown_component')
282
+ logger.error(f"Error retrieving surface parameters for {component_id}: {str(e)}")
283
+ # Apply defaults
284
+ if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
285
+ solar_absorption = 0.6
286
+ emissivity = 0.9
287
+ else: # WINDOW, SKYLIGHT
288
+ shgc = 0.7
289
+ # h_o retains default from component type
290
+
291
+ return surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption
292
+
293
  @staticmethod
294
  def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
295
  """Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations.
 
311
  return 0
312
 
313
  try:
314
+ # Get MaterialLibrary and project-specific data from session state
315
  material_library = st.session_state.get("material_library")
316
  if not material_library:
317
  logger.error("MaterialLibrary not found in session_state")
318
  raise ValueError("MaterialLibrary not found in session_state")
319
 
320
+ project_materials = st.session_state.get("project_materials", {})
321
+ project_constructions = st.session_state.get("project_constructions", {})
322
+ project_glazing_materials = st.session_state.get("project_glazing_materials", {})
323
+ project_door_materials = st.session_state.get("project_door_materials", {})
 
 
 
324
 
325
  # Get location parameters from climate_data
326
  climate_data = st.session_state.get("climate_data", {})
 
328
  longitude = climate_data.get("longitude", 0.0)
329
  timezone = climate_data.get("time_zone", 0.0)
330
 
331
+ # Get ground reflectivity (default 0.2)
332
  ground_reflectivity = st.session_state.get("ground_reflectivity", 0.2)
333
 
334
+ # Validate input parameters
335
+ if not -90 <= latitude <= 90:
336
+ logger.warning(f"Invalid latitude {latitude}. Using default 0.0.")
337
+ latitude = 0.0
338
+ if not -180 <= longitude <= 180:
339
+ logger.warning(f"Invalid longitude {longitude}. Using default 0.0.")
340
+ longitude = 0.0
341
+ if not -12 <= timezone <= 14:
342
+ logger.warning(f"Invalid timezone {timezone}. Using default 0.0.")
343
+ timezone = 0.0
344
+ if not 0 <= ground_reflectivity <= 1:
345
+ logger.warning(f"Invalid ground_reflectivity {ground_reflectivity}. Using default 0.2.")
346
+ ground_reflectivity = 0.2
347
 
348
  # Ensure hourly_data has required fields
349
  required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
 
357
  logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']}")
358
  return 0
359
 
360
+ # Extract weather data
361
+ month = hourly_data["month"]
362
+ day = hourly_data["day"]
363
+ hour = hourly_data["hour"]
364
+ ghi = hourly_data["global_horizontal_radiation"]
365
+ dni = hourly_data.get("direct_normal_radiation", ghi * 0.7) # Fallback: estimate DNI
366
+ dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3) # Fallback: estimate DHI
367
+ outdoor_temp = hourly_data["dry_bulb"]
 
 
 
 
 
 
368
 
369
+ if ghi < 0 or dni < 0 or dhi < 0:
370
+ logger.error(f"Negative radiation values for {month}/{day}/{hour}")
371
+ raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
 
 
 
372
 
373
+ logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
374
+ f"dry_bulb={outdoor_temp}")
375
+
376
+ # Step 1: Local Solar Time (LST) with Equation of Time
377
+ year = 2025 # Fixed year since not provided
378
+ n = TFMCalculations.day_of_year(month, day, year)
379
+ EOT = TFMCalculations.equation_of_time(n)
380
+ lambda_std = 15 * timezone # Standard meridian longitude (°)
381
+ standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour
382
+ LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
383
+
384
+ # Step 2: Solar Declination (δ)
385
+ delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
386
+
387
+ # Step 3: Hour Angle (HRA)
388
+ hra = 15 * (LST - 12)
389
+
390
+ # Step 4: Solar Altitude (α) and Azimuth (ψ)
391
+ phi = math.radians(latitude)
392
+ delta_rad = math.radians(delta)
393
+ hra_rad = math.radians(hra)
394
+
395
+ sin_alpha = math.sin(phi) * math.sin(delta_rad) + math.cos(phi) * math.cos(delta_rad) * math.cos(hra_rad)
396
+ alpha = math.degrees(math.asin(sin_alpha))
397
+
398
+ if abs(math.cos(math.radians(alpha))) < 0.01:
399
+ azimuth = 0 # North at sunrise/sunset
400
+ else:
401
+ sin_az = math.cos(delta_rad) * math.sin(hra_rad) / math.cos(math.radians(alpha))
402
+ cos_az = (sin_alpha * math.sin(phi) - math.sin(delta_rad)) / (math.cos(math.radians(alpha)) * math.cos(phi))
403
+ azimuth = math.degrees(math.atan2(sin_az, cos_az))
404
+ if hra > 0: # Afternoon
405
+ azimuth = 360 - azimuth if azimuth > 0 else -azimuth
406
+
407
+ logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
408
+ f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f}")
409
+
410
+ # Step 5: Get surface parameters
411
+ building_info = {"orientation_angle": building_orientation}
412
+ surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption = \
413
+ TFMCalculations.get_surface_parameters(
414
+ component, building_info, material_library, project_materials,
415
+ project_constructions, project_glazing_materials, project_door_materials
416
+ )
417
+
418
+ # For windows/skylights, get SHGC from material
419
+ shgc = 0.7 # Default
420
+ if component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
421
+ glazing_material = getattr(component, 'glazing_material', None)
422
+ if glazing_material:
423
+ glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
424
+ material_library.library_glazing_materials.get(glazing_material.name))
425
+ if glazing_material_obj:
426
+ shgc = glazing_material_obj.shgc
427
+ h_o = glazing_material_obj.h_o
428
+ else:
429
+ logger.warning(f"Glazing material '{glazing_material.name}' not found for {component.id}. Using default SHGC=0.7.")
430
+ else:
431
+ logger.warning(f"No glazing material defined for {component.id}. Using default SHGC=0.7.")
432
+
433
+ # Step 6: Calculate angle of incidence (θ)
434
+ cos_theta = (math.sin(math.radians(alpha)) * math.cos(math.radians(surface_tilt)) +
435
+ math.cos(math.radians(alpha)) * math.sin(math.radians(surface_tilt)) *
436
+ math.cos(math.radians(azimuth - surface_azimuth)))
437
+ cos_theta = max(min(cos_theta, 1.0), 0.0) # Clamp to [0, 1]
438
+
439
+ logger.info(f" Component {getattr(component, 'id', 'unknown_component')} at {month}/{day}/{hour}: "
440
+ f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
441
+ f"cos_theta={cos_theta:.2f}")
442
+
443
+ # Step 7: Calculate total incident radiation (I_t)
444
+ view_factor = (1 - math.cos(math.radians(surface_tilt))) / 2
445
+ ground_reflected = ground_reflectivity * ghi * view_factor
446
+ I_t = dni * cos_theta + dhi + ground_reflected
447
+
448
+ # Step 8: Calculate solar heat gain for fenestration
449
+ glazing_type = TFMCalculations.GLAZING_TYPE_MAPPING.get(component.name, 'Single Clear')
450
+ iac = getattr(component, 'iac', 1.0) # Default internal shading
451
+ shgc_dynamic = shgc * TFMCalculations.calculate_dynamic_shgc(glazing_type, cos_theta)
452
+ solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000 # kW
453
+
454
+ logger.info(f"Solar heat gain for {getattr(component, 'id', 'unknown_component')} at {month}/{day}/{hour}: "
455
+ f"{solar_heat_gain:.2f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.2f}, "
456
+ f"I_t={I_t:.2f}, iac={iac})")
457
+
458
+ return solar_heat_gain
459
 
460
  except Exception as e:
461
+ component_id = getattr(component, 'id', 'unknown_component')
462
+ logger.error(f"Error calculating solar load for component {component_id} at hour {hour}: {str(e)}")
463
  return 0
464
 
465
  @staticmethod