Spaces:
Sleeping
Sleeping
| 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 | |
| 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 | |
| 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 | |
| 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 |