""" 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" } @staticmethod 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 @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 @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 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 @staticmethod 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 @staticmethod 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 @staticmethod 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 @staticmethod 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 @staticmethod 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 @staticmethod 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 @staticmethod 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 @staticmethod 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} @staticmethod 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