Spaces:
Sleeping
Sleeping
Update data/calculation.py
Browse files- data/calculation.py +363 -42
data/calculation.py
CHANGED
|
@@ -7,22 +7,48 @@ Developed by: Dr Majed Abuseif, Deakin University
|
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
import pandas as pd
|
| 10 |
-
from typing import Dict, List, Optional, NamedTuple
|
| 11 |
from enum import Enum
|
| 12 |
import streamlit as st
|
| 13 |
-
from data.material_library import Construction, GlazingMaterial, DoorMaterial
|
| 14 |
from data.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS
|
| 15 |
from datetime import datetime
|
| 16 |
from collections import defaultdict
|
| 17 |
import logging
|
|
|
|
| 18 |
from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
|
| 19 |
-
from utils.solar import SolarCalculations
|
| 20 |
|
| 21 |
# Configure logging
|
| 22 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
class TFMCalculations:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
@staticmethod
|
| 27 |
def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> tuple[float, float]:
|
| 28 |
"""Calculate conduction load for heating and cooling in kW based on mode."""
|
|
@@ -48,6 +74,222 @@ class TFMCalculations:
|
|
| 48 |
heating_load = -load / 1000 if mode == "heating" else 0
|
| 49 |
return cooling_load, heating_load
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
@staticmethod
|
| 52 |
def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
|
| 53 |
"""Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations.
|
|
@@ -69,19 +311,16 @@ class TFMCalculations:
|
|
| 69 |
return 0
|
| 70 |
|
| 71 |
try:
|
| 72 |
-
#
|
| 73 |
material_library = st.session_state.get("material_library")
|
| 74 |
if not material_library:
|
| 75 |
logger.error("MaterialLibrary not found in session_state")
|
| 76 |
raise ValueError("MaterialLibrary not found in session_state")
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
project_glazing_materials=st.session_state.get("project_glazing_materials", {}),
|
| 83 |
-
project_door_materials=st.session_state.get("project_door_materials", {})
|
| 84 |
-
)
|
| 85 |
|
| 86 |
# Get location parameters from climate_data
|
| 87 |
climate_data = st.session_state.get("climate_data", {})
|
|
@@ -89,16 +328,22 @@ class TFMCalculations:
|
|
| 89 |
longitude = climate_data.get("longitude", 0.0)
|
| 90 |
timezone = climate_data.get("time_zone", 0.0)
|
| 91 |
|
| 92 |
-
# Get ground reflectivity (default 0.2
|
| 93 |
ground_reflectivity = st.session_state.get("ground_reflectivity", 0.2)
|
| 94 |
|
| 95 |
-
#
|
| 96 |
-
|
| 97 |
-
"
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
}
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
# Ensure hourly_data has required fields
|
| 104 |
required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
|
|
@@ -112,33 +357,109 @@ class TFMCalculations:
|
|
| 112 |
logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']}")
|
| 113 |
return 0
|
| 114 |
|
| 115 |
-
#
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
# Extract solar heat gain for the component
|
| 126 |
-
if not results:
|
| 127 |
-
logger.warning(f"No solar results for hour {hour}")
|
| 128 |
-
return 0
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
solar_heat_gain = comp_result.get("solar_heat_gain", 0.0)
|
| 134 |
-
logger.info(f"Solar load for component {comp_result['component_id']} at hour {hour}: {solar_heat_gain:.2f} kW")
|
| 135 |
-
return solar_heat_gain
|
| 136 |
|
| 137 |
-
logger.
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
except Exception as e:
|
| 141 |
-
|
|
|
|
| 142 |
return 0
|
| 143 |
|
| 144 |
@staticmethod
|
|
|
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
import pandas as pd
|
| 10 |
+
from typing import Dict, List, Optional, NamedTuple, Any, Tuple
|
| 11 |
from enum import Enum
|
| 12 |
import streamlit as st
|
| 13 |
+
from data.material_library import Construction, GlazingMaterial, DoorMaterial, Material, MaterialLibrary
|
| 14 |
from data.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS
|
| 15 |
from datetime import datetime
|
| 16 |
from collections import defaultdict
|
| 17 |
import logging
|
| 18 |
+
import math
|
| 19 |
from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
|
|
|
|
| 20 |
|
| 21 |
# Configure logging
|
| 22 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
class TFMCalculations:
|
| 26 |
+
# Solar calculation constants (from solar.py)
|
| 27 |
+
SHGC_COEFFICIENTS = {
|
| 28 |
+
"Single Clear": [0.1, -0.0, 0.0, -0.0, 0.0, 0.87],
|
| 29 |
+
"Single Tinted": [0.12, -0.0, 0.0, -0.0, 0.8, -0.0],
|
| 30 |
+
"Double Clear": [0.14, -0.0, 0.0, -0.0, 0.78, -0.0],
|
| 31 |
+
"Double Low-E": [0.2, -0.0, 0.0, 0.7, 0.0, -0.0],
|
| 32 |
+
"Double Tinted": [0.15, -0.0, 0.0, -0.0, 0.65, -0.0],
|
| 33 |
+
"Double Low-E with Argon": [0.18, -0.0, 0.0, 0.68, 0.0, -0.0],
|
| 34 |
+
"Single Low-E Reflective": [0.22, -0.0, 0.0, 0.6, 0.0, -0.0],
|
| 35 |
+
"Double Reflective": [0.24, -0.0, 0.0, 0.58, 0.0, -0.0],
|
| 36 |
+
"Electrochromic": [0.25, -0.0, 0.5, -0.0, 0.0, -0.0]
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
GLAZING_TYPE_MAPPING = {
|
| 40 |
+
"Single Clear 3mm": "Single Clear",
|
| 41 |
+
"Single Clear 6mm": "Single Clear",
|
| 42 |
+
"Single Tinted 6mm": "Single Tinted",
|
| 43 |
+
"Double Clear 6mm/13mm Air": "Double Clear",
|
| 44 |
+
"Double Low-E 6mm/13mm Air": "Double Low-E",
|
| 45 |
+
"Double Tinted 6mm/13mm Air": "Double Tinted",
|
| 46 |
+
"Double Low-E 6mm/13mm Argon": "Double Low-E with Argon",
|
| 47 |
+
"Single Low-E Reflective 6mm": "Single Low-E Reflective",
|
| 48 |
+
"Double Reflective 6mm/13mm Air": "Double Reflective",
|
| 49 |
+
"Electrochromic 6mm/13mm Air": "Electrochromic"
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
@staticmethod
|
| 53 |
def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> tuple[float, float]:
|
| 54 |
"""Calculate conduction load for heating and cooling in kW based on mode."""
|
|
|
|
| 74 |
heating_load = -load / 1000 if mode == "heating" else 0
|
| 75 |
return cooling_load, heating_load
|
| 76 |
|
| 77 |
+
@staticmethod
|
| 78 |
+
def day_of_year(month: int, day: int, year: int) -> int:
|
| 79 |
+
"""Calculate day of the year (n) from month, day, and year, accounting for leap years.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
month (int): Month of the year (1-12).
|
| 83 |
+
day (int): Day of the month (1-31).
|
| 84 |
+
year (int): Year.
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
int: Day of the year (1-365 or 366 for leap years).
|
| 88 |
+
|
| 89 |
+
References:
|
| 90 |
+
ASHRAE Handbook—Fundamentals, Chapter 18.
|
| 91 |
+
"""
|
| 92 |
+
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
| 93 |
+
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
| 94 |
+
days_in_month[1] = 29
|
| 95 |
+
return sum(days_in_month[:month-1]) + day
|
| 96 |
+
|
| 97 |
+
@staticmethod
|
| 98 |
+
def equation_of_time(n: int) -> float:
|
| 99 |
+
"""Calculate Equation of Time (EOT) in minutes using Spencer's formula.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
n (int): Day of the year (1-365 or 366).
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
float: Equation of Time in minutes.
|
| 106 |
+
|
| 107 |
+
References:
|
| 108 |
+
ASHRAE Handbook—Fundamentals, Chapter 18.
|
| 109 |
+
"""
|
| 110 |
+
B = (n - 1) * 360 / 365
|
| 111 |
+
B_rad = math.radians(B)
|
| 112 |
+
EOT = 229.2 * (0.000075 + 0.001868 * math.cos(B_rad) - 0.032077 * math.sin(B_rad) -
|
| 113 |
+
0.014615 * math.cos(2 * B_rad) - 0.04089 * math.sin(2 * B_rad))
|
| 114 |
+
return EOT
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
def calculate_dynamic_shgc(glazing_type: str, cos_theta: float) -> float:
|
| 118 |
+
"""Calculate dynamic SHGC based on incidence angle.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
glazing_type (str): Type of glazing (e.g., 'Single Clear').
|
| 122 |
+
cos_theta (float): Cosine of the angle of incidence.
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
float: Dynamic SHGC value.
|
| 126 |
+
|
| 127 |
+
References:
|
| 128 |
+
ASHRAE Handbook—Fundamentals, Chapter 15, Table 13.
|
| 129 |
+
"""
|
| 130 |
+
if glazing_type not in TFMCalculations.SHGC_COEFFICIENTS:
|
| 131 |
+
logger.warning(f"Unknown glazing type '{glazing_type}'. Using default SHGC coefficients for Single Clear.")
|
| 132 |
+
glazing_type = "Single Clear"
|
| 133 |
+
|
| 134 |
+
c = TFMCalculations.SHGC_COEFFICIENTS[glazing_type]
|
| 135 |
+
# Incidence angle modifier: f(cos(θ)) = c_0 + c_1·cos(θ) + c_2·cos²(θ) + c_3·cos³(θ) + c_4·cos⁴(θ) + c_5·cos⁵(θ)
|
| 136 |
+
f_cos_theta = (c[0] + c[1] * cos_theta + c[2] * cos_theta**2 +
|
| 137 |
+
c[3] * cos_theta**3 + c[4] * cos_theta**4 + c[5] * cos_theta**5)
|
| 138 |
+
return f_cos_theta
|
| 139 |
+
|
| 140 |
+
@staticmethod
|
| 141 |
+
def get_surface_parameters(component: Any, building_info: Dict, material_library: MaterialLibrary,
|
| 142 |
+
project_materials: Dict, project_constructions: Dict,
|
| 143 |
+
project_glazing_materials: Dict, project_door_materials: Dict) -> Tuple[float, float, float, Optional[float], float]:
|
| 144 |
+
"""
|
| 145 |
+
Determine surface parameters (tilt, azimuth, h_o, emissivity, solar_absorption) for a component.
|
| 146 |
+
Uses MaterialLibrary to fetch properties from first layer for walls/roofs, DoorMaterial for doors,
|
| 147 |
+
and GlazingMaterial for windows/skylights. Handles orientation and tilt based on component type:
|
| 148 |
+
- Walls, Doors, Windows: Azimuth = facade base azimuth + component.rotation; Tilt = 90°.
|
| 149 |
+
- Roofs, Skylights: Azimuth = component.orientation; Tilt = component.tilt (default 180°).
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
component: Component object with component_type, facade, rotation, orientation, tilt,
|
| 153 |
+
construction, glazing_material, or door_material.
|
| 154 |
+
building_info (Dict): Building information containing orientation_angle for facade mapping.
|
| 155 |
+
material_library: MaterialLibrary instance for accessing library materials/constructions.
|
| 156 |
+
project_materials: Dict of project-specific Material objects.
|
| 157 |
+
project_constructions: Dict of project-specific Construction objects.
|
| 158 |
+
project_glazing_materials: Dict of project-specific GlazingMaterial objects.
|
| 159 |
+
project_door_materials: Dict of project-specific DoorMaterial objects.
|
| 160 |
+
|
| 161 |
+
Returns:
|
| 162 |
+
Tuple[float, float, float, Optional[float], float]: Surface tilt (°), surface azimuth (°),
|
| 163 |
+
h_o (W/m²·K), emissivity, solar_absorption.
|
| 164 |
+
|
| 165 |
+
Raises:
|
| 166 |
+
ValueError: If facade is missing or invalid for walls, doors, or windows.
|
| 167 |
+
"""
|
| 168 |
+
# Default parameters
|
| 169 |
+
if component.component_type == ComponentType.ROOF:
|
| 170 |
+
surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent
|
| 171 |
+
h_o = 23.0 # W/m²·K for roofs
|
| 172 |
+
elif component.component_type == ComponentType.SKYLIGHT:
|
| 173 |
+
surface_tilt = getattr(component, 'tilt', 180.0) # Horizontal, downward if tilt absent
|
| 174 |
+
h_o = 23.0 # W/m²·K for skylights
|
| 175 |
+
elif component.component_type == ComponentType.FLOOR:
|
| 176 |
+
surface_tilt = 0.0 # Horizontal, upward
|
| 177 |
+
h_o = 17.0 # W/m²·K
|
| 178 |
+
else: # WALL, DOOR, WINDOW
|
| 179 |
+
surface_tilt = 90.0 # Vertical
|
| 180 |
+
h_o = 17.0 # W/m²·K
|
| 181 |
+
|
| 182 |
+
emissivity = 0.9 # Default for opaque components
|
| 183 |
+
solar_absorption = 0.6 # Default
|
| 184 |
+
shgc = None # Only for windows/skylights
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
# Determine surface azimuth
|
| 188 |
+
if component.component_type in [ComponentType.ROOF, ComponentType.SKYLIGHT]:
|
| 189 |
+
# Use component's orientation attribute directly, ignoring facade
|
| 190 |
+
surface_azimuth = getattr(component, 'orientation', 0.0)
|
| 191 |
+
logger.debug(f"Using component orientation for {component.id}: "
|
| 192 |
+
f"azimuth={surface_azimuth}, tilt={surface_tilt}")
|
| 193 |
+
else: # WALL, DOOR, WINDOW
|
| 194 |
+
# Check for facade attribute
|
| 195 |
+
facade = getattr(component, 'facade', None)
|
| 196 |
+
if not facade:
|
| 197 |
+
component_id = getattr(component, 'id', 'unknown_component')
|
| 198 |
+
raise ValueError(f"Component {component_id} is missing 'facade' field")
|
| 199 |
+
|
| 200 |
+
# Define facade azimuths based on building orientation_angle
|
| 201 |
+
base_azimuth = building_info.get("orientation_angle", 0.0)
|
| 202 |
+
facade_angles = {
|
| 203 |
+
"A": base_azimuth,
|
| 204 |
+
"B": (base_azimuth + 90.0) % 360,
|
| 205 |
+
"C": (base_azimuth + 180.0) % 360,
|
| 206 |
+
"D": (base_azimuth + 270.0) % 360
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
if facade not in facade_angles:
|
| 210 |
+
component_id = getattr(component, 'id', 'unknown_component')
|
| 211 |
+
raise ValueError(f"Invalid facade '{facade}' for component {component_id}. "
|
| 212 |
+
f"Expected one of {list(facade_angles.keys())}")
|
| 213 |
+
|
| 214 |
+
# Add component rotation to facade azimuth
|
| 215 |
+
surface_azimuth = (facade_angles[facade] + getattr(component, 'rotation', 0.0)) % 360
|
| 216 |
+
logger.debug(f"Component {component.id}: facade={facade}, "
|
| 217 |
+
f"base_azimuth={facade_angles[facade]}, rotation={getattr(component, 'rotation', 0.0)}, "
|
| 218 |
+
f"total_azimuth={surface_azimuth}, tilt={surface_tilt}")
|
| 219 |
+
|
| 220 |
+
# Fetch material properties
|
| 221 |
+
if component.component_type in [ComponentType.WALL, ComponentType.ROOF]:
|
| 222 |
+
construction = getattr(component, 'construction', None)
|
| 223 |
+
if not construction:
|
| 224 |
+
logger.warning(f"No construction defined for {component.id}. "
|
| 225 |
+
f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
|
| 226 |
+
else:
|
| 227 |
+
# Get construction from library or project
|
| 228 |
+
construction_obj = (project_constructions.get(construction.name) or
|
| 229 |
+
material_library.library_constructions.get(construction.name))
|
| 230 |
+
if not construction_obj:
|
| 231 |
+
logger.error(f"Construction '{construction.name}' not found for {component.id}.")
|
| 232 |
+
elif not construction_obj.layers:
|
| 233 |
+
logger.warning(f"No layers in construction '{construction.name}' for {component.id}.")
|
| 234 |
+
else:
|
| 235 |
+
# Use first (outermost) layer's properties
|
| 236 |
+
first_layer = construction_obj.layers[0]
|
| 237 |
+
material = first_layer["material"]
|
| 238 |
+
solar_absorption = material.solar_absorption
|
| 239 |
+
emissivity = material.emissivity
|
| 240 |
+
logger.debug(f"Using first layer material '{material.name}' for {component.id}: "
|
| 241 |
+
f"solar_absorption={solar_absorption}, emissivity={emissivity}")
|
| 242 |
+
|
| 243 |
+
elif component.component_type == ComponentType.DOOR:
|
| 244 |
+
door_material = getattr(component, 'door_material', None)
|
| 245 |
+
if not door_material:
|
| 246 |
+
logger.warning(f"No door material defined for {component.id}. "
|
| 247 |
+
f"Using defaults: solar_absorption=0.6, emissivity=0.9.")
|
| 248 |
+
else:
|
| 249 |
+
# Get door material from library or project
|
| 250 |
+
door_material_obj = (project_door_materials.get(door_material.name) or
|
| 251 |
+
material_library.library_door_materials.get(door_material.name))
|
| 252 |
+
if not door_material_obj:
|
| 253 |
+
logger.error(f"Door material '{door_material.name}' not found for {component.id}.")
|
| 254 |
+
else:
|
| 255 |
+
solar_absorption = door_material_obj.solar_absorption
|
| 256 |
+
emissivity = door_material_obj.emissivity
|
| 257 |
+
logger.debug(f"Using door material '{door_material_obj.name}' for {component.id}: "
|
| 258 |
+
f"solar_absorption={solar_absorption}, emissivity={emissivity}")
|
| 259 |
+
|
| 260 |
+
elif component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
| 261 |
+
glazing_material = getattr(component, 'glazing_material', None)
|
| 262 |
+
if not glazing_material:
|
| 263 |
+
logger.warning(f"No glazing material defined for {component.id}. "
|
| 264 |
+
f"Using default SHGC=0.7, h_o={h_o}.")
|
| 265 |
+
shgc = 0.7
|
| 266 |
+
else:
|
| 267 |
+
# Get glazing material from library or project
|
| 268 |
+
glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
|
| 269 |
+
material_library.library_glazing_materials.get(glazing_material.name))
|
| 270 |
+
if not glazing_material_obj:
|
| 271 |
+
logger.error(f"Glazing material '{glazing_material.name}' not found for {component.id}.")
|
| 272 |
+
shgc = 0.7
|
| 273 |
+
else:
|
| 274 |
+
shgc = glazing_material_obj.shgc
|
| 275 |
+
h_o = glazing_material_obj.h_o
|
| 276 |
+
logger.debug(f"Using glazing material '{glazing_material_obj.name}' for {component.id}: "
|
| 277 |
+
f"shgc={shgc}, h_o={h_o}")
|
| 278 |
+
emissivity = None # Not used for glazing
|
| 279 |
+
|
| 280 |
+
except Exception as e:
|
| 281 |
+
component_id = getattr(component, 'id', 'unknown_component')
|
| 282 |
+
logger.error(f"Error retrieving surface parameters for {component_id}: {str(e)}")
|
| 283 |
+
# Apply defaults
|
| 284 |
+
if component.component_type in [ComponentType.WALL, ComponentType.ROOF, ComponentType.DOOR]:
|
| 285 |
+
solar_absorption = 0.6
|
| 286 |
+
emissivity = 0.9
|
| 287 |
+
else: # WINDOW, SKYLIGHT
|
| 288 |
+
shgc = 0.7
|
| 289 |
+
# h_o retains default from component type
|
| 290 |
+
|
| 291 |
+
return surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption
|
| 292 |
+
|
| 293 |
@staticmethod
|
| 294 |
def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
|
| 295 |
"""Calculate solar load in kW (cooling only) using ASHRAE-compliant solar calculations.
|
|
|
|
| 311 |
return 0
|
| 312 |
|
| 313 |
try:
|
| 314 |
+
# Get MaterialLibrary and project-specific data from session state
|
| 315 |
material_library = st.session_state.get("material_library")
|
| 316 |
if not material_library:
|
| 317 |
logger.error("MaterialLibrary not found in session_state")
|
| 318 |
raise ValueError("MaterialLibrary not found in session_state")
|
| 319 |
|
| 320 |
+
project_materials = st.session_state.get("project_materials", {})
|
| 321 |
+
project_constructions = st.session_state.get("project_constructions", {})
|
| 322 |
+
project_glazing_materials = st.session_state.get("project_glazing_materials", {})
|
| 323 |
+
project_door_materials = st.session_state.get("project_door_materials", {})
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
# Get location parameters from climate_data
|
| 326 |
climate_data = st.session_state.get("climate_data", {})
|
|
|
|
| 328 |
longitude = climate_data.get("longitude", 0.0)
|
| 329 |
timezone = climate_data.get("time_zone", 0.0)
|
| 330 |
|
| 331 |
+
# Get ground reflectivity (default 0.2)
|
| 332 |
ground_reflectivity = st.session_state.get("ground_reflectivity", 0.2)
|
| 333 |
|
| 334 |
+
# Validate input parameters
|
| 335 |
+
if not -90 <= latitude <= 90:
|
| 336 |
+
logger.warning(f"Invalid latitude {latitude}. Using default 0.0.")
|
| 337 |
+
latitude = 0.0
|
| 338 |
+
if not -180 <= longitude <= 180:
|
| 339 |
+
logger.warning(f"Invalid longitude {longitude}. Using default 0.0.")
|
| 340 |
+
longitude = 0.0
|
| 341 |
+
if not -12 <= timezone <= 14:
|
| 342 |
+
logger.warning(f"Invalid timezone {timezone}. Using default 0.0.")
|
| 343 |
+
timezone = 0.0
|
| 344 |
+
if not 0 <= ground_reflectivity <= 1:
|
| 345 |
+
logger.warning(f"Invalid ground_reflectivity {ground_reflectivity}. Using default 0.2.")
|
| 346 |
+
ground_reflectivity = 0.2
|
| 347 |
|
| 348 |
# Ensure hourly_data has required fields
|
| 349 |
required_fields = ["month", "day", "hour", "global_horizontal_radiation", "direct_normal_radiation",
|
|
|
|
| 357 |
logger.info(f"No solar load for hour {hour} due to GHI={hourly_data['global_horizontal_radiation']}")
|
| 358 |
return 0
|
| 359 |
|
| 360 |
+
# Extract weather data
|
| 361 |
+
month = hourly_data["month"]
|
| 362 |
+
day = hourly_data["day"]
|
| 363 |
+
hour = hourly_data["hour"]
|
| 364 |
+
ghi = hourly_data["global_horizontal_radiation"]
|
| 365 |
+
dni = hourly_data.get("direct_normal_radiation", ghi * 0.7) # Fallback: estimate DNI
|
| 366 |
+
dhi = hourly_data.get("diffuse_horizontal_radiation", ghi * 0.3) # Fallback: estimate DHI
|
| 367 |
+
outdoor_temp = hourly_data["dry_bulb"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
|
| 369 |
+
if ghi < 0 or dni < 0 or dhi < 0:
|
| 370 |
+
logger.error(f"Negative radiation values for {month}/{day}/{hour}")
|
| 371 |
+
raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
|
|
|
|
|
|
|
|
|
|
| 372 |
|
| 373 |
+
logger.info(f"Processing solar for {month}/{day}/{hour} with GHI={ghi}, DNI={dni}, DHI={dhi}, "
|
| 374 |
+
f"dry_bulb={outdoor_temp}")
|
| 375 |
+
|
| 376 |
+
# Step 1: Local Solar Time (LST) with Equation of Time
|
| 377 |
+
year = 2025 # Fixed year since not provided
|
| 378 |
+
n = TFMCalculations.day_of_year(month, day, year)
|
| 379 |
+
EOT = TFMCalculations.equation_of_time(n)
|
| 380 |
+
lambda_std = 15 * timezone # Standard meridian longitude (°)
|
| 381 |
+
standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour
|
| 382 |
+
LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
|
| 383 |
+
|
| 384 |
+
# Step 2: Solar Declination (δ)
|
| 385 |
+
delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
|
| 386 |
+
|
| 387 |
+
# Step 3: Hour Angle (HRA)
|
| 388 |
+
hra = 15 * (LST - 12)
|
| 389 |
+
|
| 390 |
+
# Step 4: Solar Altitude (α) and Azimuth (ψ)
|
| 391 |
+
phi = math.radians(latitude)
|
| 392 |
+
delta_rad = math.radians(delta)
|
| 393 |
+
hra_rad = math.radians(hra)
|
| 394 |
+
|
| 395 |
+
sin_alpha = math.sin(phi) * math.sin(delta_rad) + math.cos(phi) * math.cos(delta_rad) * math.cos(hra_rad)
|
| 396 |
+
alpha = math.degrees(math.asin(sin_alpha))
|
| 397 |
+
|
| 398 |
+
if abs(math.cos(math.radians(alpha))) < 0.01:
|
| 399 |
+
azimuth = 0 # North at sunrise/sunset
|
| 400 |
+
else:
|
| 401 |
+
sin_az = math.cos(delta_rad) * math.sin(hra_rad) / math.cos(math.radians(alpha))
|
| 402 |
+
cos_az = (sin_alpha * math.sin(phi) - math.sin(delta_rad)) / (math.cos(math.radians(alpha)) * math.cos(phi))
|
| 403 |
+
azimuth = math.degrees(math.atan2(sin_az, cos_az))
|
| 404 |
+
if hra > 0: # Afternoon
|
| 405 |
+
azimuth = 360 - azimuth if azimuth > 0 else -azimuth
|
| 406 |
+
|
| 407 |
+
logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
|
| 408 |
+
f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f}")
|
| 409 |
+
|
| 410 |
+
# Step 5: Get surface parameters
|
| 411 |
+
building_info = {"orientation_angle": building_orientation}
|
| 412 |
+
surface_tilt, surface_azimuth, h_o, emissivity, solar_absorption = \
|
| 413 |
+
TFMCalculations.get_surface_parameters(
|
| 414 |
+
component, building_info, material_library, project_materials,
|
| 415 |
+
project_constructions, project_glazing_materials, project_door_materials
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
# For windows/skylights, get SHGC from material
|
| 419 |
+
shgc = 0.7 # Default
|
| 420 |
+
if component.component_type in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
| 421 |
+
glazing_material = getattr(component, 'glazing_material', None)
|
| 422 |
+
if glazing_material:
|
| 423 |
+
glazing_material_obj = (project_glazing_materials.get(glazing_material.name) or
|
| 424 |
+
material_library.library_glazing_materials.get(glazing_material.name))
|
| 425 |
+
if glazing_material_obj:
|
| 426 |
+
shgc = glazing_material_obj.shgc
|
| 427 |
+
h_o = glazing_material_obj.h_o
|
| 428 |
+
else:
|
| 429 |
+
logger.warning(f"Glazing material '{glazing_material.name}' not found for {component.id}. Using default SHGC=0.7.")
|
| 430 |
+
else:
|
| 431 |
+
logger.warning(f"No glazing material defined for {component.id}. Using default SHGC=0.7.")
|
| 432 |
+
|
| 433 |
+
# Step 6: Calculate angle of incidence (θ)
|
| 434 |
+
cos_theta = (math.sin(math.radians(alpha)) * math.cos(math.radians(surface_tilt)) +
|
| 435 |
+
math.cos(math.radians(alpha)) * math.sin(math.radians(surface_tilt)) *
|
| 436 |
+
math.cos(math.radians(azimuth - surface_azimuth)))
|
| 437 |
+
cos_theta = max(min(cos_theta, 1.0), 0.0) # Clamp to [0, 1]
|
| 438 |
+
|
| 439 |
+
logger.info(f" Component {getattr(component, 'id', 'unknown_component')} at {month}/{day}/{hour}: "
|
| 440 |
+
f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
|
| 441 |
+
f"cos_theta={cos_theta:.2f}")
|
| 442 |
+
|
| 443 |
+
# Step 7: Calculate total incident radiation (I_t)
|
| 444 |
+
view_factor = (1 - math.cos(math.radians(surface_tilt))) / 2
|
| 445 |
+
ground_reflected = ground_reflectivity * ghi * view_factor
|
| 446 |
+
I_t = dni * cos_theta + dhi + ground_reflected
|
| 447 |
+
|
| 448 |
+
# Step 8: Calculate solar heat gain for fenestration
|
| 449 |
+
glazing_type = TFMCalculations.GLAZING_TYPE_MAPPING.get(component.name, 'Single Clear')
|
| 450 |
+
iac = getattr(component, 'iac', 1.0) # Default internal shading
|
| 451 |
+
shgc_dynamic = shgc * TFMCalculations.calculate_dynamic_shgc(glazing_type, cos_theta)
|
| 452 |
+
solar_heat_gain = component.area * shgc_dynamic * I_t * iac / 1000 # kW
|
| 453 |
+
|
| 454 |
+
logger.info(f"Solar heat gain for {getattr(component, 'id', 'unknown_component')} at {month}/{day}/{hour}: "
|
| 455 |
+
f"{solar_heat_gain:.2f} kW (area={component.area}, shgc_dynamic={shgc_dynamic:.2f}, "
|
| 456 |
+
f"I_t={I_t:.2f}, iac={iac})")
|
| 457 |
+
|
| 458 |
+
return solar_heat_gain
|
| 459 |
|
| 460 |
except Exception as e:
|
| 461 |
+
component_id = getattr(component, 'id', 'unknown_component')
|
| 462 |
+
logger.error(f"Error calculating solar load for component {component_id} at hour {hour}: {str(e)}")
|
| 463 |
return 0
|
| 464 |
|
| 465 |
@staticmethod
|