""" CTF Calculations Module This module contains the CTFCalculator class for calculating Conduction Transfer Function (CTF) coefficients for HVAC load calculations using the implicit Finite Difference Method, enhanced with sol-air temperature calculations accounting for solar radiation, longwave radiation, and dynamic outdoor heat transfer coefficient. Developed by: Dr Majed Abuseif, Deakin University © 2025 """ import numpy as np import scipy.sparse as sparse import scipy.sparse.linalg as sparse_linalg import hashlib import logging import threading from typing import List, Dict, Any, NamedTuple import streamlit as st from enum import Enum # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) class ComponentType(Enum): WALL = "Wall" ROOF = "Roof" FLOOR = "Floor" WINDOW = "Window" SKYLIGHT = "Skylight" class CTFCoefficients(NamedTuple): X: List[float] # Exterior temperature coefficients Y: List[float] # Cross coefficients Z: List[float] # Interior temperature coefficients F: List[float] # Flux history coefficients class CTFCalculator: """Class to calculate and cache CTF coefficients for building components.""" # Cache for CTF coefficients based on construction properties _ctf_cache = {} _cache_lock = threading.Lock() # Thread-safe lock for cache access @staticmethod def calculate_sky_temperature(T_out: float, dew_point: float, total_sky_cover: float = 0.5) -> float: """Calculate sky temperature using cloud-cover-dependent model. Args: T_out (float): Outdoor dry-bulb temperature (°C). dew_point (float): Dew point temperature (°C). total_sky_cover (float): Sky cover fraction (0 to 1). Returns: float: Sky temperature (°C), bounded by dew point. References: ASHRAE Handbook—Fundamentals (2021), Chapter 26. """ epsilon_sky = 0.9 + 0.04 * total_sky_cover T_sky = (epsilon_sky * (T_out + 273.15)**4)**0.25 - 273.15 return T_sky if dew_point <= T_out else dew_point @staticmethod def calculate_h_o(wind_speed: float, surface_type: ComponentType) -> float: """Calculate dynamic outdoor heat transfer coefficient based on wind speed and surface type. Args: wind_speed (float): Wind speed (m/s). surface_type (ComponentType): Type of surface (WALL, ROOF, FLOOR, WINDOW, SKYLIGHT). Returns: float: Outdoor heat transfer coefficient (W/m²·K). References: ASHRAE Handbook—Fundamentals (2021), Chapter 26. """ from app.m_c_data import DEFAULT_WINDOW_PROPERTIES # Delayed import to avoid circular dependency wind_speed = max(min(wind_speed, 20.0), 0.0) # Bound for stability if surface_type in [ComponentType.WALL, ComponentType.FLOOR]: h_o = 8.3 + 4.0 * (wind_speed ** 0.6) # ASHRAE Ch. 26 elif surface_type == ComponentType.ROOF: h_o = 9.1 + 2.8 * wind_speed # ASHRAE Ch. 26 else: # WINDOW, SKYLIGHT h_o = DEFAULT_WINDOW_PROPERTIES["h_o"] return max(h_o, 5.0) # Minimum for stability @staticmethod def calculate_sol_air_temperature(T_out: float, I_t: float, absorptivity: float, emissivity: float, h_o: float, dew_point: float, total_sky_cover: float = 0.5) -> float: """Calculate sol-air temperature for a surface. Args: T_out (float): Outdoor dry-bulb temperature (°C). I_t (float): Total incident solar radiation (W/m²). absorptivity (float): Surface absorptivity. emissivity (float): Surface emissivity. h_o (float): Outdoor heat transfer coefficient (W/m²·K). dew_point (float): Dew point temperature (°C). total_sky_cover (float): Sky cover fraction (0 to 1). Returns: float: Sol-air temperature (°C). References: ASHRAE Handbook—Fundamentals (2021), Chapter 26. """ sigma = 5.67e-8 # Stefan-Boltzmann constant (W/m²·K⁴) T_sky = CTFCalculator.calculate_sky_temperature(T_out, dew_point, total_sky_cover) T_sol_air = T_out + (absorptivity * I_t - emissivity * sigma * ((T_out + 273.15)**4 - (T_sky + 273.15)**4)) / h_o return T_sol_air @staticmethod def _hash_construction(construction: Dict[str, Any]) -> str: """Generate a unique hash for a construction based on its properties. Args: construction: Dictionary containing construction properties (name, layers, adiabatic). Returns: str: SHA-256 hash of the construction properties. """ hash_input = f"{construction.get('name', '')}{construction.get('adiabatic', False)}" layers = construction.get('layers', []) for layer in layers: material_name = layer.get('material', '') thickness = layer.get('thickness', 0.0) hash_input += f"{material_name}{thickness}" return hashlib.sha256(hash_input.encode()).hexdigest() @classmethod def _get_material_properties(cls, material_name: str) -> Dict[str, float]: """Retrieve material properties from session state. Args: material_name: Name of the material. Returns: Dict containing conductivity, density, specific_heat, absorptivity, emissivity. Returns empty dict if material not found. """ try: materials = st.session_state.project_data.get('materials', {}) material = materials.get('library', {}).get(material_name, materials.get('project', {}).get(material_name)) if not material: logger.error(f"Material '{material_name}' not found in library or project materials.") return {} # Extract required properties thermal_props = material.get('thermal_properties', {}) return { 'name': material_name, 'conductivity': thermal_props.get('conductivity', 0.0), 'density': thermal_props.get('density', 0.0), 'specific_heat': thermal_props.get('specific_heat', 0.0), 'absorptivity': material.get('absorptivity', 0.6), 'emissivity': material.get('emissivity', 0.9) } except Exception as e: logger.error(f"Error retrieving material '{material_name}' properties: {str(e)}") return {} @classmethod def calculate_ctf_coefficients(cls, component: Dict[str, Any], hourly_data: Dict[str, Any] = None) -> CTFCoefficients: """Calculate CTF coefficients using implicit Finite Difference Method with sol-air temperature. Note: Per ASHRAE, CTF calculations are skipped for WINDOW and SKYLIGHT components, as they use typical material properties. CTF tables for these components will be added later. Args: component: Dictionary containing component properties from st.session_state.project_data["components"]. hourly_data: Dictionary containing hourly weather data (T_out, dew_point, wind_speed, total_sky_cover, I_t). Returns: CTFCoefficients: Named tuple containing X, Y, Z, and F coefficients. """ # Determine component type comp_type_str = component.get('type', '').lower() # Expected from component dictionary key (e.g., 'walls') comp_type_map = { 'walls': ComponentType.WALL, 'roofs': ComponentType.ROOF, 'floors': ComponentType.FLOOR, 'windows': ComponentType.WINDOW, 'skylights': ComponentType.SKYLIGHT } component_type = comp_type_map.get(comp_type_str, None) if not component_type: logger.warning(f"Invalid component type '{comp_type_str}' for component '{component.get('name', 'Unknown')}'. Returning zero CTFs.") return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0]) # Validate adiabatic and ground_contact mutual exclusivity if component.get('adiabatic', False) and component.get('ground_contact', False): logger.warning(f"Component {component.get('name', 'Unknown')} has both adiabatic and ground_contact set to True. Treating as adiabatic, setting ground_contact to False.") component['ground_contact'] = False # Skip CTF for adiabatic components if component.get('adiabatic', False): logger.info(f"Skipping CTF calculation for adiabatic {component_type.value} component '{component.get('name', 'Unknown')}'. Returning zero coefficients.") return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0]) # Skip CTF for WINDOW, SKYLIGHT as per ASHRAE; return zero coefficients if component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]: logger.info(f"Skipping CTF calculation for {component_type.value} component '{component.get('name', 'Unknown')}'. Using zero coefficients until CTF tables are implemented.") return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0]) # Retrieve construction construction_name = component.get('construction', '') if not construction_name: logger.warning(f"No construction specified for component '{component.get('name', 'Unknown')}' ({component_type.value}). Returning zero CTFs.") return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0]) constructions = st.session_state.project_data.get('constructions', {}) construction = constructions.get('library', {}).get(construction_name, constructions.get('project', {}).get(construction_name)) if not construction or not construction.get('layers'): logger.warning(f"No valid construction or layers found for construction '{construction_name}' in component '{component.get('name', 'Unknown')}' ({component_type.value}). Returning zero CTFs.") return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0]) # Check cache with thread-safe access construction_hash = cls._hash_construction(construction) with cls._cache_lock: if construction_hash in cls._ctf_cache: logger.info(f"Using cached CTF coefficients for construction '{construction_name}'") return cls._ctf_cache[construction_hash] # Collect layer properties thicknesses = [] material_props = [] for layer in construction.get('layers', []): material_name = layer.get('material', '') thickness = layer.get('thickness', 0.0) if thickness <= 0.0: logger.warning(f"Invalid thickness {thickness} for material '{material_name}' in construction '{construction_name}'. Skipping layer.") continue material = cls._get_material_properties(material_name) if not material: logger.warning(f"Skipping layer with material '{material_name}' in construction '{construction_name}' due to missing properties.") continue thicknesses.append(thickness) material_props.append(material) if not thicknesses or not material_props: logger.warning(f"No valid layers with material properties for construction '{construction_name}' in component '{component.get('name', 'Unknown')}'. Returning zero CTFs.") return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0]) # Extract material properties k = [m['conductivity'] for m in material_props] # W/m·K rho = [m['density'] for m in material_props] # kg/m³ c = [m['specific_heat'] for m in material_props] # J/kg·K alpha = [k_i / (rho_i * c_i) if rho_i * c_i > 0 else 0.0 for k_i, rho_i, c_i in zip(k, rho, c)] # Thermal diffusivity (m²/s) absorptivity = material_props[0].get('absorptivity', 0.6) # Use first layer's absorptivity emissivity = material_props[0].get('emissivity', 0.9) # Use first layer's emissivity # Discretization parameters dt = 3600 # 1-hour time step (s) nodes_per_layer = 3 # 2–4 nodes per layer for balance R_in = 0.12 # Indoor surface resistance (m²·K/W, ASHRAE) # Get weather data for sol-air temperature T_out = hourly_data.get('dry_bulb', 25.0) if hourly_data else 25.0 dew_point = hourly_data.get('dew_point', T_out - 5.0) if hourly_data else T_out - 5.0 wind_speed = hourly_data.get('wind_speed', 4.0) if hourly_data else 4.0 total_sky_cover = hourly_data.get('total_sky_cover', 0.5) if hourly_data else 0.0 I_t = hourly_data.get('total_incident_radiation', 0.0) if hourly_data else 0.0 # Calculate dynamic h_o and sol-air temperature h_o = cls.calculate_h_o(wind_speed, component_type) T_sol_air = cls.calculate_sol_air_temperature(T_out, I_t, absorptivity, emissivity, h_o, dew_point, total_sky_cover) R_out = 1.0 / h_o # Outdoor surface resistance based on dynamic h_o logger.info(f"Calculated h_o={h_o:.2f} W/m²·K, T_sol_air={T_sol_air:.2f}°C for component '{component.get('name', 'Unknown')}'") # Calculate node spacing and check stability total_nodes = sum(nodes_per_layer for _ in thicknesses) dx = [t / nodes_per_layer for t in thicknesses] # Node spacing per layer node_positions = [] node_idx = 0 for i, t in enumerate(thicknesses): for j in range(nodes_per_layer): node_positions.append((i, j, node_idx)) # (layer_idx, node_in_layer, global_node_idx) node_idx += 1 # Stability check: Fourier number for i, (a, d) in enumerate(zip(alpha, dx)): if a == 0 or d == 0: logger.warning(f"Invalid thermal diffusivity or node spacing for layer {i} in construction '{construction_name}'. Skipping stability adjustment.") continue Fo = a * dt / (d ** 2) if Fo < 0.33: logger.warning(f"Fourier number {Fo:.3f} < 0.33 for layer {i} ({material_props[i]['name']}). Adjusting node spacing.") dx[i] = np.sqrt(a * dt / 0.33) nodes_per_layer = max(2, int(np.ceil(thicknesses[i] / dx[i]))) dx[i] = thicknesses[i] / nodes_per_layer Fo = a * dt / (dx[i] ** 2) logger.info(f"Adjusted node spacing for layer {i}: dx={dx[i]:.4f} m, Fo={Fo:.3f}") # Build system matrices A = sparse.lil_matrix((total_nodes, total_nodes)) b = np.zeros(total_nodes) node_to_layer = [i for i, _, _ in node_positions] for idx, (layer_idx, node_j, global_idx) in enumerate(node_positions): k_i = k[layer_idx] rho_i = rho[layer_idx] c_i = c[layer_idx] dx_i = dx[layer_idx] if k_i == 0 or rho_i == 0 or c_i == 0 or dx_i == 0: logger.warning(f"Invalid material properties for layer {layer_idx} ({material_props[layer_idx]['name']}) in construction '{construction_name}'. Using default values.") continue if node_j == 0 and layer_idx == 0: # Outdoor surface node A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i) + dt / (rho_i * c_i * dx_i * R_out) A[idx, idx + 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i) b[idx] = dt / (rho_i * c_i * dx_i * R_out) * T_sol_air # Use sol-air temperature elif node_j == nodes_per_layer - 1 and layer_idx == len(thicknesses) - 1: # Indoor surface node A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i) + dt / (rho_i * c_i * dx_i * R_in) A[idx, idx - 1] = -2 * dt * k_i / (dx_i * rho_i * c_i * dx_i) b[idx] = dt / (rho_i * c_i * dx_i * R_in) # Indoor temp contribution # Add radiant load to indoor surface node (convert kW to W) radiant_load = component.get("radiant_load", 0.0) * 1000 # kW to W if radiant_load != 0 and rho_i * c_i * dx_i != 0: b[idx] += dt / (rho_i * c_i * dx_i) * radiant_load logger.debug(f"Added radiant load {radiant_load:.2f} W to indoor node for component '{component.get('name', 'Unknown')}'") elif radiant_load != 0: logger.warning(f"Invalid material properties (rho={rho_i}, c={c_i}, dx={dx_i}) for radiant load in component '{component.get('name', 'Unknown')}'. Skipping.") elif node_j == nodes_per_layer - 1 and layer_idx < len(thicknesses) - 1: # Interface between layers k_next = k[layer_idx + 1] dx_next = dx[layer_idx + 1] rho_next = rho[layer_idx + 1] c_next = c[layer_idx + 1] if k_next == 0 or dx_next == 0 or rho_next == 0 or c_next == 0: logger.warning(f"Invalid material properties for layer {layer_idx + 1} ({material_props[layer_idx + 1]['name']}) in construction '{construction_name}'. Skipping interface.") continue A[idx, idx] = 1.0 + dt * (k_i / dx_i + k_next / dx_next) / (0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next)) A[idx, idx - 1] = -dt * k_i / (dx_i * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next)) A[idx, idx + 1] = -dt * k_next / (dx_next * 0.5 * (rho_i * c_i * dx_i + rho_next * c_next * dx_next)) elif node_j == 0 and layer_idx > 0: # Interface from previous layer k_prev = k[layer_idx - 1] dx_prev = dx[layer_idx - 1] rho_prev = rho[layer_idx - 1] c_prev = c[layer_idx - 1] if k_prev == 0 or dx_prev == 0 or rho_prev == 0 or c_prev == 0: logger.warning(f"Invalid material properties for layer {layer_idx - 1} ({material_props[layer_idx - 1]['name']}) in construction '{construction_name}'. Skipping interface.") continue A[idx, idx] = 1.0 + dt * (k_prev / dx_prev + k_i / dx_i) / (0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i)) A[idx, idx - 1] = -dt * k_prev / (dx_prev * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i)) A[idx, idx + 1] = -dt * k_i / (dx_i * 0.5 * (rho_prev * c_prev * dx_prev + rho_i * c_i * dx_i)) else: # Internal node A[idx, idx] = 1.0 + 2 * dt * k_i / (dx_i * rho_i * c_i * dx_i) A[idx, idx - 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i) A[idx, idx + 1] = -dt * k_i / (dx_i * rho_i * c_i * dx_i) A = A.tocsr() # Convert to CSR for efficient solving # Calculate CTF coefficients (X, Y, Z, F) num_ctf = 12 # Standard number of coefficients X = [0.0] * num_ctf # Exterior temp response Y = [0.0] * num_ctf # Cross response Z = [0.0] * num_ctf # Interior temp response F = [0.0] * num_ctf # Flux history T_prev = np.zeros(total_nodes) # Previous temperatures # Impulse response for exterior temperature (X, Y) for t in range(num_ctf): b_out = b.copy() if t == 0: b_out[0] = dt / (rho[0] * c[0] * dx[0] * R_out) * T_sol_air if rho[0] * c[0] * dx[0] != 0 else 0.0 # Unit outdoor temp impulse with sol-air T = sparse_linalg.spsolve(A, b_out + T_prev) q_in = (T[-1] - 0.0) / R_in # Indoor heat flux (W/m²) Y[t] = q_in q_out = (0.0 - T[0]) / R_out # Outdoor heat flux X[t] = q_out T_prev = T.copy() # Reset for interior temperature (Z) T_prev = np.zeros(total_nodes) for t in range(num_ctf): b_in = b.copy() if t == 0: b_in[-1] = dt / (rho[-1] * c[-1] * dx[-1] * R_in) if rho[-1] * c[-1] * dx[-1] != 0 else 0.0 # Unit indoor temp impulse T = sparse_linalg.spsolve(A, b_in + T_prev) q_in = (T[-1] - 0.0) / R_in Z[t] = q_in T_prev = T.copy() # Flux history coefficients (F) T_prev = np.zeros(total_nodes) for t in range(num_ctf): b_flux = np.zeros(total_nodes) if t == 0: b_flux[-1] = -1.0 / (rho[-1] * c[-1] * dx[-1]) if rho[-1] * c[-1] * dx[-1] != 0 else 0.0 # Unit flux impulse T = sparse_linalg.spsolve(A, b_flux + T_prev) q_in = (T[-1] - 0.0) / R_in F[t] = q_in T_prev = T.copy() ctf = CTFCoefficients(X=X, Y=Y, Z=Z, F=F) with cls._cache_lock: cls._ctf_cache[construction_hash] = ctf logger.info(f"Calculated CTF coefficients for construction '{construction_name}' in component '{component.get('name', 'Unknown')}'") return ctf @classmethod def calculate_ctf_tables(cls, component: Dict[str, Any]) -> CTFCoefficients: """Placeholder for future implementation of CTF table lookups for windows and skylights. Args: component: Dictionary containing component properties. Returns: CTFCoefficients: Placeholder zero coefficients until implementation. """ logger.info(f"CTF table calculation for {component.get('type', 'Unknown')} component '{component.get('name', 'Unknown')}' not yet implemented. Returning zero coefficients.") return CTFCoefficients(X=[0.0], Y=[0.0], Z=[0.0], F=[0.0])