mabuseif's picture
Update utils/solar.py
cdcab9b verified
import numpy as np
from typing import List, Dict, Any, Optional, Tuple
import math
from datetime import datetime
from app.materials_library import MaterialLibrary, GlazingMaterial, Material
from utils.ctf_calculations import ComponentType, CTFCalculator
from app.m_c_data import DEFAULT_WINDOW_PROPERTIES
import logging
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class SolarCalculations:
"""Class for performing ASHRAE-compliant solar radiation and angle calculations.
References:
- ASHRAE Handbook—Fundamentals, Chapter 18 (Sol-air temperature)
- ASHRAE Handbook—Fundamentals, Chapter 15 (Dynamic SHGC)
- ASHRAE Handbook—Fundamentals (2021), Chapter 14 (Perez model for diffuse radiation)
- Duffie & Beckman, Solar Engineering of Thermal Processes, 4th Ed., Section 2.16
"""
# Updated dynamic SHGC coefficients (provided by user, May 2025)
SHGC_COEFFICIENTS = {
"Single Clear": [0.1, -0.0, 0.0, -0.0, 0.0, 0.87],
"Single Tinted": [0.12, -0.0, 0.0, -0.0, 0.8, -0.0],
"Double Clear": [0.14, -0.0, 0.0, -0.0, 0.78, -0.0],
"Double Low-E": [0.2, -0.0, 0.0, 0.7, 0.0, -0.0],
"Double Tinted": [0.15, -0.0, 0.0, -0.0, 0.65, -0.0],
"Double Low-E with Argon": [0.18, -0.0, 0.0, 0.68, 0.0, -0.0],
"Single Low-E Reflective": [0.22, -0.0, 0.0, 0.6, 0.0, -0.0],
"Double Reflective": [0.24, -0.0, 0.0, 0.58, 0.0, -0.0],
"Electrochromic": [0.25, -0.0, 0.5, -0.0, 0.0, -0.0]
}
# Mapping of GlazingMaterial names to SHGC types
GLAZING_TYPE_MAPPING = {
"Single Clear 3mm": "Single Clear",
"Single Clear 6mm": "Single Clear",
"Single Tinted 6mm": "Single Tinted",
"Double Clear 6mm/13mm Air": "Double Clear",
"Double Low-E 6mm/13mm Air": "Double Low-E",
"Double Tinted 6mm/13mm Air": "Double Tinted",
"Double Low-E 6mm/13mm Argon": "Double Low-E with Argon",
"Single Low-E Reflective 6mm": "Single Low-E Reflective",
"Double Reflective 6mm/13mm Air": "Double Reflective",
"Electrochromic 6mm/13mm Air": "Electrochromic"
}
# Sky clearness constant from climate_data.py
SKY_CLEARNESS_CONSTANT = 5.535e-6
def __init__(self, material_library: MaterialLibrary, project_materials: Optional[Dict] = None,
project_constructions: Optional[Dict] = None, project_glazing_materials: Optional[Dict] = None,
project_door_materials: Optional[Dict] = None):
"""
Initialize SolarCalculations with material library and optional project-specific libraries.
Args:
material_library: MaterialLibrary instance for accessing library materials/constructions.
project_materials: Dict of project-specific Material objects.
project_constructions: Dict of project-specific Construction objects.
project_glazing_materials: Dict of project-specific GlazingMaterial objects.
project_door_materials: Dict of project-specific DoorMaterial objects.
"""
self.material_library = material_library
self.project_materials = project_materials or {}
self.project_constructions = project_constructions or {}
self.project_glazing_materials = project_glazing_materials or {}
self.project_door_materials = project_door_materials or {}
logger.info("Initialized SolarCalculations with MaterialLibrary and project-specific libraries.")
def calculate_perez_coefficients(self, kt: float, zenith_deg: float) -> Tuple[float, float]:
"""
Calculate Perez model coefficients f1 and f2 for diffuse radiation.
Args:
kt (float): Clearness index (dimensionless).
zenith_deg (float): Zenith angle in degrees.
Returns:
Tuple[float, float]: Coefficients f1 (circumsolar), f2 (horizon brightening).
References:
Duffie & Beckman, Solar Engineering of Thermal Processes, 4th Ed., Section 2.16.
ASHRAE Handbook—Fundamentals (2021), Chapter 14.
"""
if kt <= 0 or zenith_deg >= 90:
logger.debug(f"Invalid kt={kt} or zenith_deg={zenith_deg}, returning f1=0, f2=0")
return 0.0, 0.0
# Convert zenith angle to radians for cosine
zenith_rad = math.radians(zenith_deg)
# Perez model clearness bins (simplified from ASHRAE)
epsilon_bins = [1.065, 1.230, 1.500, 1.950, 2.800, 4.500, 6.200, float('inf')]
epsilon = 1.0 + (self.SKY_CLEARNESS_CONSTANT * hourly_data.get('direct_normal_radiation', 0.0)) / max(hourly_data.get('diffuse_horizontal_radiation', 0.0), 1e-6)
bin_index = next(i for i, bin_edge in enumerate(epsilon_bins) if epsilon <= bin_edge)
# Perez coefficients for f1 (circumsolar) and f2 (horizon) per bin
f1_coeffs = [
[-0.008, 0.588, -0.062, 0.060, 0.072, 0.066, 0.021, -0.040],
[0.130, 0.683, -0.151, -0.019, -0.066, -0.093, -0.084, -0.022],
[0.330, 0.487, -0.221, 0.055, 0.156, 0.219, 0.222, 0.212]
]
f2_coeffs = [
[0.003, -0.039, 0.029, -0.006, -0.011, -0.013, -0.014, -0.013],
[-0.013, -0.081, 0.046, 0.009, 0.019, 0.026, 0.028, 0.029],
[-0.042, -0.068, 0.037, 0.017, 0.013, 0.008, 0.004, -0.001]
]
# Calculate f1 and f2
a = max(math.cos(zenith_rad), 0.087) # Avoid division by zero
f1 = max(0, f1_coeffs[0][bin_index] + f1_coeffs[1][bin_index] * kt + f1_coeffs[2][bin_index] * math.cos(zenith_rad))
f2 = f2_coeffs[0][bin_index] + f2_coeffs[1][bin_index] * kt + f2_coeffs[2][bin_index] * math.cos(zenith_rad)
logger.debug(f"Perez coefficients for kt={kt:.3f}, zenith_deg={zenith_deg:.1f}: f1={f1:.3f}, f2={f2:.3f}")
return f1, f2
@staticmethod
def day_of_year(month: int, day: int, year: int) -> int:
"""Calculate day of the year (n) from month, day, and year, accounting for leap years.
Args:
month (int): Month of the year (1-12).
day (int): Day of the month (1-31).
year (int): Year.
Returns:
int: Day of the year (1-365 or 366 for leap years).
References:
ASHRAE Handbook—Fundamentals, Chapter 18.
"""
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
days_in_month[1] = 29
return sum(days_in_month[:month-1]) + day
@staticmethod
def equation_of_time(n: int) -> float:
"""Calculate Equation of Time (EOT) in minutes using Spencer's formula.
Args:
n (int): Day of the year (1-365 or 366).
Returns:
float: Equation of Time in minutes.
References:
ASHRAE Handbook—Fundamentals, Chapter 18.
"""
B = (n - 1) * 360 / 365
B_rad = math.radians(B)
EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
0.014615 * math.cos(2 * B_rad) - 0.04089 * math.sin(2 * B_rad))
return EOT
def get_surface_parameters(self, component: Any, building_info: Dict) -> Tuple[float, float, float, Optional[float], float]:
"""
Determine surface parameters (tilt, azimuth, h_o, emissivity, absorptivity) for a component.
Uses pre-calculated values stored in the component dictionary from components.py.
Args:
component: Component dictionary with surface_tilt, surface_azimuth, absorptivity/emissivity or shgc,
component_type, and optionally fenestration.
building_info (Dict): Building information (not used since parameters are pre-calculated).
Returns:
Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°),
h_o (W/m²·K), emissivity, absorptivity.
"""
component_name = component.get('name', 'unknown_component')
component_type = component.get('type', 'wall').lower()
# Map string type to ComponentType enum
type_map = {
'walls': ComponentType.WALL,
'roofs': ComponentType.ROOF,
'floors': ComponentType.FLOOR,
'windows': ComponentType.WINDOW,
'skylights': ComponentType.SKYLIGHT,
'wall': ComponentType.WALL,
'roof': ComponentType.ROOF,
'floor': ComponentType.FLOOR,
'window': ComponentType.WINDOW,
'skylight': ComponentType.SKYLIGHT
}
component_type = type_map.get(component_type, ComponentType.WALL)
# Get dynamic h_o using wind speed from component or default
wind_speed = component.get('wind_speed', 4.0) # Default from session state or component
h_o = CTFCalculator.calculate_h_o(wind_speed, component_type)
# Default parameters
if component_type == ComponentType.ROOF:
surface_tilt = component.get('surface_tilt', 0.0)
elif component_type == ComponentType.SKYLIGHT:
surface_tilt = component.get('surface_tilt', 0.0)
elif component_type == ComponentType.FLOOR:
surface_tilt = component.get('surface_tilt', 180.0)
else: # WALL, WINDOW
surface_tilt = component.get('surface_tilt', 90.0)
surface_azimuth = component.get('surface_azimuth', 0.0)
emissivity = component.get('emissivity', 0.9 if component_type in [ComponentType.WALL, ComponentType.ROOF] else None)
absorptivity = component.get('absorptivity', 0.6 if component_type in [ComponentType.WALL, ComponentType.ROOF] else 0.0)
try:
# For windows and skylights, use shgc instead of absorptivity
if component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
fenestration_name = component.get('fenestration', None)
if not fenestration_name:
logger.warning(f"No fenestration defined for {component_name}. Using default SHGC=0.7, h_o={h_o}.")
absorptivity = component.get('shgc', 0.7)
else:
absorptivity = component.get('shgc', 0.7)
logger.debug(f"Using component-stored SHGC for {component_name}: shgc={absorptivity}, h_o={h_o}")
logger.debug(f"Surface parameters for {component_name}: tilt={surface_tilt:.2f}, azimuth={surface_azimuth:.2f}, "
f"h_o={h_o:.2f}, emissivity={emissivity}, absorptivity={absorptivity}")
return surface_tilt, surface_azimuth, h_o, emissivity, absorptivity
except Exception as e:
logger.error(f"Error retrieving surface parameters for {component_name}: {str(e)}")
# Apply defaults
if component_type == ComponentType.ROOF:
surface_tilt = 0.0
h_o = CTFCalculator.calculate_h_o(wind_speed, component_type)
surface_azimuth = 0.0
elif component_type == ComponentType.SKYLIGHT:
surface_tilt = 0.0
h_o = CTFCalculator.calculate_h_o(wind_speed, component_type)
surface_azimuth = 0.0
elif component_type == ComponentType.FLOOR:
surface_tilt = 180.0
h_o = CTFCalculator.calculate_h_o(wind_speed, component_type)
surface_azimuth = 0.0
else: # WALL, WINDOW
surface_tilt = 90.0
h_o = CTFCalculator.calculate_h_o(wind_speed, component_type)
surface_azimuth = 0.0
if component_type in [ComponentType.WALL, ComponentType.ROOF]:
absorptivity = 0.6
emissivity = 0.9
else: # WINDOW, SKYLIGHT
absorptivity = 0.7
emissivity = None
logger.info(f"Default surface parameters for {component_name}: tilt={surface_tilt:.1f}, azimuth={surface_azimuth:.1f}, h_o={h_o:.1f}")
return surface_tilt, surface_azimuth, h_o, emissivity, absorptivity
@staticmethod
def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
"""Calculate dynamic SHGC based on incidence angle.
Args:
glazing_type (str): Type of glazing (e.g., 'Single Clear').
cos_theta (float): Cosine of the angle of incidence.
Returns:
float: Dynamic SHGC value.
References:
ASHRAE Handbook—Fundamentals, Chapter 15, Table 13.
"""
if glazing_type not in SolarCalculations.SHGC_COEFFICIENTS:
logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
glazing_type = "Single Clear"
c = SolarCalculations.SHGC_COEFFICIENTS[glazing_type]
# Incidence angle modifier: f(cos(θ)) = c_0 + c_1·cos(θ) + c_2·cos²(θ) + c_3·cos³(θ) + c_4·cos⁴(θ) + c_5·cos⁵(θ)
f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
return f_cos_theta
def calculate_solar_parameters(
self,
hourly_data: List[Dict[str, Any]],
latitude: float,
longitude: float,
timezone: float,
ground_reflectivity: float,
components: Dict[str, List[Any]]
) -> List[Dict[str, Any]]:
"""
Calculate solar angles, sol-air temperature, and solar heat gain for hourly data with global_horizontal_radiation > 0.
Uses the Perez model for diffuse radiation on tilted surfaces, accounting for anisotropic sky conditions
(circumsolar and horizon brightening). Direct and ground-reflected radiation follow ASHRAE isotropic models.
Args:
hourly_data (List[Dict]): Hourly weather data containing month, day, hour, global_horizontal_radiation,
direct_normal_radiation, diffuse_horizontal_radiation, dry_bulb, dew_point,
wind_speed, total_sky_cover.
latitude (float): Latitude in degrees.
longitude (float): Longitude in degrees.
timezone (float): Timezone offset in hours.
ground_reflectivity (float): Ground reflectivity (albedo, typically 0.2).
components (Dict[str, List]): Dictionary of component lists (e.g., walls, windows) with id, area,
type, facade, construction, fenestration, or door_material.
Returns:
List[Dict]: List of results for each hour with global_horizontal_radiation > 0, containing solar angles
and per-component results (total_incident_radiation, sol_air_temp, solar_heat_gain, etc.).
Raises:
ValueError: If required weather data or component parameters are missing or invalid.
References:
ASHRAE Handbook—Fundamentals (2021), Chapters 14 and 15.
Duffie & Beckman, Solar Engineering of Thermal Processes, 4th Ed., Section 2.16.
"""
year = 2025 # Fixed year since not provided in data
results = []
# Validate input parameters
if not -90 <= latitude <= 90:
logger.warning(f"Invalid latitude {latitude}. Using default 0.0.")
latitude = 0.0
if not -180 <= longitude <= 180:
logger.warning(f"Invalid longitude {longitude}. Using default 0.0.")
longitude = 0.0
if not -12 <= timezone <= 14:
logger.warning(f"Invalid timezone {timezone}. Using default 0.0.")
timezone = 0.0
if not 0 <= ground_reflectivity <= 1:
logger.warning(f"Invalid ground_reflectivity {ground_reflectivity}. Using default 0.2.")
ground_reflectivity = 0.2
logger.info(f"Using parameters: latitude={latitude}, longitude={longitude}, timezone={timezone}, "
f"ground_reflectivity={ground_reflectivity}")
lambda_std = 15 * timezone # Standard meridian longitude (°)
# Cache facade azimuths (used only for walls, windows)
building_info = components.get("_building_info", {})
facade_cache = {
"A": building_info.get("orientation_angle", 0.0),
"B": (building_info.get("orientation_angle", 0.0) + 90.0) % 360,
"C": (building_info.get("orientation_angle", 0.0) + 180.0) % 360,
"D": (building_info.get("orientation_angle", 0.0) + 270.0) % 360
}
for record in hourly_data:
# Step 1: Extract and validate data
month = record.get("month")
day = record.get("day")
hour = record.get("hour")
global_horizontal_radiation = record.get("global_horizontal_radiation", 0.0)
direct_normal_radiation = record.get("direct_normal_radiation", global_horizontal_radiation * 0.7)
diffuse_horizontal_radiation = record.get("diffuse_horizontal_radiation", global_horizontal_radiation * 0.3)
outdoor_temp = record.get("dry_bulb")
dew_point = record.get("dew_point", outdoor_temp - 5.0) # Default
wind_speed = record.get("wind_speed", 4.0) # Default
total_sky_cover = record.get("total_sky_cover", 0.5) # Default
# Validate radiation inputs
if diffuse_horizontal_radiation > global_horizontal_radiation:
logger.warning(f"Diffuse radiation {diffuse_horizontal_radiation} exceeds global {global_horizontal_radiation} "
f"at {month}/{day}/{hour}. Capping diffuse to global.")
diffuse_horizontal_radiation = global_horizontal_radiation
if None in [month, day, hour, outdoor_temp]:
logger.error(f"Missing required weather data for {month}/{day}/{hour}")
raise ValueError(f"Missing required weather data for {month}/{day}/{hour}")
if global_horizontal_radiation < 0 or direct_normal_radiation < 0 or diffuse_horizontal_radiation < 0:
logger.error(f"Negative radiation values for {month}/{day}/{hour}")
raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
if global_horizontal_radiation <= 0:
logger.info(f"Skipping hour {month}/{day}/{hour} due to global_horizontal_radiation={global_horizontal_radiation} <= 0")
continue # Skip hours with no solar radiation
logger.info(f"Processing solar for {month}/{day}/{hour} with global_horizontal_radiation={global_horizontal_radiation}, "
f"direct_normal_radiation={direct_normal_radiation}, diffuse_horizontal_radiation={diffuse_horizontal_radiation}, "
f"dry_bulb={outdoor_temp}, dew_point={dew_point}, wind_speed={wind_speed}, "
f"total_sky_cover={total_sky_cover}")
# Step 2: Local Solar Time (LST) with Equation of Time
n = self.day_of_year(month, day, year)
EOT = self.equation_of_time(n)
standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour
LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
# Step 3: Solar Declination (δ)
delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
# Step 4: Hour Angle (HRA)
hra = 15 * (LST - 12)
# Step 5: Solar Altitude (α) and Azimuth (ψ)
phi = math.radians(latitude)
delta_rad = math.radians(delta)
hra_rad = math.radians(hra)
sin_alpha = math.sin(phi) * math.sin(delta_rad) + math.cos(phi) * math.cos(delta_rad) * math.cos(hra_rad)
alpha = math.degrees(math.asin(sin_alpha))
if abs(math.cos(math.radians(alpha))) < 0.01:
azimuth = 0 # North at sunrise/sunset
else:
sin_az = math.cos(delta_rad) * math.sin(hra_rad) / math.cos(math.radians(alpha))
cos_az = (sin_alpha * math.sin(phi) - math.sin(delta_rad)) / (math.cos(math.radians(alpha)) * math.cos(phi))
azimuth = math.degrees(math.atan2(sin_az, cos_az))
if hra > 0: # Afternoon
azimuth = 360 - azimuth if azimuth > 0 else -azimuth
logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f}")
# Calculate clearness index (kt) and Perez coefficients once per hour
zenith_deg = 90 - alpha # Zenith angle = 90 - altitude
if global_horizontal_radiation > 0 and zenith_deg < 90:
kt = global_horizontal_radiation / (1367 * math.cos(math.radians(zenith_deg)))
kt = max(min(kt, 1.0), 0.0) # Clamp kt to [0, 1]
f1, f2 = self.calculate_perez_coefficients(kt, zenith_deg)
else:
kt, f1, f2 = 0.0, 0.0, 0.0
# Step 6: Component-specific calculations
component_results = []
for comp_type, comp_list in components.items():
if comp_type == "_building_info":
continue
for comp in comp_list:
try:
# Validate adiabatic and ground_contact mutual exclusivity
if comp.get('adiabatic', False) and comp.get('ground_contact', False):
logger.warning(f"Component {comp.get('name', 'unknown_component')} has both adiabatic and ground_contact set to True. Treating as adiabatic, setting ground_contact to False.")
comp['ground_contact'] = False
# Get surface parameters
surface_tilt, surface_azimuth, h_o, emissivity, absorptivity = \
self.get_surface_parameters(comp, building_info)
# For windows/skylights, get SHGC from component
shgc = comp.get('shgc', 0.7)
fenestration_name = comp.get('fenestration', None)
# Calculate angle of incidence (θ)
cos_theta = (math.sin(math.radians(alpha)) * math.cos(math.radians(surface_tilt)) +
math.cos(math.radians(alpha)) * math.sin(math.radians(surface_tilt)) *
math.cos(math.radians(azimuth - surface_azimuth)))
cos_theta = max(min(cos_theta, 1.0), 0.0) # Clamp to [0, 1]
logger.info(f" Component {comp.get('name', 'unknown_component')} at {month}/{day}/{hour}: "
f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
f"cos_theta={cos_theta:.2f}")
# Calculate total incident radiation (I_t) with Perez model
view_factor = (1 - math.cos(math.radians(surface_tilt))) / 2
ground_reflected = ground_reflectivity * global_horizontal_radiation * view_factor
if global_horizontal_radiation > 0 and zenith_deg < 90:
diffuse_tilted = diffuse_horizontal_radiation * (
(1 - f1) * (1 + math.cos(math.radians(surface_tilt))) / 2 +
f1 * cos_theta / max(math.cos(math.radians(zenith_deg)), 0.087) +
f2 * math.sin(math.radians(surface_tilt))
)
else:
diffuse_tilted = 0.0
direct_tilted = direct_normal_radiation * max(cos_theta, 0.0)
I_t = direct_tilted + diffuse_tilted + ground_reflected
I_t = max(I_t, 0.0) # Ensure non-negative radiation
# Initialize result
comp_result = {
"component_id": comp.get('name', 'unknown_component'),
"total_incident_radiation": round(I_t, 2),
"absorptivity": round(absorptivity, 2),
"emissivity": round(emissivity, 2) if emissivity is not None else None
}
# Skip calculations for adiabatic surfaces
if comp.get('adiabatic', False):
logger.info(f"Skipping solar calculations for adiabatic component {comp_result['component_id']} at {month}/{day}/{hour}")
component_results.append(comp_result)
continue
# Handle ground-contact surfaces
if comp.get('ground_contact', False):
# Validate component type
component_type = comp.get('type', '').lower()
valid_types = ['walls', 'roofs', 'floors']
if component_type not in valid_types:
logger.warning(f"Invalid ground-contact component type '{component_type}' for {comp_result['component_id']}. Skipping ground temperature assignment.")
component_results.append(comp_result)
continue
# Retrieve ground temperature
climate_data = st.session_state.project_data.get("climate_data", {})
ground_temperatures = climate_data.get("ground_temperatures", {})
depth = "2" # Default depth
if depth not in ground_temperatures or not ground_temperatures[depth]:
logger.warning(f"No ground temperature data for depth {depth} m for {month}/{day}/{hour}. Using default 18°C.")
ground_temp = 18.0
else:
ground_temp = ground_temperatures[depth][month-1] if len(ground_temperatures[depth]) >= month else 18.0
comp_result["ground_temp"] = round(ground_temp, 2)
logger.info(f"Ground-contact component {comp_result['component_id']} at {month}/{day}/{hour}: ground_temp={ground_temp:.2f}°C")
component_results.append(comp_result)
continue
# Calculate sol-air temperature for opaque surfaces (non-ground-contact)
if comp.get('type', '').lower() in ['walls', 'roofs'] and not comp.get('ground_contact', False):
T_sol_air = CTFCalculator.calculate_sol_air_temperature(
outdoor_temp, I_t, absorptivity, emissivity or 0.9,
h_o, dew_point, total_sky_cover
)
comp_result["sol_air_temp"] = round(T_sol_air, 2)
logger.info(f"Sol-air temp for {comp_result['component_id']} at {month}/{day}/{hour}: {T_sol_air:.2f}°C")
# Calculate solar heat gain for fenestration
elif comp.get('type', '').lower() in ['windows', 'skylights']:
glazing_type = self.GLAZING_TYPE_MAPPING.get(comp.get('fenestration', ''), 'Single Clear')
iac = comp.get('shading_coefficient', 1.0)
# Adjust shading coefficient based on shading type
shading_type = comp.get('shading_type', 'No shading')
if shading_type == "External Shading":
iac *= 0.6
elif shading_type == "Internal Shading":
iac *= 0.8
shgc_dynamic = shgc * self.calculate_dynamic_shgc(glazing_type, cos_theta)
solar_heat_gain = comp.get('area', 0.0) * shgc_dynamic * I_t * iac / 1000 # kW
comp_result["solar_heat_gain"] = round(solar_heat_gain, 2)
comp_result["shgc_dynamic"] = round(shgc_dynamic, 2)
logger.info(f"Solar heat gain for {comp_result['component_id']} at {month}/{day}/{hour}: "
f"{solar_heat_gain:.2f} kW (area={comp.get('area', 0.0)}, shgc_dynamic={shgc_dynamic:.2f}, "
f"I_t={I_t:.2f}, iac={iac}, shading_type={shading_type})")
component_results.append(comp_result)
except Exception as e:
component_name = comp.get('name', 'unknown_component')
logger.error(f"Error processing component {component_name} at {month}/{day}/{hour}: {str(e)}")
continue
# Store results for this hour
result = {
"month": month,
"day": day,
"hour": hour,
"declination": round(delta, 2),
"LST": round(LST, 2),
"HRA": round(hra, 2),
"altitude": round(alpha, 2),
"azimuth": round(azimuth, 2),
"component_results": component_results
}
results.append(result)
return results