Spaces:
Sleeping
Sleeping
| """ | |
| HVAC Calculator Code Documentation | |
| Developed by: Dr Majed Abuseif, Deakin University | |
| © 2025 | |
| """ | |
| import numpy as np | |
| import pandas as pd | |
| from typing import Dict, List, Optional, NamedTuple, Any, Tuple | |
| from enum import Enum | |
| import streamlit as st | |
| from data.material_library import Construction, GlazingMaterial, DoorMaterial, Material, MaterialLibrary | |
| from data.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS | |
| from datetime import datetime | |
| from collections import defaultdict | |
| import logging | |
| import math | |
| from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| logger = logging.getLogger(__name__) | |
| class TFMCalculations: | |
| # Solar calculation constants (from solar.py) | |
| 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] | |
| } | |
| 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" | |
| } | |
| def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> tuple[float, float]: | |
| """Calculate conduction load for heating and cooling in kW based on mode.""" | |
| if mode == "none": | |
| return 0, 0 | |
| delta_t = outdoor_temp - indoor_temp | |
| if mode == "cooling" and delta_t <= 0: | |
| return 0, 0 | |
| if mode == "heating" and delta_t >= 0: | |
| return 0, 0 | |
| # Get CTF coefficients using CTFCalculator | |
| ctf = CTFCalculator.calculate_ctf_coefficients(component) | |
| # Initialize history terms (simplified: assume steady-state history for demonstration) | |
| # In practice, maintain temperature and flux histories | |
| load = component.u_value * component.area * delta_t | |
| for i in range(len(ctf.Y)): | |
| load += component.area * ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600) | |
| load -= component.area * ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600) | |
| # Note: F terms require flux history, omitted here for simplicity | |
| cooling_load = load / 1000 if mode == "cooling" else 0 | |
| heating_load = -load / 1000 if mode == "heating" else 0 | |
| return cooling_load, heating_load | |
| 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 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 TFMCalculations.SHGC_COEFFICIENTS: | |
| logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.") | |
| glazing_type = "Single Clear" | |
| c = TFMCalculations.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 get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary, | |
| project_materials: Dict, project_constructions: Dict, | |
| project_glazing_materials: Dict, project_door_materials: Dict) -> Tuple[float, float, float, Optional[float], float]: | |
| """ | |
| Determine surface parameters (tilt, azimuth, h_o, emissivity, solar_absorption) for a component. | |
| Uses MaterialLibrary to fetch properties from first layer for walls/roofs, DoorMaterial for doors, | |
| and GlazingMaterial for windows/skylights. Handles orientation and tilt based on component type: | |
| - Walls, Doors, Windows: Azimuth = elevation base azimuth + component.rotation; Tilt = 90°. | |
| - Roofs, Skylights: Azimuth = component.orientation; Tilt = component.tilt (default 180°). | |
| Args: | |
| component: Component object with component_type, elevation, rotation, orientation, tilt, | |
| construction, glazing_material, or door_material. | |
| building_info (Dict): Building information containing orientation_angle for elevation mapping. | |
| 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. | |
| Returns: | |
| Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°), | |
| h_o (W/m²·K), emissivity, solar_absorption. | |
| Raises: | |
| ValueError: If elevation is missing or invalid for walls, doors, or windows. | |
| """ | |
| # Default parameters | |
| if component.component_type == ComponentType.ROOF: | |
| surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent | |
| h_o = 23.0 # W/m²·K for roofs | |
| elif component.component_type == ComponentType.SKYLIGHT: | |
| surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent | |
| h_o = 23.0 # W/m²·K for skylights | |
| elif component.component_type == ComponentType.FLOOR: | |
| surface_tilt = 0.0 # Horizontal, upward | |
| h_o = 17.0 # W/m²·K | |
| else: # WALL, DOOR, WINDOW | |
| surface_tilt = 90.0 # Vertical | |
| h_o = 17.0 # W/m²·K | |
| emissivity = 0.9 # Default for opaque components | |
| solar_absorption = 0.6 # Default | |
| shgc = None # Only for windows/skylights | |
| component_name = getattr(component, 'name', 'unnamed_component') | |
| try: | |
| # Determine surface azimuth | |
| if component.component_type in [ComponentType.ROOF, ComponentType.SKYLIGHT]: | |
| # Use component's orientation attribute directly, ignoring elevation | |
| surface_azimuth = getattr(component, 'orientation', 0.0) | |
| logger.debug(f"Using component orientation for {component_name} ({component.component_type.value}): " | |
| f"azimuth={surface_azimuth}, tilt={surface_tilt}") | |
| else: # WALL, DOOR, WINDOW | |
| # Check for elevation attribute | |
| elevation = getattr(component, 'elevation', None) | |
| if not elevation: | |
| raise ValueError(f"Component {component_name} ({component.component_type.value}) is missing 'elevation' field") | |
| # Define elevation azimuths based on building orientation_angle | |
| base_azimuth = building_info.get("orientation_angle", 0.0) | |
| elevation_angles = { | |
| "A": base_azimuth, | |
| "B": (base_azimuth + 90.0) % 360, | |
| "C": (base_azimuth + 180.0) % 360, | |
| "D": (base_azimuth + 270.0) % 360 | |
| } | |
| if elevation not in elevation_angles: | |
| raise ValueError(f"Invalid elevation '{elevation}' for component {component_name} ({component.component_type.value}). " | |
| f"Expected one of {list(elevation_angles.keys())}") | |
| # Add component rotation to elevation azimuth | |
| surface_azimuth = (elevation_angles[elevation] + getattr(component, 'rotation', 0.0)) % 360 | |
| logger.debug(f"Component {component_name} ({component.component_type.value}): elevation={elevation}, " | |
| f"base_azimuth={elevation_angles[elevation]}, rotation={getattr(component, 'rotation', 0.0)}, " | |
| f"total_azimuth={surface_azimuth}, tilt={surface_tilt}") | |
| # Fetch material properties | |
| if component.component_type in [ComponentType.WALL, ComponentType.ROOF]: | |
| construction = getattr(component, 'construction', None) | |
| if not construction: | |
| logger.warning(f"No construction defined for {component_name} ({component.component_type.value}). " | |
| f"Using defaults: solar_absorption=0.6, emissivity=0.9.") | |
| else: | |
| # Get construction from library or project | |
| construction_obj = (project_constructions.get(construction.name) or | |
| material_library.library_constructions.get(construction.name)) | |
| if not construction_obj: | |
| logger.error(f"Construction '{construction.name}' not found for {component_name} ({component.component_type.value}).") | |
| elif not construction_obj.layers: | |
| logger.warning(f"No layers in construction '{construction.name}' for {component_name} ({component.component_type.value}).") | |
| else: | |
| # Use first (outermost) layer's properties | |
| first_layer = construction_obj.layers[0] | |
| material = first_layer["material"] | |
| solar_absorption = material.solar_absorption | |
| emissivity = material.emissivity | |
| logger.debug(f"Using first layer material '{material.name}' for {component_name} ({component.component_type.value}): " | |
| f"solar_absorption={solar_absorption}, emissivity={emissivity}") | |
| elif component.component_type == ComponentType.DOOR: | |
| door_material = getattr(component, 'door_material', None) | |
| if not door_material: | |
| logger.warning(f"No door material defined for {component_name} ({component.component_type.value}). " | |
| f"Using defaults: solar_absorption=0.6, emissivity=0.9.") | |
| else: | |
| # Get door material from library or project | |
| door_material_obj = (project_door_materials.get(door_material.name) or | |
| material_library.library_door_materials.get(door_material.name)) | |
| if not door_material_obj: | |
| logger.error(f"Door material '{door_material.name}' not found for {component_name} ({component.component_type.value}).") | |
| else: | |
| solar_absorption = door_material_obj.solar_absorption | |
| emissivity = door_material_obj.emissivity | |
| logger.debug(f"Using door material '{door_material_obj.name}' for {component_name} ({component.component_type.value}): " | |
| f"solar_absorption={solar_absorption}, emissivity={emissivity}") | |
| elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]: | |
| glazing_material = getattr(component, 'glazing_material', None) | |
| if not glazing_material: | |
| logger.warning(f"No glazing material defined for {component_name} ({component.component_type.value}). " | |
| f"Using default SHGC=0.7, h_o={h_o}.") | |
| shgc = 0.7 | |
| else: | |
| # Get glazing material from library or project | |
| glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or | |
| material_library.library_glazing_materials.get(glazing_material.name)) | |
| if not glazing_material_obj: | |
| logger.error(f"Glazing material '{glazing_material.name}' not found for {component_name} ({component.component_type.value}).") | |
| shgc = 0.7 | |
| else: | |
| shgc = glazing_material_obj.shgc | |
| h_o = glazing_material_obj.h_o | |
| logger.debug(f"Using glazing material '{glazing_material_obj.name}' for {component_name} ({component.component_type.value}): " | |
| f"shgc={shgc}, h_o={h_o}") | |
| emissivity = None # Not used for glazing | |
| except Exception as e: | |
| logger.error(f"Error retrieving surface parameters for {component_name} ({component.component_type.value}): {str(e)}") | |
| # Apply defaults | |
| if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]: | |
| solar_absorption = 0.6 | |
| emissivity = 0.9 | |
| else: # WINDOW, SKYLIGHT | |
| shgc = 0.7 | |
| # h_o retains default from component type | |
| return surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption | |
| def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float: | |
| """Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations. | |
| Args: | |
| component: Component object with area, component_type, elevation, glazing_material, shgc, iac. | |
| hourly_data (Dict): Single hour's weather data with GHI, DNI, DHI, dry_bulb, month, day, hour. | |
| hour (int): Hour of the day (1-24). | |
| building_orientation (float): Building orientation angle in degrees. | |
| mode (str): Operating mode ('cooling', 'heating', 'none'). | |
| Returns: | |
| float: Solar cooling load in kW. Returns 0 for non-cooling modes or non-fenestration components. | |
| References: | |
| ASHRAE Handbook—Fundamentals, Chapters 15 and 18. | |
| """ | |
| if mode != "cooling" or component.component_type not in [ComponentType.WINDOW, ComponentType.SKYLIGHT]: | |
| return 0 | |
| component_name = getattr(component, 'name', 'unnamed_component') | |
| try: | |
| # Get MaterialLibrary and project-specific data from session state | |
| material_library = st.session_state.get("material_library") | |
| if not material_library: | |
| logger.error(f"MaterialLibrary not found in session_state for {component_name} ({component.component_type.value})") | |
| raise ValueError("MaterialLibrary not found in session_state") | |
| project_materials = st.session_state.get("project_materials", {}) | |
| project_constructions = st.session_state.get("project_constructions", {}) | |
| project_glazing_materials = st.session_state.get("project_glazing_materials", {}) | |
| project_door_materials = st.session_state.get("project_door_materials", {}) | |
| # Get location parameters from climate_data | |
| climate_data = st.session_state.get("climate_data", {}) | |
| latitude = climate_data.get("latitude", 0.0) | |
| longitude = climate_data.get("longitude", 0.0) | |
| timezone = climate_data.get("time_zone", 0.0) | |
| # Get ground reflectivity (default 0.2) | |
| ground_reflectivity = st.session_state.get("ground_reflectivity", 0.2) | |
| # Validate input parameters | |
| if not -90 <= latitude <= 90: | |
| logger.warning(f"Invalid latitude {latitude} for {component_name} ({component.component_type.value}). Using default 0.0.") | |
| latitude = 0.0 | |
| if not -180 <= longitude <= 180: | |
| logger.warning(f"Invalid longitude {longitude} for {component_name} ({component.component_type.value}). Using default 0.0.") | |
| longitude = 0.0 | |
| if not -12 <= timezone <= 14: | |
| logger.warning(f"Invalid timezone {timezone} for {component_name} ({component.component_type.value}). Using default 0.0.") | |
| timezone = 0.0 | |
| if not 0 <= ground_reflectivity <= 1: | |
| logger.warning(f"Invalid ground_reflectivity {ground_reflectivity} for {component_name} ({component.component_type.value}). Using default 0.2.") | |
| ground_reflectivity = 0.2 | |
| # Ensure hourly_data has required fields | |
| required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation", | |
| "diffuse_horizontal_radiation", "dry_bulb"] | |
| if not all(field in hourly_data for field in required_fields): | |
| logger.warning(f"Missing required fields in hourly_data for hour {hour} for {component_name} ({component.component_type.value}): {hourly_data}") | |
| return 0 | |
| # Skip if GHI <= 0 | |
| if hourly_data["global_horizontal_radiation"] <= 0: | |
| logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']} for {component_name} ({component.component_type.value})") | |
| return 0 | |
| # Extract weather data | |
| month = hourly_data["month"] | |
| day = hourly_data["day"] | |
| hour = hourly_data["hour"] | |
| ghi = hourly_data["global_horizontal_radiation"] | |
| dni = hourly_data.get("direct_normal_radiation", ghi * 0.7) # Fallback: estimate DNI | |
| dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3) # Fallback: estimate DHI | |
| outdoor_temp = hourly_data["dry_bulb"] | |
| if ghi < 0 or dni < 0 or dhi < 0: | |
| logger.error(f"Negative radiation values for {month}/{day}/{hour} for {component_name} ({component.component_type.value})") | |
| raise ValueError(f"Negative radiation values for {month}/{day}/{hour}") | |
| logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, " | |
| f"dry_bulb={outdoor_temp} for {component_name} ({component.component_type.value})") | |
| # Step 1: Local Solar Time (LST) with Equation of Time | |
| year = 2025 # Fixed year since not provided | |
| n = TFMCalculations.day_of_year(month, day, year) | |
| EOT = TFMCalculations.equation_of_time(n) | |
| lambda_std = 15 * timezone # Standard meridian longitude (°) | |
| standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour | |
| LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60 | |
| # Step 2: Solar Declination (δ) | |
| delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n))) | |
| # Step 3: Hour Angle (HRA) | |
| hra = 15 * (LST - 12) | |
| # Step 4: 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} for {component_name} ({component.component_type.value})") | |
| # Step 5: Get surface parameters | |
| building_info = {"orientation_angle": building_orientation} | |
| surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption = \ | |
| TFMCalculations.get_surface_parameters( | |
| component, building_info, material_library, project_materials, | |
| project_constructions, project_glazing_materials, project_door_materials | |
| ) | |
| # For windows/skylights, get SHGC from material | |
| shgc = 0.7 # Default | |
| if component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]: | |
| glazing_material = getattr(component, 'glazing_material', None) | |
| if glazing_material: | |
| glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or | |
| material_library.library_glazing_materials.get(glazing_material.name)) | |
| if glazing_material_obj: | |
| shgc = glazing_material_obj.shgc | |
| h_o = glazing_material_obj.h_o | |
| else: | |
| logger.warning(f"Glazing material '{glazing_material.name}' not found for {component_name} ({component.component_type.value}). Using default SHGC=0.7.") | |
| else: | |
| logger.warning(f"No glazing material defined for {component_name} ({component.component_type.value}). Using default SHGC=0.7.") | |
| # Step 6: 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 {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: " | |
| f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, " | |
| f"cos_theta={cos_theta:.2f}") | |
| # Step 7: Calculate total incident radiation (I_t) | |
| view_factor = (1 - math.cos(math.radians(surface_tilt))) / 2 | |
| ground_reflected = ground_reflectivity * ghi * view_factor | |
| I_t = dni * cos_theta + dhi + ground_reflected | |
| # Step 8: Calculate solar heat gain for fenestration | |
| glazing_type = TFMCalculations.GLAZING_TYPE_MAPPING.get(component.name, 'Single Clear') | |
| iac = getattr(component, 'iac', 1.0) # Default internal shading | |
| shgc_dynamic = shgc * TFMCalculations.calculate_dynamic_shgc(glazing_type, cos_theta) | |
| solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000 # kW | |
| logger.info(f"Solar heat gain for {component_name} ({component.component_type.value}) at {month}/{day}/{hour}: " | |
| f"{solar_heat_gain:.2f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.2f}, " | |
| f"I_t={I_t:.2f}, iac={iac})") | |
| return solar_heat_gain | |
| except Exception as e: | |
| logger.error(f"Error calculating solar load for {component_name} ({component.component_type.value}) at hour {hour}: {str(e)}") | |
| return 0 | |
| def calculate_internal_load(internal_loads: Dict, hour: int, operation_hours: int, area: float) -> float: | |
| """Calculate total internal load in kW.""" | |
| total_load = 0 | |
| for group in internal_loads.get("people", []): | |
| activity_data = group["activity_data"] | |
| sensible = (activity_data["sensible_min_w"] + activity_data["sensible_max_w"]) / 2 | |
| latent = (activity_data["latent_min_w"] + activity_data["latent_max_w"]) / 2 | |
| load_per_person = sensible + latent | |
| total_load += group["num_people"] * load_per_person * group["diversity_factor"] | |
| for light in internal_loads.get("lighting", []): | |
| lpd = light["lpd"] | |
| lighting_operating_hours = light["operating_hours"] | |
| fraction = min(lighting_operating_hours, operation_hours) / operation_hours if operation_hours > 0 else 0 | |
| lighting_load = lpd * area * fraction | |
| total_load += lighting_load | |
| equipment = internal_loads.get("equipment") | |
| if equipment: | |
| total_power_density = equipment.get("total_power_density", 0) | |
| equipment_load = total_power_density * area | |
| total_load += equipment_load | |
| return total_load / 1000 | |
| def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]: | |
| """Calculate ventilation load for heating and cooling in kW based on mode.""" | |
| if mode == "none": | |
| return 0, 0 | |
| ventilation = internal_loads.get("ventilation") | |
| if not ventilation: | |
| return 0, 0 | |
| space_rate = ventilation.get("space_rate", 0.3) # L/s/m² | |
| people_rate = ventilation.get("people_rate", 2.5) # L/s/person | |
| num_people = sum(group["num_people"] for group in internal_loads.get("people", [])) | |
| ventilation_flow = (space_rate * area + people_rate * num_people) / 1000 # m³/s | |
| air_density = 1.2 # kg/m³ | |
| specific_heat = 1000 # J/kg·K | |
| delta_t = outdoor_temp - indoor_temp | |
| if mode == "cooling" and delta_t <= 0: | |
| return 0, 0 | |
| if mode == "heating" and delta_t >= 0: | |
| return 0, 0 | |
| load = ventilation_flow * air_density * specific_heat * delta_t / 1000 # kW | |
| cooling_load = load if mode == "cooling" else 0 | |
| heating_load = -load if mode == "heating" else 0 | |
| return cooling_load, heating_load | |
| def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]: | |
| """Calculate infiltration load for heating and cooling in kW based on mode.""" | |
| if mode == "none": | |
| return 0, 0 | |
| infiltration = internal_loads.get("infiltration") | |
| if not infiltration: | |
| return 0, 0 | |
| method = infiltration.get("method", "ACH") | |
| settings = infiltration.get("settings", {}) | |
| building_height = building_info.get("building_height", 3.0) | |
| volume = area * building_height # m³ | |
| air_density = 1.2 # kg/m³ | |
| specific_heat = 1000 # J/kg·K | |
| delta_t = outdoor_temp - indoor_temp | |
| if mode == "cooling" and delta_t <= 0: | |
| return 0, 0 | |
| if mode == "heating" and delta_t >= 0: | |
| return 0, 0 | |
| if method == "ACH": | |
| ach = settings.get("rate", 0.5) | |
| infiltration_flow = ach * volume / 3600 # m³/s | |
| elif method == "Crack Flow": | |
| ela = settings.get("ela", 0.0001) # m²/m² | |
| wind_speed = 4.0 # m/s (assumed) | |
| infiltration_flow = ela * area * wind_speed / 2 # m³/s | |
| else: # Empirical Equations | |
| c = settings.get("c", 0.1) | |
| n = settings.get("n", 0.65) | |
| delta_t_abs = abs(delta_t) | |
| infiltration_flow = c * (delta_t_abs ** n) * area / 3600 # m³/s | |
| load = infiltration_flow * air_density * specific_heat * delta_t / 1000 # kW | |
| cooling_load = load if mode == "cooling" else 0 | |
| heating_load = -load if mode == "heating" else 0 | |
| return cooling_load, heating_load | |
| def get_adaptive_comfort_temp(outdoor_temp: float) -> float: | |
| """Calculate adaptive comfort temperature per ASHRAE 55.""" | |
| if 10 <= outdoor_temp <= 33.5: | |
| return 0.31 * outdoor_temp + 17.8 | |
| return 24.0 # Default to standard setpoint if outside range | |
| def filter_hourly_data(hourly_data: List[Dict], sim_period: Dict, climate_data: Dict) -> List[Dict]: | |
| """Filter hourly data based on simulation period, ignoring year.""" | |
| if sim_period["type"] == "Full Year": | |
| return hourly_data | |
| filtered_data = [] | |
| if sim_period["type"] == "From-to": | |
| start_month = sim_period["start_date"].month | |
| start_day = sim_period["start_date"].day | |
| end_month = sim_period["end_date"].month | |
| end_day = sim_period["end_date"].day | |
| for data in hourly_data: | |
| month, day = data["month"], data["day"] | |
| if (month > start_month or (month == start_month and day >= start_day)) and \ | |
| (month < end_month or (month == end_month and day <= end_day)): | |
| filtered_data.append(data) | |
| elif sim_period["type"] in ["HDD", "CDD"]: | |
| base_temp = sim_period.get("base_temp", 18.3 if sim_period["type"] == "HDD" else 23.9) | |
| for data in hourly_data: | |
| temp = data["dry_bulb"] | |
| if (sim_period["type"] == "HDD" and temp < base_temp) or (sim_period["type"] == "CDD" and temp > base_temp): | |
| filtered_data.append(data) | |
| return filtered_data | |
| def get_indoor_conditions(indoor_conditions: Dict, hour: int, outdoor_temp: float) -> Dict: | |
| """Determine indoor conditions based on user settings.""" | |
| if indoor_conditions["type"] == "Fixed": | |
| mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating" | |
| if mode == "cooling": | |
| return { | |
| "temperature": indoor_conditions.get("cooling_setpoint", {}).get("temperature", 24.0), | |
| "rh": indoor_conditions.get("cooling_setpoint", {}).get("rh", 50.0) | |
| } | |
| elif mode == "heating": | |
| return { | |
| "temperature": indoor_conditions.get("heating_setpoint", {}).get("temperature", 22.0), | |
| "rh": indoor_conditions.get("heating_setpoint", {}).get("rh", 50.0) | |
| } | |
| else: | |
| return {"temperature": 24.0, "rh": 50.0} | |
| elif indoor_conditions["type"] == "Time-varying": | |
| schedule = indoor_conditions.get("schedule", []) | |
| if schedule: | |
| hour_idx = hour % 24 | |
| for entry in schedule: | |
| if entry["hour"] == hour_idx: | |
| return {"temperature": entry["temperature"], "rh": entry["rh"]} | |
| return {"temperature": 24.0, "rh": 50.0} | |
| else: # Adaptive | |
| return {"temperature": TFMCalculations.get_adaptive_comfort_temp(outdoor_temp), "rh": 50.0} | |
| 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]: | |
| """Calculate TFM loads for heating and cooling with user-defined filters and temperature threshold.""" | |
| filtered_data = TFMCalculations.filter_hourly_data(hourly_data, sim_period, building_info) | |
| temp_loads = [] | |
| building_orientation = building_info.get("orientation_angle", 0.0) | |
| operating_periods = hvac_settings.get("operating_hours", [{"start": 8, "end": 18}]) | |
| area = building_info.get("floor_area", 100.0) | |
| # Pre-calculate CTF coefficients for all components using CTFCalculator | |
| for comp_list in components.values(): | |
| for comp in comp_list: | |
| comp.ctf = CTFCalculator.calculate_ctf_coefficients(comp) | |
| for hour_data in filtered_data: | |
| hour = hour_data["hour"] | |
| outdoor_temp = hour_data["dry_bulb"] | |
| indoor_cond = TFMCalculations.get_indoor_conditions(indoor_conditions, hour, outdoor_temp) | |
| indoor_temp = indoor_cond["temperature"] | |
| # Initialize all loads to 0 | |
| conduction_cooling = conduction_heating = solar = internal = ventilation_cooling = ventilation_heating = infiltration_cooling = infiltration_heating = 0 | |
| # Check if hour is within operating periods | |
| is_operating = False | |
| for period in operating_periods: | |
| start_hour = period.get("start", 8) | |
| end_hour = period.get("end", 18) | |
| if start_hour <= hour % 24 <= end_hour: | |
| is_operating = True | |
| break | |
| # Determine mode based on temperature threshold (18°C) | |
| mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating" | |
| if is_operating and mode == "cooling": | |
| for comp_list in components.values(): | |
| for comp in comp_list: | |
| cool_load, _ = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="cooling") | |
| conduction_cooling += cool_load | |
| # Updated call to calculate_solar_load to match current signature | |
| solar += TFMCalculations.calculate_solar_load(comp, hour_data, hour, building_orientation, mode="cooling") | |
| internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area) | |
| ventilation_cooling, _ = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling") | |
| infiltration_cooling, _ = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling") | |
| elif is_operating and mode == "heating": | |
| for comp_list in components.values(): | |
| for comp in comp_list: | |
| _, heat_load = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="heating") | |
| conduction_heating += heat_load | |
| internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area) | |
| _, ventilation_heating = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating") | |
| _, infiltration_heating = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating") | |
| else: # mode == "none" or not is_operating | |
| internal = 0 # No internal loads when no heating or cooling is needed or outside operating hours | |
| # Calculate total loads, subtracting internal load for heating | |
| total_cooling = conduction_cooling + solar + internal + ventilation_cooling + infiltration_cooling | |
| total_heating = max(conduction_heating + ventilation_heating + infiltration_heating - internal, 0) | |
| # Enforce mutual exclusivity within hour | |
| if mode == "cooling": | |
| total_heating = 0 | |
| elif mode == "heating": | |
| total_cooling = 0 | |
| temp_loads.append({ | |
| "hour": hour, | |
| "month": hour_data["month"], | |
| "day": hour_data["day"], | |
| "conduction_cooling": conduction_cooling, | |
| "conduction_heating": conduction_heating, | |
| "solar": solar, | |
| "internal": internal, | |
| "ventilation_cooling": ventilation_cooling, | |
| "ventilation_heating": ventilation_heating, | |
| "infiltration_cooling": infiltration_cooling, | |
| "infiltration_heating": infiltration_heating, | |
| "total_cooling": total_cooling, | |
| "total_heating": total_heating | |
| }) | |
| # Group loads by day and apply daily control | |
| loads_by_day = defaultdict(list) | |
| for load in temp_loads: | |
| day_key = (load["month"], load["day"]) | |
| loads_by_day[day_key].append(load) | |
| final_loads = [] | |
| for day_key, day_loads in loads_by_day.items(): | |
| # Count hours with non-zero cooling and heating loads | |
| cooling_hours = sum(1 for load in day_loads if load["total_cooling"] > 0) | |
| heating_hours = sum(1 for load in day_loads if load["total_heating"] > 0) | |
| # Apply daily control | |
| for load in day_loads: | |
| if cooling_hours > heating_hours: | |
| load["total_heating"] = 0 # Keep cooling components, zero heating total | |
| elif heating_hours > cooling_hours: | |
| load["total_cooling"] = 0 # Keep heating components, zero cooling total | |
| else: # Equal hours | |
| load["total_cooling"] = 0 | |
| load["total_heating"] = 0 # Zero both totals, keep components | |
| final_loads.append(load) | |
| return final_loads |