Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- data/calculation.py +289 -34
- data/climate_data.py +859 -0
- data/internal_loads.py +263 -0
- data/material_library.py +505 -0
data/calculation.py
CHANGED
|
@@ -1,40 +1,295 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import numpy as np
|
| 3 |
import pandas as pd
|
| 4 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HVAC Calculator Code Documentation
|
| 3 |
+
|
| 4 |
+
Developed by: Dr Majed Abuseif, Deakin University
|
| 5 |
+
© 2025
|
| 6 |
+
"""
|
| 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 |
+
from data.material_library import Construction, GlazingMaterial, DoorMaterial
|
| 13 |
+
from data.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from collections import defaultdict
|
| 16 |
+
import logging
|
| 17 |
+
from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
|
| 18 |
|
| 19 |
+
# Configure logging
|
| 20 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
+
class TFMCalculations:
|
| 24 |
+
@staticmethod
|
| 25 |
+
def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> tuple[float, float]:
|
| 26 |
+
"""Calculate conduction load for heating and cooling in kW based on mode."""
|
| 27 |
+
if mode == "none":
|
| 28 |
+
return 0, 0
|
| 29 |
+
delta_t = outdoor_temp - indoor_temp
|
| 30 |
+
if mode == "cooling" and delta_t <= 0:
|
| 31 |
+
return 0, 0
|
| 32 |
+
if mode == "heating" and delta_t >= 0:
|
| 33 |
+
return 0, 0
|
| 34 |
|
| 35 |
+
# Get CTF coefficients using CTFCalculator
|
| 36 |
+
ctf = CTFCalculator.calculate_ctf_coefficients(component)
|
| 37 |
+
|
| 38 |
+
# Initialize history terms (simplified: assume steady-state history for demonstration)
|
| 39 |
+
# In practice, maintain temperature and flux histories
|
| 40 |
+
load = component.u_value * component.area * delta_t
|
| 41 |
+
for i in range(len(ctf.Y)):
|
| 42 |
+
load += component.area * ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
| 43 |
+
load -= component.area * ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
|
| 44 |
+
# Note: F terms require flux history, omitted here for simplicity
|
| 45 |
+
cooling_load = load / 1000 if mode == "cooling" else 0
|
| 46 |
+
heating_load = -load / 1000 if mode == "heating" else 0
|
| 47 |
+
return cooling_load, heating_load
|
| 48 |
+
|
| 49 |
+
@staticmethod
|
| 50 |
+
def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
|
| 51 |
+
"""Calculate solar load in kW (cooling only) based on mode."""
|
| 52 |
+
if mode != "cooling" or component.component_type not in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
|
| 53 |
+
return 0
|
| 54 |
+
solar_radiation = hourly_data.get("global_horizontal_radiation", 800)
|
| 55 |
+
shgc = component.shgc or 0.7
|
| 56 |
+
absolute_angle = (building_orientation + component.orientation_angle) % 360
|
| 57 |
+
solar_incidence = np.cos(np.radians(absolute_angle - hour % 24 * 15))
|
| 58 |
+
return component.area * shgc * solar_radiation * max(solar_incidence, 0) * 0.8 / 1000
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
def calculate_internal_load(internal_loads: Dict, hour: int, operation_hours: int, area: float) -> float:
|
| 62 |
+
"""Calculate total internal load in kW."""
|
| 63 |
+
total_load = 0
|
| 64 |
+
for group in internal_loads.get("people", []):
|
| 65 |
+
activity_data = group["activity_data"]
|
| 66 |
+
sensible = (activity_data["sensible_min_w"] + activity_data["sensible_max_w"]) / 2
|
| 67 |
+
latent = (activity_data["latent_min_w"] + activity_data["latent_max_w"]) / 2
|
| 68 |
+
load_per_person = sensible + latent
|
| 69 |
+
total_load += group["num_people"] * load_per_person * group["diversity_factor"]
|
| 70 |
+
for light in internal_loads.get("lighting", []):
|
| 71 |
+
lpd = light["lpd"]
|
| 72 |
+
lighting_operating_hours = light["operating_hours"]
|
| 73 |
+
fraction = min(lighting_operating_hours, operation_hours) / operation_hours if operation_hours > 0 else 0
|
| 74 |
+
lighting_load = lpd * area * fraction
|
| 75 |
+
total_load += lighting_load
|
| 76 |
+
equipment = internal_loads.get("equipment")
|
| 77 |
+
if equipment:
|
| 78 |
+
total_power_density = equipment.get("total_power_density", 0)
|
| 79 |
+
equipment_load = total_power_density * area
|
| 80 |
+
total_load += equipment_load
|
| 81 |
+
return total_load / 1000
|
| 82 |
+
|
| 83 |
+
@staticmethod
|
| 84 |
+
def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
|
| 85 |
+
"""Calculate ventilation load for heating and cooling in kW based on mode."""
|
| 86 |
+
if mode == "none":
|
| 87 |
+
return 0, 0
|
| 88 |
+
ventilation = internal_loads.get("ventilation")
|
| 89 |
+
if not ventilation:
|
| 90 |
+
return 0, 0
|
| 91 |
+
space_rate = ventilation.get("space_rate", 0.3) # L/s/m²
|
| 92 |
+
people_rate = ventilation.get("people_rate", 2.5) # L/s/person
|
| 93 |
+
num_people = sum(group["num_people"] for group in internal_loads.get("people", []))
|
| 94 |
+
ventilation_flow = (space_rate * area + people_rate * num_people) / 1000 # m³/s
|
| 95 |
+
air_density = 1.2 # kg/m³
|
| 96 |
+
specific_heat = 1000 # J/kg·K
|
| 97 |
+
delta_t = outdoor_temp - indoor_temp
|
| 98 |
+
if mode == "cooling" and delta_t <= 0:
|
| 99 |
+
return 0, 0
|
| 100 |
+
if mode == "heating" and delta_t >= 0:
|
| 101 |
+
return 0, 0
|
| 102 |
+
load = ventilation_flow * air_density * specific_heat * delta_t / 1000 # kW
|
| 103 |
+
cooling_load = load if mode == "cooling" else 0
|
| 104 |
+
heating_load = -load if mode == "heating" else 0
|
| 105 |
+
return cooling_load, heating_load
|
| 106 |
+
|
| 107 |
+
@staticmethod
|
| 108 |
+
def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
|
| 109 |
+
"""Calculate infiltration load for heating and cooling in kW based on mode."""
|
| 110 |
+
if mode == "none":
|
| 111 |
+
return 0, 0
|
| 112 |
+
infiltration = internal_loads.get("infiltration")
|
| 113 |
+
if not infiltration:
|
| 114 |
+
return 0, 0
|
| 115 |
+
method = infiltration.get("method", "ACH")
|
| 116 |
+
settings = infiltration.get("settings", {})
|
| 117 |
+
building_height = building_info.get("building_height", 3.0)
|
| 118 |
+
volume = area * building_height # m³
|
| 119 |
+
air_density = 1.2 # kg/m³
|
| 120 |
+
specific_heat = 1000 # J/kg·K
|
| 121 |
+
delta_t = outdoor_temp - indoor_temp
|
| 122 |
+
if mode == "cooling" and delta_t <= 0:
|
| 123 |
+
return 0, 0
|
| 124 |
+
if mode == "heating" and delta_t >= 0:
|
| 125 |
+
return 0, 0
|
| 126 |
+
if method == "ACH":
|
| 127 |
+
ach = settings.get("rate", 0.5)
|
| 128 |
+
infiltration_flow = ach * volume / 3600 # m³/s
|
| 129 |
+
elif method == "Crack Flow":
|
| 130 |
+
ela = settings.get("ela", 0.0001) # m²/m²
|
| 131 |
+
wind_speed = 4.0 # m/s (assumed)
|
| 132 |
+
infiltration_flow = ela * area * wind_speed / 2 # m³/s
|
| 133 |
+
else: # Empirical Equations
|
| 134 |
+
c = settings.get("c", 0.1)
|
| 135 |
+
n = settings.get("n", 0.65)
|
| 136 |
+
delta_t_abs = abs(delta_t)
|
| 137 |
+
infiltration_flow = c * (delta_t_abs ** n) * area / 3600 # m³/s
|
| 138 |
+
load = infiltration_flow * air_density * specific_heat * delta_t / 1000 # kW
|
| 139 |
+
cooling_load = load if mode == "cooling" else 0
|
| 140 |
+
heating_load = -load if mode == "heating" else 0
|
| 141 |
+
return cooling_load, heating_load
|
| 142 |
+
|
| 143 |
+
@staticmethod
|
| 144 |
+
def get_adaptive_comfort_temp(outdoor_temp: float) -> float:
|
| 145 |
+
"""Calculate adaptive comfort temperature per ASHRAE 55."""
|
| 146 |
+
if 10 <= outdoor_temp <= 33.5:
|
| 147 |
+
return 0.31 * outdoor_temp + 17.8
|
| 148 |
+
return 24.0 # Default to standard setpoint if outside range
|
| 149 |
+
|
| 150 |
+
@staticmethod
|
| 151 |
+
def filter_hourly_data(hourly_data: List[Dict], sim_period: Dict, climate_data: Dict) -> List[Dict]:
|
| 152 |
+
"""Filter hourly data based on simulation period, ignoring year."""
|
| 153 |
+
if sim_period["type"] == "Full Year":
|
| 154 |
+
return hourly_data
|
| 155 |
+
filtered_data = []
|
| 156 |
+
if sim_period["type"] == "From-to":
|
| 157 |
+
start_month = sim_period["start_date"].month
|
| 158 |
+
start_day = sim_period["start_date"].day
|
| 159 |
+
end_month = sim_period["end_date"].month
|
| 160 |
+
end_day = sim_period["end_date"].day
|
| 161 |
+
for data in hourly_data:
|
| 162 |
+
month, day = data["month"], data["day"]
|
| 163 |
+
if (month > start_month or (month == start_month and day >= start_day)) and \
|
| 164 |
+
(month < end_month or (month == end_month and day <= end_day)):
|
| 165 |
+
filtered_data.append(data)
|
| 166 |
+
elif sim_period["type"] in ["HDD", "CDD"]:
|
| 167 |
+
base_temp = sim_period.get("base_temp", 18.3 if sim_period["type"] == "HDD" else 23.9)
|
| 168 |
+
for data in hourly_data:
|
| 169 |
+
temp = data["dry_bulb"]
|
| 170 |
+
if (sim_period["type"] == "HDD" and temp < base_temp) or (sim_period["type"] == "CDD" and temp > base_temp):
|
| 171 |
+
filtered_data.append(data)
|
| 172 |
+
return filtered_data
|
| 173 |
+
|
| 174 |
+
@staticmethod
|
| 175 |
+
def get_indoor_conditions(indoor_conditions: Dict, hour: int, outdoor_temp: float) -> Dict:
|
| 176 |
+
"""Determine indoor conditions based on user settings."""
|
| 177 |
+
if indoor_conditions["type"] == "Fixed":
|
| 178 |
+
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
|
| 179 |
+
if mode == "cooling":
|
| 180 |
+
return {
|
| 181 |
+
"temperature": indoor_conditions.get("cooling_setpoint", {}).get("temperature", 24.0),
|
| 182 |
+
"rh": indoor_conditions.get("cooling_setpoint", {}).get("rh", 50.0)
|
| 183 |
+
}
|
| 184 |
+
elif mode == "heating":
|
| 185 |
+
return {
|
| 186 |
+
"temperature": indoor_conditions.get("heating_setpoint", {}).get("temperature", 22.0),
|
| 187 |
+
"rh": indoor_conditions.get("heating_setpoint", {}).get("rh", 50.0)
|
| 188 |
+
}
|
| 189 |
+
else:
|
| 190 |
+
return {"temperature": 24.0, "rh": 50.0}
|
| 191 |
+
elif indoor_conditions["type"] == "Time-varying":
|
| 192 |
+
schedule = indoor_conditions.get("schedule", [])
|
| 193 |
+
if schedule:
|
| 194 |
+
hour_idx = hour % 24
|
| 195 |
+
for entry in schedule:
|
| 196 |
+
if entry["hour"] == hour_idx:
|
| 197 |
+
return {"temperature": entry["temperature"], "rh": entry["rh"]}
|
| 198 |
+
return {"temperature": 24.0, "rh": 50.0}
|
| 199 |
+
else: # Adaptive
|
| 200 |
+
return {"temperature": TFMCalculations.get_adaptive_comfort_temp(outdoor_temp), "rh": 50.0}
|
| 201 |
+
|
| 202 |
+
@staticmethod
|
| 203 |
+
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]:
|
| 204 |
+
"""Calculate TFM loads for heating and cooling with user-defined filters and temperature threshold."""
|
| 205 |
+
filtered_data = TFMCalculations.filter_hourly_data(hourly_data, sim_period, building_info)
|
| 206 |
+
temp_loads = []
|
| 207 |
+
building_orientation = building_info.get("orientation_angle", 0.0)
|
| 208 |
+
operating_periods = hvac_settings.get("operating_hours", [{"start": 8, "end": 18}])
|
| 209 |
+
area = building_info.get("floor_area", 100.0)
|
| 210 |
+
|
| 211 |
+
# Pre-calculate CTF coefficients for all components using CTFCalculator
|
| 212 |
+
for comp_list in components.values():
|
| 213 |
+
for comp in comp_list:
|
| 214 |
+
comp.ctf = CTFCalculator.calculate_ctf_coefficients(comp)
|
| 215 |
|
| 216 |
+
for hour_data in filtered_data:
|
| 217 |
+
hour = hour_data["hour"]
|
| 218 |
+
outdoor_temp = hour_data["dry_bulb"]
|
| 219 |
+
indoor_cond = TFMCalculations.get_indoor_conditions(indoor_conditions, hour, outdoor_temp)
|
| 220 |
+
indoor_temp = indoor_cond["temperature"]
|
| 221 |
+
# Initialize all loads to 0
|
| 222 |
+
conduction_cooling = conduction_heating = solar = internal = ventilation_cooling = ventilation_heating = infiltration_cooling = infiltration_heating = 0
|
| 223 |
+
# Check if hour is within operating periods
|
| 224 |
+
is_operating = False
|
| 225 |
+
for period in operating_periods:
|
| 226 |
+
start_hour = period.get("start", 8)
|
| 227 |
+
end_hour = period.get("end", 18)
|
| 228 |
+
if start_hour <= hour % 24 <= end_hour:
|
| 229 |
+
is_operating = True
|
| 230 |
+
break
|
| 231 |
+
# Determine mode based on temperature threshold (18°C)
|
| 232 |
+
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
|
| 233 |
+
if is_operating and mode == "cooling":
|
| 234 |
+
for comp_list in components.values():
|
| 235 |
+
for comp in comp_list:
|
| 236 |
+
cool_load, _ = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="cooling")
|
| 237 |
+
conduction_cooling += cool_load
|
| 238 |
+
solar += TFMCalculations.calculate_solar_load(comp, hour_data, hour, building_orientation, mode="cooling")
|
| 239 |
+
internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
|
| 240 |
+
ventilation_cooling, _ = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
|
| 241 |
+
infiltration_cooling, _ = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
|
| 242 |
+
elif is_operating and mode == "heating":
|
| 243 |
+
for comp_list in components.values():
|
| 244 |
+
for comp in comp_list:
|
| 245 |
+
_, heat_load = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="heating")
|
| 246 |
+
conduction_heating += heat_load
|
| 247 |
+
internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
|
| 248 |
+
_, ventilation_heating = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
|
| 249 |
+
_, infiltration_heating = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
|
| 250 |
+
else: # mode == "none" or not is_operating
|
| 251 |
+
internal = 0 # No internal loads when no heating or cooling is needed or outside operating hours
|
| 252 |
+
# Calculate total loads, subtracting internal load for heating
|
| 253 |
+
total_cooling = conduction_cooling + solar + internal + ventilation_cooling + infiltration_cooling
|
| 254 |
+
total_heating = max(conduction_heating + ventilation_heating + infiltration_heating - internal, 0)
|
| 255 |
+
# Enforce mutual exclusivity within hour
|
| 256 |
+
if mode == "cooling":
|
| 257 |
+
total_heating = 0
|
| 258 |
+
elif mode == "heating":
|
| 259 |
+
total_cooling = 0
|
| 260 |
+
temp_loads.append({
|
| 261 |
+
"hour": hour,
|
| 262 |
+
"month": hour_data["month"],
|
| 263 |
+
"day": hour_data["day"],
|
| 264 |
+
"conduction_cooling": conduction_cooling,
|
| 265 |
+
"conduction_heating": conduction_heating,
|
| 266 |
+
"solar": solar,
|
| 267 |
+
"internal": internal,
|
| 268 |
+
"ventilation_cooling": ventilation_cooling,
|
| 269 |
+
"ventilation_heating": ventilation_heating,
|
| 270 |
+
"infiltration_cooling": infiltration_cooling,
|
| 271 |
+
"infiltration_heating": infiltration_heating,
|
| 272 |
+
"total_cooling": total_cooling,
|
| 273 |
+
"total_heating": total_heating
|
| 274 |
+
})
|
| 275 |
+
# Group loads by day and apply daily control
|
| 276 |
+
loads_by_day = defaultdict(list)
|
| 277 |
+
for load in temp_loads:
|
| 278 |
+
day_key = (load["month"], load["day"])
|
| 279 |
+
loads_by_day[day_key].append(load)
|
| 280 |
+
final_loads = []
|
| 281 |
+
for day_key, day_loads in loads_by_day.items():
|
| 282 |
+
# Count hours with non-zero cooling and heating loads
|
| 283 |
+
cooling_hours = sum(1 for load in day_loads if load["total_cooling"] > 0)
|
| 284 |
+
heating_hours = sum(1 for load in day_loads if load["total_heating"] > 0)
|
| 285 |
+
# Apply daily control
|
| 286 |
+
for load in day_loads:
|
| 287 |
+
if cooling_hours > heating_hours:
|
| 288 |
+
load["total_heating"] = 0 # Keep cooling components, zero heating total
|
| 289 |
+
elif heating_hours > cooling_hours:
|
| 290 |
+
load["total_cooling"] = 0 # Keep heating components, zero cooling total
|
| 291 |
+
else: # Equal hours
|
| 292 |
+
load["total_cooling"] = 0
|
| 293 |
+
load["total_heating"] = 0 # Zero both totals, keep components
|
| 294 |
+
final_loads.append(load)
|
| 295 |
+
return final_loads
|
data/climate_data.py
ADDED
|
@@ -0,0 +1,859 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Extracts climate data from EPW files
|
| 3 |
+
Includes Solar Analysis tab for solar angle and ground-reflected radiation calculations.
|
| 4 |
+
|
| 5 |
+
Author: Dr Majed Abuseif
|
| 6 |
+
Date: May 2025
|
| 7 |
+
Version: 2.1.6
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Dict, List, Any, Optional
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import numpy as np
|
| 13 |
+
import os
|
| 14 |
+
import json
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
import streamlit as st
|
| 17 |
+
import plotly.graph_objects as go
|
| 18 |
+
from io import StringIO
|
| 19 |
+
import pvlib
|
| 20 |
+
from datetime import datetime, timedelta
|
| 21 |
+
import re
|
| 22 |
+
import logging
|
| 23 |
+
from data.solar_calculations import SolarCalculations
|
| 24 |
+
|
| 25 |
+
# Set up logging
|
| 26 |
+
logging.basicConfig(level=logging.INFO)
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
# Define paths
|
| 30 |
+
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 31 |
+
|
| 32 |
+
# CSS for consistent formatting
|
| 33 |
+
STYLE = """
|
| 34 |
+
<style>
|
| 35 |
+
.markdown-text {
|
| 36 |
+
font-family: Roboto, sans-serif;
|
| 37 |
+
font-size: 14px;
|
| 38 |
+
line-height: 1.5;
|
| 39 |
+
margin-bottom: 20px;
|
| 40 |
+
}
|
| 41 |
+
.markdown-text h3 {
|
| 42 |
+
font-size: 18px;
|
| 43 |
+
font-weight: bold;
|
| 44 |
+
margin-top: 20px;
|
| 45 |
+
margin-bottom: 10px;
|
| 46 |
+
}
|
| 47 |
+
.markdown-text ul {
|
| 48 |
+
list-style-type: disc;
|
| 49 |
+
padding-left: 20px;
|
| 50 |
+
margin: 0;
|
| 51 |
+
}
|
| 52 |
+
.markdown-text li {
|
| 53 |
+
margin-bottom: 8px;
|
| 54 |
+
}
|
| 55 |
+
.markdown-text strong {
|
| 56 |
+
font-weight: bold;
|
| 57 |
+
}
|
| 58 |
+
.two-column {
|
| 59 |
+
display: grid;
|
| 60 |
+
grid-template-columns: 1fr 1fr;
|
| 61 |
+
gap: 20px;
|
| 62 |
+
}
|
| 63 |
+
.column {
|
| 64 |
+
width: 100%;
|
| 65 |
+
}
|
| 66 |
+
</style>
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
@dataclass
|
| 70 |
+
class ClimateLocation:
|
| 71 |
+
"""Class representing a climate location with ASHRAE 169 data derived from EPW files."""
|
| 72 |
+
|
| 73 |
+
id: str
|
| 74 |
+
country: str
|
| 75 |
+
state_province: str
|
| 76 |
+
city: str
|
| 77 |
+
latitude: float
|
| 78 |
+
longitude: float
|
| 79 |
+
elevation: float # meters
|
| 80 |
+
timezone: float # hours from UTC
|
| 81 |
+
climate_zone: str
|
| 82 |
+
heating_degree_days: float # base 18°C
|
| 83 |
+
cooling_degree_days: float # base 18°C
|
| 84 |
+
winter_design_temp: float # 99.6% heating design temperature (°C)
|
| 85 |
+
summer_design_temp_db: float # 0.4% cooling design dry-bulb temperature (°C)
|
| 86 |
+
summer_design_temp_wb: float # 0.4% cooling design wet-bulb temperature (°C)
|
| 87 |
+
summer_daily_range: float # Mean daily temperature range in summer (°C)
|
| 88 |
+
wind_speed: float # Mean wind speed (m/s)
|
| 89 |
+
pressure: float # Mean atmospheric pressure (Pa)
|
| 90 |
+
hourly_data: List[Dict] # Hourly data for integration with main.py
|
| 91 |
+
typical_extreme_periods: Dict[str, Dict] # Typical/extreme periods (summer/winter)
|
| 92 |
+
ground_temperatures: Dict[str, List[float]] # Monthly ground temperatures by depth
|
| 93 |
+
solar_calculations: List[Dict] = None # Solar calculation results
|
| 94 |
+
|
| 95 |
+
def __init__(self, epw_file: pd.DataFrame, typical_extreme_periods: Dict, ground_temperatures: Dict, **kwargs):
|
| 96 |
+
"""Initialize ClimateLocation with EPW file data and header information."""
|
| 97 |
+
self.id = kwargs.get("id")
|
| 98 |
+
self.country = kwargs.get("country")
|
| 99 |
+
self.state_province = kwargs.get("state_province", "N/A")
|
| 100 |
+
self.city = kwargs.get("city")
|
| 101 |
+
self.latitude = kwargs.get("latitude")
|
| 102 |
+
self.longitude = kwargs.get("longitude")
|
| 103 |
+
self.elevation = kwargs.get("elevation")
|
| 104 |
+
self.timezone = kwargs.get("timezone")
|
| 105 |
+
self.typical_extreme_periods = typical_extreme_periods
|
| 106 |
+
self.ground_temperatures = ground_temperatures
|
| 107 |
+
self.solar_calculations = kwargs.get("solar_calculations", [])
|
| 108 |
+
|
| 109 |
+
# Extract columns from EPW data
|
| 110 |
+
months = pd.to_numeric(epw_file[1], errors='coerce').values
|
| 111 |
+
days = pd.to_numeric(epw_file[2], errors='coerce').values
|
| 112 |
+
hours = pd.to_numeric(epw_file[3], errors='coerce').values
|
| 113 |
+
dry_bulb = pd.to_numeric(epw_file[6], errors='coerce').values
|
| 114 |
+
humidity = pd.to_numeric(epw_file[8], errors='coerce').values
|
| 115 |
+
pressure = pd.to_numeric(epw_file[9], errors='coerce').values
|
| 116 |
+
global_radiation = pd.to_numeric(epw_file[13], errors='coerce').values
|
| 117 |
+
direct_normal_radiation = pd.to_numeric(epw_file[14], errors='coerce').values
|
| 118 |
+
diffuse_horizontal_radiation = pd.to_numeric(epw_file[15], errors='coerce').values
|
| 119 |
+
wind_direction = pd.to_numeric(epw_file[20], errors='coerce').values
|
| 120 |
+
wind_speed = pd.to_numeric(epw_file[21], errors='coerce')
|
| 121 |
+
|
| 122 |
+
# Filter wind speed outliers and log high values
|
| 123 |
+
wind_speed = wind_speed[wind_speed <= 50] # Remove extreme outliers
|
| 124 |
+
if (wind_speed > 15).any():
|
| 125 |
+
logger.warning(f"High wind speeds detected: {wind_speed[wind_speed > 15].tolist()}")
|
| 126 |
+
|
| 127 |
+
# Calculate wet-bulb temperature
|
| 128 |
+
wet_bulb = ClimateData.calculate_wet_bulb(dry_bulb, humidity)
|
| 129 |
+
|
| 130 |
+
# Calculate design conditions
|
| 131 |
+
self.winter_design_temp = round(np.nanpercentile(dry_bulb, 0.4), 1)
|
| 132 |
+
self.summer_design_temp_db = round(np.nanpercentile(dry_bulb, 99.6), 1)
|
| 133 |
+
self.summer_design_temp_wb = round(np.nanpercentile(wet_bulb, 99.6), 1)
|
| 134 |
+
|
| 135 |
+
# Calculate degree days using (T_max + T_min)/2
|
| 136 |
+
daily_temps = dry_bulb.reshape(-1, 24)
|
| 137 |
+
daily_max = np.nanmax(daily_temps, axis=1)
|
| 138 |
+
daily_min = np.nanmin(daily_temps, axis=1)
|
| 139 |
+
daily_avg = (daily_max + daily_min) / 2
|
| 140 |
+
self.heating_degree_days = round(np.nansum(np.where(daily_avg < 18, 18 - daily_avg, 0)))
|
| 141 |
+
self.cooling_degree_days = round(np.nansum(np.where(daily_avg > 18, daily_avg - 18, 0)))
|
| 142 |
+
|
| 143 |
+
# Calculate summer daily temperature range (June–August, Southern Hemisphere)
|
| 144 |
+
summer_mask = (months >= 6) & (months <= 8)
|
| 145 |
+
summer_temps = dry_bulb[summer_mask].reshape(-1, 24)
|
| 146 |
+
self.summer_daily_range = round(np.nanmean(np.nanmax(summer_temps, axis=1) - np.nanmin(summer_temps, axis=1)), 1)
|
| 147 |
+
|
| 148 |
+
# Calculate mean wind speed and pressure
|
| 149 |
+
self.wind_speed = round(np.nanmean(wind_speed), 1)
|
| 150 |
+
self.pressure = round(np.nanmean(pressure), 1)
|
| 151 |
+
|
| 152 |
+
# Log wind speed diagnostics
|
| 153 |
+
logger.info(f"Wind speed stats: min={wind_speed.min():.1f}, max={wind_speed.max():.1f}, mean={self.wind_speed:.1f}")
|
| 154 |
+
|
| 155 |
+
# Assign climate zone
|
| 156 |
+
self.climate_zone = ClimateData.assign_climate_zone(self.heating_degree_days, self.cooling_degree_days, np.nanmean(humidity))
|
| 157 |
+
|
| 158 |
+
# Store hourly data with enhanced fields
|
| 159 |
+
self.hourly_data = []
|
| 160 |
+
for i in range(len(months)):
|
| 161 |
+
if np.isnan(months[i]) or np.isnan(days[i]) or np.isnan(hours[i]) or np.isnan(dry_bulb[i]):
|
| 162 |
+
continue # Skip records with missing critical fields
|
| 163 |
+
record = {
|
| 164 |
+
"month": int(months[i]),
|
| 165 |
+
"day": int(days[i]),
|
| 166 |
+
"hour": int(hours[i]),
|
| 167 |
+
"dry_bulb": float(dry_bulb[i]),
|
| 168 |
+
"relative_humidity": float(humidity[i]) if not np.isnan(humidity[i]) else 0.0,
|
| 169 |
+
"atmospheric_pressure": float(pressure[i]) if not np.isnan(pressure[i]) else self.pressure,
|
| 170 |
+
"global_horizontal_radiation": float(global_radiation[i]) if not np.isnan(global_radiation[i]) else 0.0,
|
| 171 |
+
"direct_normal_radiation": float(direct_normal_radiation[i]) if not np.isnan(direct_normal_radiation[i]) else 0.0,
|
| 172 |
+
"diffuse_horizontal_radiation": float(diffuse_horizontal_radiation[i]) if not np.isnan(diffuse_horizontal_radiation[i]) else 0.0,
|
| 173 |
+
"wind_speed": float(wind_speed[i]) if not np.isnan(wind_speed[i]) else 0.0,
|
| 174 |
+
"wind_direction": float(wind_direction[i]) if not np.isnan(wind_direction[i]) else 0.0
|
| 175 |
+
}
|
| 176 |
+
self.hourly_data.append(record)
|
| 177 |
+
|
| 178 |
+
if len(self.hourly_data) != 8760:
|
| 179 |
+
st.warning(f"Hourly data has {len(self.hourly_data)} records instead of 8760. Some records may have been excluded due to missing data.")
|
| 180 |
+
|
| 181 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 182 |
+
"""Convert the climate location to a dictionary."""
|
| 183 |
+
return {
|
| 184 |
+
"id": self.id,
|
| 185 |
+
"country": self.country,
|
| 186 |
+
"state_province": self.state_province,
|
| 187 |
+
"city": self.city,
|
| 188 |
+
"latitude": self.latitude,
|
| 189 |
+
"longitude": self.longitude,
|
| 190 |
+
"elevation": self.elevation,
|
| 191 |
+
"timezone": self.timezone,
|
| 192 |
+
"climate_zone": self.climate_zone,
|
| 193 |
+
"heating_degree_days": self.heating_degree_days,
|
| 194 |
+
"cooling_degree_days": self.cooling_degree_days,
|
| 195 |
+
"winter_design_temp": self.winter_design_temp,
|
| 196 |
+
"summer_design_temp_db": self.summer_design_temp_db,
|
| 197 |
+
"summer_design_temp_wb": self.summer_design_temp_wb,
|
| 198 |
+
"summer_daily_range": self.summer_daily_range,
|
| 199 |
+
"wind_speed": self.wind_speed,
|
| 200 |
+
"pressure": self.pressure,
|
| 201 |
+
"hourly_data": self.hourly_data,
|
| 202 |
+
"typical_extreme_periods": self.typical_extreme_periods,
|
| 203 |
+
"ground_temperatures": self.ground_temperatures,
|
| 204 |
+
"solar_calculations": self.solar_calculations
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
class ClimateData:
|
| 208 |
+
"""Class for managing ASHRAE 169 climate data from EPW files."""
|
| 209 |
+
|
| 210 |
+
def __init__(self):
|
| 211 |
+
"""Initialize climate data."""
|
| 212 |
+
self.locations = {}
|
| 213 |
+
self.countries = []
|
| 214 |
+
self.country_states = {}
|
| 215 |
+
|
| 216 |
+
def add_location(self, location: ClimateLocation):
|
| 217 |
+
"""Add a new location to the dictionary."""
|
| 218 |
+
self.locations[location.id] = location
|
| 219 |
+
self.countries = sorted(list(set(loc.country for loc in self.locations.values())))
|
| 220 |
+
self.country_states = self._group_locations_by_country_state()
|
| 221 |
+
|
| 222 |
+
def _group_locations_by_country_state(self) -> Dict[str, Dict[str, List[str]]]:
|
| 223 |
+
"""Group locations by country and state/province."""
|
| 224 |
+
result = {}
|
| 225 |
+
for loc in self.locations.values():
|
| 226 |
+
if loc.country not in result:
|
| 227 |
+
result[loc.country] = {}
|
| 228 |
+
if loc.state_province not in result[loc.country]:
|
| 229 |
+
result[loc.country][loc.state_province] = []
|
| 230 |
+
result[loc.country][loc.state_province].append(loc.city)
|
| 231 |
+
for country in result:
|
| 232 |
+
for state in result[country]:
|
| 233 |
+
result[country][state] = sorted(result[country][state])
|
| 234 |
+
return result
|
| 235 |
+
|
| 236 |
+
def get_location_by_id(self, location_id: str, session_state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
| 237 |
+
"""Retrieve climate data by ID from session state or locations."""
|
| 238 |
+
if "climate_data" in session_state and session_state["climate_data"].get("id") == location_id:
|
| 239 |
+
return session_state["climate_data"]
|
| 240 |
+
if location_id in self.locations:
|
| 241 |
+
return self.locations[location_id].to_dict()
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
@staticmethod
|
| 245 |
+
def validate_climate_data(data: Dict[str, Any]) -> bool:
|
| 246 |
+
"""Validate climate data for required fields and ranges."""
|
| 247 |
+
required_fields = [
|
| 248 |
+
"id", "country", "city", "latitude", "longitude", "elevation", "timezone",
|
| 249 |
+
"climate_zone", "heating_degree_days", "cooling_degree_days",
|
| 250 |
+
"winter_design_temp", "summer_design_temp_db", "summer_design_temp_wb",
|
| 251 |
+
"summer_daily_range", "wind_speed", "pressure", "hourly_data"
|
| 252 |
+
]
|
| 253 |
+
|
| 254 |
+
for field in required_fields:
|
| 255 |
+
if field not in data:
|
| 256 |
+
st.error(f"Validation failed: Missing required field '{field}'")
|
| 257 |
+
return False
|
| 258 |
+
|
| 259 |
+
if not (-90 <= data["latitude"] <= 90 and -180 <= data["longitude"] <= 180):
|
| 260 |
+
st.error("Validation failed: Invalid latitude or longitude")
|
| 261 |
+
return False
|
| 262 |
+
if data["elevation"] < 0:
|
| 263 |
+
st.error("Validation failed: Negative elevation")
|
| 264 |
+
return False
|
| 265 |
+
if not (-24 <= data["timezone"] <= 24):
|
| 266 |
+
st.error(f"Validation failed: Timezone {data['timezone']} outside range")
|
| 267 |
+
return False
|
| 268 |
+
if data["climate_zone"] not in ["0A", "0B", "1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"]:
|
| 269 |
+
st.error(f"Validation failed: Invalid climate zone '{data['climate_zone']}'")
|
| 270 |
+
return False
|
| 271 |
+
if not (data["heating_degree_days"] >= 0 and data["cooling_degree_days"] >= 0):
|
| 272 |
+
st.error("Validation failed: Negative degree days")
|
| 273 |
+
return False
|
| 274 |
+
if not (-50 <= data["winter_design_temp"] <= 20):
|
| 275 |
+
st.error(f"Validation failed: Winter design temp {data['winter_design_temp']} outside range")
|
| 276 |
+
return False
|
| 277 |
+
if not (0 <= data["summer_design_temp_db"] <= 50 and 0 <= data["summer_design_temp_wb"] <= 40):
|
| 278 |
+
st.error("Validation failed: Invalid summer design temperatures")
|
| 279 |
+
return False
|
| 280 |
+
if data["summer_daily_range"] < 0:
|
| 281 |
+
st.error("Validation failed: Negative summer daily range")
|
| 282 |
+
return False
|
| 283 |
+
if not (0 <= data["wind_speed"] <= 30):
|
| 284 |
+
st.error(f"Validation failed: Wind speed {data['wind_speed']} outside range")
|
| 285 |
+
return False
|
| 286 |
+
if not (80000 <= data["pressure"] <= 110000):
|
| 287 |
+
st.error(f"Validation failed: Pressure {data['pressure']} outside range")
|
| 288 |
+
return False
|
| 289 |
+
|
| 290 |
+
if not data["hourly_data"] or len(data["hourly_data"]) < 8700:
|
| 291 |
+
st.error(f"Validation failed: Hourly data has {len(data['hourly_data'])} records, expected ~8760")
|
| 292 |
+
return False
|
| 293 |
+
for record in data["hourly_data"]:
|
| 294 |
+
if not (1 <= record["month"] <= 12):
|
| 295 |
+
st.error(f"Validation failed: Invalid month {record['month']}")
|
| 296 |
+
return False
|
| 297 |
+
if not (1 <= record["day"] <= 31):
|
| 298 |
+
st.error(f"Validation failed: Invalid day {record['day']}")
|
| 299 |
+
return False
|
| 300 |
+
if not (1 <= record["hour"] <= 24):
|
| 301 |
+
st.error(f"Validation failed: Invalid hour {record['hour']}")
|
| 302 |
+
return False
|
| 303 |
+
if not (-50 <= record["dry_bulb"] <= 50):
|
| 304 |
+
st.error(f"Validation failed: Dry bulb {record['dry_bulb']} outside range")
|
| 305 |
+
return False
|
| 306 |
+
if not (0 <= record["relative_humidity"] <= 100):
|
| 307 |
+
st.error(f"Validation failed: Relative humidity {record['relative_humidity']} outside range")
|
| 308 |
+
return False
|
| 309 |
+
if not (80000 <= record["atmospheric_pressure"] <= 110000):
|
| 310 |
+
st.error(f"Validation failed: Atmospheric pressure {record['atmospheric_pressure']} outside range")
|
| 311 |
+
return False
|
| 312 |
+
if not (0 <= record["global_horizontal_radiation"] <= 1200):
|
| 313 |
+
st.error(f"Validation failed: Global radiation {record['global_horizontal_radiation']} outside range")
|
| 314 |
+
return False
|
| 315 |
+
if not (0 <= record["direct_normal_radiation"] <= 1200):
|
| 316 |
+
st.error(f"Validation failed: Direct normal radiation {record['direct_normal_radiation']} outside range")
|
| 317 |
+
return False
|
| 318 |
+
if not (0 <= record["diffuse_horizontal_radiation"] <= 1200):
|
| 319 |
+
st.error(f"Validation failed: Diffuse horizontal radiation {record['diffuse_horizontal_radiation']} outside range")
|
| 320 |
+
return False
|
| 321 |
+
if not (0 <= record["wind_speed"] <= 30):
|
| 322 |
+
st.error(f"Validation failed: Wind speed {record['wind_speed']} outside range")
|
| 323 |
+
return False
|
| 324 |
+
if not (0 <= record["wind_direction"] <= 360):
|
| 325 |
+
st.error(f"Validation failed: Wind direction {record['wind_direction']} outside range")
|
| 326 |
+
return False
|
| 327 |
+
|
| 328 |
+
# Validate typical/extreme periods (optional)
|
| 329 |
+
if "typical_extreme_periods" in data and data["typical_extreme_periods"]:
|
| 330 |
+
expected_periods = ["summer_extreme", "summer_typical", "winter_extreme", "winter_typical"]
|
| 331 |
+
missing_periods = [p for p in expected_periods if p not in data["typical_extreme_periods"]]
|
| 332 |
+
if missing_periods:
|
| 333 |
+
st.warning(f"Validation warning: Missing typical/extreme periods: {', '.join(missing_periods)}")
|
| 334 |
+
for period in data["typical_extreme_periods"].values():
|
| 335 |
+
for date in ["start", "end"]:
|
| 336 |
+
if not (1 <= period[date]["month"] <= 12 and 1 <= period[date]["day"] <= 31):
|
| 337 |
+
st.error(f"Validation failed: Invalid date in typical/extreme periods: {period[date]}")
|
| 338 |
+
return False
|
| 339 |
+
|
| 340 |
+
# Validate ground temperatures (optional)
|
| 341 |
+
if "ground_temperatures" in data and data["ground_temperatures"]:
|
| 342 |
+
for depth, temps in data["ground_temperatures"].items():
|
| 343 |
+
if len(temps) != 12 or not all(0 <= t <= 50 for t in temps):
|
| 344 |
+
st.error(f"Validation failed: Invalid ground temperatures for depth {depth}")
|
| 345 |
+
return False
|
| 346 |
+
|
| 347 |
+
# Validate solar calculations (optional)
|
| 348 |
+
if "solar_calculations" in data and data["solar_calculations"]:
|
| 349 |
+
for calc in data["solar_calculations"]:
|
| 350 |
+
if not (1 <= calc["month"] <= 12 and 1 <= calc["day"] <= 31 and 1 <= calc["hour"] <= 24):
|
| 351 |
+
st.error(f"Validation failed: Invalid date/time in solar calculations: {calc}")
|
| 352 |
+
return False
|
| 353 |
+
if not (-23.45 <= calc["declination"] <= 23.45):
|
| 354 |
+
st.error(f"Validation failed: Declination {calc['declination']} outside range")
|
| 355 |
+
return False
|
| 356 |
+
if not (0 <= calc["LST"] <= 24):
|
| 357 |
+
st.error(f"Validation failed: LST {calc['LST']} outside range")
|
| 358 |
+
return False
|
| 359 |
+
if not (-180 <= calc["HRA"] <= 180):
|
| 360 |
+
st.error(f"Validation failed: HRA {calc['HRA']} outside range")
|
| 361 |
+
return False
|
| 362 |
+
if not (0 <= calc["altitude"] <= 90):
|
| 363 |
+
st.error(f"Validation failed: Altitude {calc['altitude']} outside range")
|
| 364 |
+
return False
|
| 365 |
+
if not (0 <= calc["azimuth"] <= 360):
|
| 366 |
+
st.error(f"Validation failed: Azimuth {calc['azimuth']} outside range")
|
| 367 |
+
return False
|
| 368 |
+
if not (0 <= calc["ground_reflected"] <= 1200):
|
| 369 |
+
st.error(f"Validation failed: Ground-reflected radiation {calc['ground_reflected']} outside range")
|
| 370 |
+
return False
|
| 371 |
+
|
| 372 |
+
return True
|
| 373 |
+
|
| 374 |
+
@staticmethod
|
| 375 |
+
def calculate_wet_bulb(dry_bulb: np.ndarray, relative_humidity: np.ndarray) -> np.ndarray:
|
| 376 |
+
"""Calculate Wet Bulb Temperature using Stull (2011) approximation."""
|
| 377 |
+
db = np.array(dry_bulb, dtype=float)
|
| 378 |
+
rh = np.array(relative_humidity, dtype=float)
|
| 379 |
+
|
| 380 |
+
term1 = db * np.arctan(0.151977 * (rh + 8.313659)**0.5)
|
| 381 |
+
term2 = np.arctan(db + rh)
|
| 382 |
+
term3 = np.arctan(rh - 1.676331)
|
| 383 |
+
term4 = 0.00391838 * rh**1.5 * np.arctan(0.023101 * rh)
|
| 384 |
+
term5 = -4.686035
|
| 385 |
+
|
| 386 |
+
wet_bulb = term1 + term2 - term3 + term4 + term5
|
| 387 |
+
|
| 388 |
+
invalid_mask = (rh < 5) | (rh > 99) | (db < -20) | (db > 50) | np.isnan(db) | np.isnan(rh)
|
| 389 |
+
wet_bulb[invalid_mask] = np.nan
|
| 390 |
+
|
| 391 |
+
return wet_bulb
|
| 392 |
+
|
| 393 |
+
@staticmethod
|
| 394 |
+
def is_numeric(value: str) -> bool:
|
| 395 |
+
"""Check if a string can be converted to a number."""
|
| 396 |
+
try:
|
| 397 |
+
float(value)
|
| 398 |
+
return True
|
| 399 |
+
except ValueError:
|
| 400 |
+
return False
|
| 401 |
+
|
| 402 |
+
def display_climate_input(self, session_state: Dict[str, Any]):
|
| 403 |
+
"""Display Streamlit interface for EPW upload, visualizations, and solar analysis."""
|
| 404 |
+
st.title("Climate Data Analysis")
|
| 405 |
+
|
| 406 |
+
# Apply consistent styling
|
| 407 |
+
st.markdown(STYLE, unsafe_allow_html=True)
|
| 408 |
+
|
| 409 |
+
# Clear invalid session_state["climate_data"] without warning
|
| 410 |
+
if "climate_data" in session_state and not all(key in session_state["climate_data"] for key in ["id", "country", "city", "timezone"]):
|
| 411 |
+
del session_state["climate_data"]
|
| 412 |
+
|
| 413 |
+
uploaded_file = st.file_uploader("Upload EPW File", type=["epw"])
|
| 414 |
+
|
| 415 |
+
# Initialize location and epw_data for display
|
| 416 |
+
location = None
|
| 417 |
+
epw_data = None
|
| 418 |
+
|
| 419 |
+
if uploaded_file:
|
| 420 |
+
try:
|
| 421 |
+
# Process new EPW file
|
| 422 |
+
epw_content = uploaded_file.read().decode("utf-8")
|
| 423 |
+
epw_lines = epw_content.splitlines()
|
| 424 |
+
|
| 425 |
+
# Parse header
|
| 426 |
+
header = next(line for line in epw_lines if line.startswith("LOCATION"))
|
| 427 |
+
header_parts = header.split(",")
|
| 428 |
+
city = header_parts[1].strip() or "Unknown"
|
| 429 |
+
# Clean city name by removing suffixes like '.Racecourse'
|
| 430 |
+
city = re.sub(r'\..*', '', city)
|
| 431 |
+
state_province = header_parts[2].strip() or "Unknown"
|
| 432 |
+
country = header_parts[3].strip() or "Unknown"
|
| 433 |
+
|
| 434 |
+
latitude = float(header_parts[6])
|
| 435 |
+
longitude = float(header_parts[7])
|
| 436 |
+
elevation = float(header_parts[9])
|
| 437 |
+
timezone = float(header_parts[8]) # Time zone from EPW header
|
| 438 |
+
|
| 439 |
+
# Parse TYPICAL/EXTREME PERIODS
|
| 440 |
+
typical_extreme_periods = {}
|
| 441 |
+
date_pattern = r'^\d{1,2}\s*/\s*\d{1,2}$'
|
| 442 |
+
for line in epw_lines:
|
| 443 |
+
if line.startswith("TYPICAL/EXTREME PERIODS"):
|
| 444 |
+
parts = line.strip().split(',')
|
| 445 |
+
try:
|
| 446 |
+
num_periods = int(parts[1])
|
| 447 |
+
except ValueError:
|
| 448 |
+
st.warning("Invalid number of periods in TYPICAL/EXTREME PERIODS, skipping parsing.")
|
| 449 |
+
break
|
| 450 |
+
for i in range(num_periods):
|
| 451 |
+
try:
|
| 452 |
+
if len(parts) < 2 + i*4 + 4:
|
| 453 |
+
st.warning(f"Insufficient fields for period {i+1}, skipping.")
|
| 454 |
+
continue
|
| 455 |
+
period_name = parts[2 + i*4]
|
| 456 |
+
period_type = parts[3 + i*4]
|
| 457 |
+
start_date = parts[4 + i*4].strip()
|
| 458 |
+
end_date = parts[5 + i*4].strip()
|
| 459 |
+
if period_name in [
|
| 460 |
+
"Summer - Week Nearest Max Temperature For Period",
|
| 461 |
+
"Summer - Week Nearest Average Temperature For Period",
|
| 462 |
+
"Winter - Week Nearest Min Temperature For Period",
|
| 463 |
+
"Winter - Week Nearest Average Temperature For Period"
|
| 464 |
+
]:
|
| 465 |
+
season = 'summer' if 'Summer' in period_name else 'winter'
|
| 466 |
+
period_type = ('extreme' if 'Max' in period_name or 'Min' in period_name else 'typical')
|
| 467 |
+
key = f"{season}_{period_type}"
|
| 468 |
+
# Clean dates to remove non-standard whitespace
|
| 469 |
+
start_date_clean = re.sub(r'\s+', '', start_date)
|
| 470 |
+
end_date_clean = re.sub(r'\s+', '', end_date)
|
| 471 |
+
if not re.match(date_pattern, start_date) or not re.match(date_pattern, end_date):
|
| 472 |
+
st.warning(f"Invalid date format for period {period_name}: {start_date} to {end_date}, skipping.")
|
| 473 |
+
continue
|
| 474 |
+
start_month, start_day = map(int, start_date_clean.split('/'))
|
| 475 |
+
end_month, end_day = map(int, end_date_clean.split('/'))
|
| 476 |
+
typical_extreme_periods[key] = {
|
| 477 |
+
"start": {"month": start_month, "day": start_day},
|
| 478 |
+
"end": {"month": end_month, "day": end_day}
|
| 479 |
+
}
|
| 480 |
+
except (IndexError, ValueError) as e:
|
| 481 |
+
st.warning(f"Error parsing period {i+1}: {str(e)}, skipping.")
|
| 482 |
+
continue
|
| 483 |
+
break
|
| 484 |
+
|
| 485 |
+
# Parse GROUND TEMPERATURES
|
| 486 |
+
ground_temperatures = {}
|
| 487 |
+
for line in epw_lines:
|
| 488 |
+
if line.startswith("GROUND TEMPERATURES"):
|
| 489 |
+
parts = line.strip().split(',')
|
| 490 |
+
try:
|
| 491 |
+
num_depths = int(parts[1])
|
| 492 |
+
except ValueError:
|
| 493 |
+
st.warning("Invalid number of depths in GROUND TEMPERATURES, skipping parsing.")
|
| 494 |
+
break
|
| 495 |
+
for i in range(num_depths):
|
| 496 |
+
try:
|
| 497 |
+
if len(parts) < 2 + i*16 + 16:
|
| 498 |
+
st.warning(f"Insufficient fields for ground temperature depth {i+1}, skipping.")
|
| 499 |
+
continue
|
| 500 |
+
depth = parts[2 + i*16]
|
| 501 |
+
temps = [float(t) for t in parts[6 + i*16:18 + i*16] if t.strip()]
|
| 502 |
+
if len(temps) != 12:
|
| 503 |
+
st.warning(f"Invalid number of temperatures for depth {depth}m, expected 12, got {len(temps)}, skipping.")
|
| 504 |
+
continue
|
| 505 |
+
ground_temperatures[depth] = temps
|
| 506 |
+
except (ValueError, IndexError) as e:
|
| 507 |
+
st.warning(f"Error parsing ground temperatures for depth {i+1}: {str(e)}, skipping.")
|
| 508 |
+
continue
|
| 509 |
+
break
|
| 510 |
+
|
| 511 |
+
# Read data section
|
| 512 |
+
data_start_idx = next(i for i, line in enumerate(epw_lines) if line.startswith("DATA PERIODS")) + 1
|
| 513 |
+
epw_data = pd.read_csv(StringIO("\n".join(epw_lines[data_start_idx:])), header=None, dtype=str)
|
| 514 |
+
|
| 515 |
+
if len(epw_data) != 8760:
|
| 516 |
+
raise ValueError(f"EPW file has {len(epw_data)} records, expected 8760.")
|
| 517 |
+
if len(epw_data.columns) != 35:
|
| 518 |
+
raise ValueError(f"EPW file has {len(epw_data.columns)} columns, expected 35.")
|
| 519 |
+
|
| 520 |
+
for col in [1, 2, 3, 6, 8, 9, 13, 14, 15, 20, 21]:
|
| 521 |
+
epw_data[col] = pd.to_numeric(epw_data[col], errors='coerce')
|
| 522 |
+
if epw_data[col].isna().all():
|
| 523 |
+
raise ValueError(f"Column {col} contains only non-numeric or missing data.")
|
| 524 |
+
|
| 525 |
+
# Create ClimateLocation
|
| 526 |
+
location = ClimateLocation(
|
| 527 |
+
epw_file=epw_data,
|
| 528 |
+
typical_extreme_periods=typical_extreme_periods,
|
| 529 |
+
ground_temperatures=ground_temperatures,
|
| 530 |
+
id=f"{country[:1].upper()}{city[:3].upper()}",
|
| 531 |
+
country=country,
|
| 532 |
+
state_province=state_province,
|
| 533 |
+
city=city,
|
| 534 |
+
latitude=latitude,
|
| 535 |
+
longitude=longitude,
|
| 536 |
+
elevation=elevation,
|
| 537 |
+
timezone=timezone
|
| 538 |
+
)
|
| 539 |
+
self.add_location(location)
|
| 540 |
+
climate_data_dict = location.to_dict()
|
| 541 |
+
if not self.validate_climate_data(climate_data_dict):
|
| 542 |
+
raise ValueError("Invalid climate data extracted from EPW file.")
|
| 543 |
+
session_state["climate_data"] = climate_data_dict
|
| 544 |
+
st.success("Climate data extracted from EPW file!")
|
| 545 |
+
|
| 546 |
+
except Exception as e:
|
| 547 |
+
st.error(f"Error processing EPW file: {str(e)}. Ensure it has 8760 hourly records and correct format.")
|
| 548 |
+
|
| 549 |
+
elif "climate_data" in session_state and self.validate_climate_data(session_state["climate_data"]):
|
| 550 |
+
# Reconstruct from session_state
|
| 551 |
+
climate_data_dict = session_state["climate_data"]
|
| 552 |
+
|
| 553 |
+
# Rebuild epw_data from hourly_data
|
| 554 |
+
hourly_data = climate_data_dict["hourly_data"]
|
| 555 |
+
epw_data = pd.DataFrame({
|
| 556 |
+
1: [d["month"] for d in hourly_data], # Month
|
| 557 |
+
2: [d["day"] for d in hourly_data], # Day
|
| 558 |
+
3: [d["hour"] for d in hourly_data], # Hour
|
| 559 |
+
6: [d["dry_bulb"] for d in hourly_data], # Dry-bulb temperature
|
| 560 |
+
8: [d["relative_humidity"] for d in hourly_data], # Relative humidity
|
| 561 |
+
9: [d["atmospheric_pressure"] for d in hourly_data], # Pressure
|
| 562 |
+
13: [d["global_horizontal_radiation"] for d in hourly_data], # Global horizontal radiation
|
| 563 |
+
14: [d["direct_normal_radiation"] for d in hourly_data], # Direct normal radiation
|
| 564 |
+
15: [d["diffuse_horizontal_radiation"] for d in hourly_data], # Diffuse horizontal radiation
|
| 565 |
+
20: [d["wind_direction"] for d in hourly_data], # Wind direction
|
| 566 |
+
21: [d["wind_speed"] for d in hourly_data], # Wind speed
|
| 567 |
+
})
|
| 568 |
+
|
| 569 |
+
# Create ClimateLocation with reconstructed epw_data
|
| 570 |
+
location = ClimateLocation(
|
| 571 |
+
epw_file=epw_data,
|
| 572 |
+
typical_extreme_periods=climate_data_dict["typical_extreme_periods"],
|
| 573 |
+
ground_temperatures=climate_data_dict["ground_temperatures"],
|
| 574 |
+
id=climate_data_dict["id"],
|
| 575 |
+
country=climate_data_dict["country"],
|
| 576 |
+
state_province=climate_data_dict["state_province"],
|
| 577 |
+
city=climate_data_dict["city"],
|
| 578 |
+
latitude=climate_data_dict["latitude"],
|
| 579 |
+
longitude=climate_data_dict["longitude"],
|
| 580 |
+
elevation=climate_data_dict["elevation"],
|
| 581 |
+
timezone=climate_data_dict["timezone"],
|
| 582 |
+
solar_calculations=climate_data_dict.get("solar_calculations", [])
|
| 583 |
+
)
|
| 584 |
+
# Override hourly_data to ensure consistency
|
| 585 |
+
location.hourly_data = climate_data_dict["hourly_data"]
|
| 586 |
+
self.add_location(location)
|
| 587 |
+
st.info("Displaying previously extracted climate data.")
|
| 588 |
+
|
| 589 |
+
# Display tabs if location and epw_data are available
|
| 590 |
+
if location and epw_data is not None:
|
| 591 |
+
tab1, tab2 = st.tabs(["General Information", "Solar Analysis"])
|
| 592 |
+
|
| 593 |
+
with tab1:
|
| 594 |
+
self.display_design_conditions(location)
|
| 595 |
+
|
| 596 |
+
with tab2:
|
| 597 |
+
self.display_solar_analysis(location, session_state)
|
| 598 |
+
|
| 599 |
+
else:
|
| 600 |
+
st.info("No climate data available. Please upload an EPW file to proceed.")
|
| 601 |
+
|
| 602 |
+
def display_solar_analysis(self, location: ClimateLocation, session_state: Dict[str, Any]):
|
| 603 |
+
"""Display solar analysis tab with input fields and calculation results."""
|
| 604 |
+
st.subheader("Solar Analysis")
|
| 605 |
+
|
| 606 |
+
# Input fields with help text
|
| 607 |
+
col1, col2 = st.columns(2)
|
| 608 |
+
with col1:
|
| 609 |
+
ground_reflectivity = st.number_input(
|
| 610 |
+
"Ground Reflectivity (ρg)",
|
| 611 |
+
min_value=0.0,
|
| 612 |
+
max_value=1.0,
|
| 613 |
+
value=0.2,
|
| 614 |
+
step=0.01,
|
| 615 |
+
help="Enter the albedo of the ground surface (0 to 1). Common values: 0.2 (grass), 0.3 (concrete), 0.8 (snow). Default: 0.2."
|
| 616 |
+
)
|
| 617 |
+
with col2:
|
| 618 |
+
surface_tilt = st.number_input(
|
| 619 |
+
"Surface Tilt (β, degrees)",
|
| 620 |
+
min_value=0.0,
|
| 621 |
+
max_value=180.0,
|
| 622 |
+
value=0.0,
|
| 623 |
+
step=1.0,
|
| 624 |
+
help="Enter the tilt angle of the surface in degrees (0° for horizontal, 90° for vertical, up to 180° for downward-facing). Default: 0°."
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
# Calculate button
|
| 628 |
+
if st.button("Calculate Solar Parameters"):
|
| 629 |
+
try:
|
| 630 |
+
solar_results = SolarCalculations.calculate_solar_parameters(
|
| 631 |
+
hourly_data=location.hourly_data,
|
| 632 |
+
latitude=location.latitude,
|
| 633 |
+
longitude=location.longitude,
|
| 634 |
+
timezone=session_state["climate_data"].get("timezone", 0),
|
| 635 |
+
ground_reflectivity=ground_reflectivity,
|
| 636 |
+
surface_tilt=surface_tilt
|
| 637 |
+
)
|
| 638 |
+
session_state["climate_data"]["solar_calculations"] = solar_results
|
| 639 |
+
location.solar_calculations = solar_results
|
| 640 |
+
st.success("Solar calculations completed!")
|
| 641 |
+
except Exception as e:
|
| 642 |
+
st.error(f"Error in solar calculations: {str(e)}")
|
| 643 |
+
|
| 644 |
+
# Display results table
|
| 645 |
+
if "solar_calculations" in session_state["climate_data"] and session_state["climate_data"]["solar_calculations"]:
|
| 646 |
+
st.markdown('<div class="markdown-text"><h3>Solar Analysis Results</h3></div>', unsafe_allow_html=True)
|
| 647 |
+
table_data = []
|
| 648 |
+
solar_data = {f"{r['month']}-{r['day']}-{r['hour']}": r for r in session_state["climate_data"]["solar_calculations"]}
|
| 649 |
+
|
| 650 |
+
for record in location.hourly_data:
|
| 651 |
+
key = f"{record['month']}-{record['day']}-{record['hour']}"
|
| 652 |
+
row = {
|
| 653 |
+
"Month": record["month"],
|
| 654 |
+
"Day": record["day"],
|
| 655 |
+
"Hour": record["hour"],
|
| 656 |
+
"Dry Bulb Temperature (°C)": f"{record['dry_bulb']:.1f}",
|
| 657 |
+
"Relative Humidity (%)": f"{record['relative_humidity']:.1f}",
|
| 658 |
+
"Wind Speed (m/s)": f"{record['wind_speed']:.1f}",
|
| 659 |
+
"Wind Direction (°)": f"{record['wind_direction']:.1f}",
|
| 660 |
+
"Global Horizontal Radiation (W/m²)": f"{record['global_horizontal_radiation']:.1f}",
|
| 661 |
+
"Direct Normal Radiation (W/m²)": f"{record['direct_normal_radiation']:.1f}",
|
| 662 |
+
"Diffuse Horizontal Radiation (W/m²)": f"{record['diffuse_horizontal_radiation']:.1f}",
|
| 663 |
+
"Declination (°)": "",
|
| 664 |
+
"Local Solar Time (h)": "",
|
| 665 |
+
"Hour Angle (°)": "",
|
| 666 |
+
"Solar Altitude (°)": "",
|
| 667 |
+
"Solar Azimuth (°)": "",
|
| 668 |
+
"Ground-Reflected Radiation (W/m²)": ""
|
| 669 |
+
}
|
| 670 |
+
if key in solar_data:
|
| 671 |
+
solar = solar_data[key]
|
| 672 |
+
row.update({
|
| 673 |
+
"Declination (°)": f"{solar['declination']:.2f}",
|
| 674 |
+
"Local Solar Time (h)": f"{solar['LST']:.2f}",
|
| 675 |
+
"Hour Angle (°)": f"{solar['HRA']:.2f}",
|
| 676 |
+
"Solar Altitude (°)": f"{solar['altitude']:.2f}",
|
| 677 |
+
"Solar Azimuth (°)": f"{solar['azimuth']:.2f}",
|
| 678 |
+
"Ground-Reflected Radiation (W/m²)": f"{solar['ground_reflected']:.2f}"
|
| 679 |
+
})
|
| 680 |
+
table_data.append(row)
|
| 681 |
+
|
| 682 |
+
df = pd.DataFrame(table_data)
|
| 683 |
+
st.dataframe(df, use_container_width=True)
|
| 684 |
+
else:
|
| 685 |
+
st.info("No solar calculation results available. Click 'Calculate Solar Parameters' to generate results.")
|
| 686 |
+
|
| 687 |
+
def display_design_conditions(self, location: ClimateLocation):
|
| 688 |
+
"""Display design conditions for HVAC calculations using styled HTML."""
|
| 689 |
+
st.subheader("Design Conditions")
|
| 690 |
+
|
| 691 |
+
col1, col2 = st.columns(2)
|
| 692 |
+
|
| 693 |
+
# Location Details (First Column)
|
| 694 |
+
with col1:
|
| 695 |
+
st.markdown(f"""
|
| 696 |
+
<div class="column">
|
| 697 |
+
<div class="markdown-text">
|
| 698 |
+
<h3>Location Details</h3>
|
| 699 |
+
<ul>
|
| 700 |
+
<li><strong>Country:</strong> {location.country}</li>
|
| 701 |
+
<li><strong>City:</strong> {location.city}</li>
|
| 702 |
+
<li><strong>State/Province:</strong> {location.state_province}</li>
|
| 703 |
+
<li><strong>Latitude:</strong> {location.latitude}°</li>
|
| 704 |
+
<li><strong>Longitude:</strong> {location.longitude}°</li>
|
| 705 |
+
<li><strong>Elevation:</strong> {location.elevation} m</li>
|
| 706 |
+
<li><strong>Timezone:</strong> {location.timezone:+.1f} hours</li>
|
| 707 |
+
</ul>
|
| 708 |
+
</div>
|
| 709 |
+
</div>
|
| 710 |
+
""", unsafe_allow_html=True)
|
| 711 |
+
|
| 712 |
+
# Typical/Extreme Periods (Second Column)
|
| 713 |
+
with col2:
|
| 714 |
+
if location.typical_extreme_periods:
|
| 715 |
+
period_items = [
|
| 716 |
+
f"<li><strong>{key.replace('_', ' ').title()}:</strong> {period['start']['month']}/{period['start']['day']} to {period['end']['month']}/{period['end']['day']}</li>"
|
| 717 |
+
for key, period in location.typical_extreme_periods.items()
|
| 718 |
+
]
|
| 719 |
+
period_content = f"""
|
| 720 |
+
<div class="markdown-text">
|
| 721 |
+
<h3>Typical/Extreme Periods</h3>
|
| 722 |
+
<ul>
|
| 723 |
+
{''.join(period_items)}
|
| 724 |
+
</ul>
|
| 725 |
+
</div>
|
| 726 |
+
"""
|
| 727 |
+
else:
|
| 728 |
+
period_content = """
|
| 729 |
+
<div class="markdown-text">
|
| 730 |
+
<h3>Typical/Extreme Periods</h3>
|
| 731 |
+
<p>No typical/extreme period data available.</p>
|
| 732 |
+
</div>
|
| 733 |
+
"""
|
| 734 |
+
st.markdown(period_content, unsafe_allow_html=True)
|
| 735 |
+
|
| 736 |
+
# Calculated Climate Parameters
|
| 737 |
+
st.markdown(f"""
|
| 738 |
+
<div class="markdown-text">
|
| 739 |
+
<h3>Calculated Climate Parameters</h3>
|
| 740 |
+
<ul>
|
| 741 |
+
<li><strong>Climate Zone:</strong> {location.climate_zone}</li>
|
| 742 |
+
<li><strong>Heating Degree Days (base 18°C):</strong> {location.heating_degree_days} HDD</li>
|
| 743 |
+
<li><strong>Cooling Degree Days (base 18°C):</strong> {location.cooling_degree_days} CDD</li>
|
| 744 |
+
<li><strong>Winter Design Temperature (99.6%):</strong> {location.winter_design_temp} °C</li>
|
| 745 |
+
<li><strong>Summer Design Dry-Bulb Temp (0.4%):</strong> {location.summer_design_temp_db} °C</li>
|
| 746 |
+
<li><strong>Summer Design Wet-Bulb Temp (0.4%):</strong> {location.summer_design_temp_wb} °C</li>
|
| 747 |
+
<li><strong>Summer Daily Temperature Range:</strong> {location.summer_daily_range} °C</li>
|
| 748 |
+
<li><strong>Mean Wind Speed:</strong> {location.wind_speed} m/s</li>
|
| 749 |
+
<li><strong>Mean Atmospheric Pressure:</strong> {location.pressure} Pa</li>
|
| 750 |
+
</ul>
|
| 751 |
+
</div>
|
| 752 |
+
""", unsafe_allow_html=True)
|
| 753 |
+
|
| 754 |
+
# Ground Temperatures (Table)
|
| 755 |
+
if location.ground_temperatures:
|
| 756 |
+
st.markdown('<div class="markdown-text"><h3>Ground Temperatures</h3></div>', unsafe_allow_html=True)
|
| 757 |
+
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
| 758 |
+
table_data = []
|
| 759 |
+
for depth, temps in location.ground_temperatures.items():
|
| 760 |
+
row = {"Depth (m)": float(depth)}
|
| 761 |
+
row.update({month: f"{temp:.2f}" for month, temp in zip(month_names, temps)})
|
| 762 |
+
table_data.append(row)
|
| 763 |
+
df = pd.DataFrame(table_data)
|
| 764 |
+
st.dataframe(df, use_container_width=True)
|
| 765 |
+
|
| 766 |
+
# Hourly Climate Data (Table)
|
| 767 |
+
if location.hourly_data:
|
| 768 |
+
st.markdown('<div class="markdown-text"><h3>Hourly Climate Data</h3></div>', unsafe_allow_html=True)
|
| 769 |
+
hourly_table_data = []
|
| 770 |
+
for record in location.hourly_data:
|
| 771 |
+
row = {
|
| 772 |
+
"Month": record["month"],
|
| 773 |
+
"Day": record["day"],
|
| 774 |
+
"Hour": record["hour"],
|
| 775 |
+
"Dry Bulb Temperature (°C)": f"{record['dry_bulb']:.1f}",
|
| 776 |
+
"Relative Humidity (%)": f"{record['relative_humidity']:.1f}",
|
| 777 |
+
"Atmospheric Pressure (Pa)": f"{record['atmospheric_pressure']:.1f}",
|
| 778 |
+
"Global Horizontal Radiation (W/m²)": f"{record['global_horizontal_radiation']:.1f}",
|
| 779 |
+
"Direct Normal Radiation (W/m²)": f"{record['direct_normal_radiation']:.1f}",
|
| 780 |
+
"Diffuse Horizontal Radiation (W/m²)": f"{record['diffuse_horizontal_radiation']:.1f}",
|
| 781 |
+
"Wind Speed (m/s)": f"{record['wind_speed']:.1f}",
|
| 782 |
+
"Wind Direction (°)": f"{record['wind_direction']:.1f}"
|
| 783 |
+
}
|
| 784 |
+
hourly_table_data.append(row)
|
| 785 |
+
hourly_df = pd.DataFrame(hourly_table_data)
|
| 786 |
+
st.dataframe(hourly_df, use_container_width=True)
|
| 787 |
+
|
| 788 |
+
@staticmethod
|
| 789 |
+
def assign_climate_zone(hdd: float, cdd: float, avg_humidity: float) -> str:
|
| 790 |
+
"""Assign ASHRAE 169 climate zone based on HDD, CDD, and humidity."""
|
| 791 |
+
if cdd > 10000:
|
| 792 |
+
return "0A" if avg_humidity > 60 else "0B"
|
| 793 |
+
elif cdd > 5000:
|
| 794 |
+
return "1A" if avg_humidity > 60 else "1B"
|
| 795 |
+
elif cdd > 2500:
|
| 796 |
+
return "2A" if avg_humidity > 60 else "2B"
|
| 797 |
+
elif hdd < 2000 and cdd > 1000:
|
| 798 |
+
return "3A" if avg_humidity > 60 else "3B" if avg_humidity < 40 else "3C"
|
| 799 |
+
elif hdd < 3000:
|
| 800 |
+
return "4A" if avg_humidity > 60 else "4B" if avg_humidity < 40 else "4C"
|
| 801 |
+
elif hdd < 4000:
|
| 802 |
+
return "5A" if avg_humidity > 60 else "5B" if avg_humidity < 40 else "5C"
|
| 803 |
+
elif hdd < 5000:
|
| 804 |
+
return "6A" if avg_humidity > 60 else "6B"
|
| 805 |
+
elif hdd < 7000:
|
| 806 |
+
return "7"
|
| 807 |
+
else:
|
| 808 |
+
return "8"
|
| 809 |
+
|
| 810 |
+
def export_to_json(self, file_path: str) -> None:
|
| 811 |
+
"""Export all climate data to a JSON file."""
|
| 812 |
+
data = {loc_id: loc.to_dict() for loc_id, loc in self.locations.items()}
|
| 813 |
+
with open(file_path, 'w') as f:
|
| 814 |
+
json.dump(data, f, indent=4)
|
| 815 |
+
|
| 816 |
+
@classmethod
|
| 817 |
+
def from_json(cls, file_path: str) -> 'ClimateData':
|
| 818 |
+
"""Load climate data from a JSON file."""
|
| 819 |
+
with open(file_path, 'r') as f:
|
| 820 |
+
data = json.load(f)
|
| 821 |
+
climate_data = cls()
|
| 822 |
+
for loc_id, loc_dict in data.items():
|
| 823 |
+
# Rebuild epw_data from hourly_data
|
| 824 |
+
hourly_data = loc_dict["hourly_data"]
|
| 825 |
+
epw_data = pd.DataFrame({
|
| 826 |
+
1: [d["month"] for d in hourly_data],
|
| 827 |
+
2: [d["day"] for d in hourly_data],
|
| 828 |
+
3: [d["hour"] for d in hourly_data],
|
| 829 |
+
6: [d["dry_bulb"] for d in hourly_data],
|
| 830 |
+
8: [d["relative_humidity"] for d in hourly_data],
|
| 831 |
+
9: [d["atmospheric_pressure"] for d in hourly_data],
|
| 832 |
+
13: [d["global_horizontal_radiation"] for d in hourly_data],
|
| 833 |
+
14: [d["direct_normal_radiation"] for d in hourly_data],
|
| 834 |
+
15: [d["diffuse_horizontal_radiation"] for d in hourly_data],
|
| 835 |
+
20: [d["wind_direction"] for d in hourly_data],
|
| 836 |
+
21: [d["wind_speed"] for d in hourly_data],
|
| 837 |
+
})
|
| 838 |
+
location = ClimateLocation(
|
| 839 |
+
epw_file=epw_data,
|
| 840 |
+
typical_extreme_periods=loc_dict["typical_extreme_periods"],
|
| 841 |
+
ground_temperatures=loc_dict["ground_temperatures"],
|
| 842 |
+
id=loc_dict["id"],
|
| 843 |
+
country=loc_dict["country"],
|
| 844 |
+
state_province=loc_dict["state_province"],
|
| 845 |
+
city=loc_dict["city"],
|
| 846 |
+
latitude=loc_dict["latitude"],
|
| 847 |
+
longitude=loc_dict["longitude"],
|
| 848 |
+
elevation=loc_dict["elevation"],
|
| 849 |
+
timezone=loc_dict["timezone"],
|
| 850 |
+
solar_calculations=loc_dict.get("solar_calculations", [])
|
| 851 |
+
)
|
| 852 |
+
location.hourly_data = loc_dict["hourly_data"] # Ensure consistency
|
| 853 |
+
climate_data.add_location(location)
|
| 854 |
+
return climate_data
|
| 855 |
+
|
| 856 |
+
if __name__ == "__main__":
|
| 857 |
+
climate_data = ClimateData()
|
| 858 |
+
session_state = {"building_info": {"country": "Australia", "city": "Geelong"}, "page": "Climate Data"}
|
| 859 |
+
climate_data.display_climate_input(session_state)
|
data/internal_loads.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# internal_loads.py
|
| 2 |
+
|
| 3 |
+
# People Activity Levels
|
| 4 |
+
# Each activity level includes metabolic rate (met), metabolic rate in W/person,
|
| 5 |
+
# and ranges for sensible and latent heat gains in W.
|
| 6 |
+
PEOPLE_ACTIVITY_LEVELS = {
|
| 7 |
+
"Seated, at Rest (Quiet, Reading, Writing)": {
|
| 8 |
+
"metabolic_rate_met": 1.0,
|
| 9 |
+
"metabolic_rate_w": 110,
|
| 10 |
+
"sensible_min_w": 20,
|
| 11 |
+
"sensible_max_w": 24,
|
| 12 |
+
"latent_min_w": 9,
|
| 13 |
+
"latent_max_w": 12
|
| 14 |
+
},
|
| 15 |
+
"Seated, Light Office Work (Typing, Filing)": {
|
| 16 |
+
"metabolic_rate_met": 1.1,
|
| 17 |
+
"metabolic_rate_w": 125,
|
| 18 |
+
"sensible_min_w": 24,
|
| 19 |
+
"sensible_max_w": 27,
|
| 20 |
+
"latent_min_w": 12,
|
| 21 |
+
"latent_max_w": 15
|
| 22 |
+
},
|
| 23 |
+
"Standing, Light Work (Filing, Walking Slowly)": {
|
| 24 |
+
"metabolic_rate_met": 1.35,
|
| 25 |
+
"metabolic_rate_w": 155,
|
| 26 |
+
"sensible_min_w": 30,
|
| 27 |
+
"sensible_max_w": 35,
|
| 28 |
+
"latent_min_w": 18,
|
| 29 |
+
"latent_max_w": 24
|
| 30 |
+
},
|
| 31 |
+
"Walking, Moderate Pace (2–3 mph / 3.2–4.8 km/h)": {
|
| 32 |
+
"metabolic_rate_met": 2.0,
|
| 33 |
+
"metabolic_rate_w": 210,
|
| 34 |
+
"sensible_min_w": 41,
|
| 35 |
+
"sensible_max_w": 47,
|
| 36 |
+
"latent_min_w": 30,
|
| 37 |
+
"latent_max_w": 35
|
| 38 |
+
},
|
| 39 |
+
"Light Machine Work (Assembly, Small Tools)": {
|
| 40 |
+
"metabolic_rate_met": 2.35,
|
| 41 |
+
"metabolic_rate_w": 250,
|
| 42 |
+
"sensible_min_w": 47,
|
| 43 |
+
"sensible_max_w": 56,
|
| 44 |
+
"latent_min_w": 35,
|
| 45 |
+
"latent_max_w": 44
|
| 46 |
+
},
|
| 47 |
+
"Moderate Work (Walking with Loads, Lifting)": {
|
| 48 |
+
"metabolic_rate_met": 3.0,
|
| 49 |
+
"metabolic_rate_w": 310,
|
| 50 |
+
"sensible_min_w": 59,
|
| 51 |
+
"sensible_max_w": 68,
|
| 52 |
+
"latent_min_w": 50,
|
| 53 |
+
"latent_max_w": 59
|
| 54 |
+
},
|
| 55 |
+
"Heavy Work (Carrying Heavy Loads, Shoveling)": {
|
| 56 |
+
"metabolic_rate_met": 4.0,
|
| 57 |
+
"metabolic_rate_w": 425,
|
| 58 |
+
"sensible_min_w": 73,
|
| 59 |
+
"sensible_max_w": 88,
|
| 60 |
+
"latent_min_w": 73,
|
| 61 |
+
"latent_max_w": 88
|
| 62 |
+
},
|
| 63 |
+
"Dancing (Moderate to Vigorous)": {
|
| 64 |
+
"metabolic_rate_met": 3.5,
|
| 65 |
+
"metabolic_rate_w": 400,
|
| 66 |
+
"sensible_min_w": 59,
|
| 67 |
+
"sensible_max_w": 88,
|
| 68 |
+
"latent_min_w": 59,
|
| 69 |
+
"latent_max_w": 73
|
| 70 |
+
},
|
| 71 |
+
"Athletics/Exercise (Vigorous)": {
|
| 72 |
+
"metabolic_rate_met": 6.0,
|
| 73 |
+
"metabolic_rate_w": 600,
|
| 74 |
+
"sensible_min_w": 88,
|
| 75 |
+
"sensible_max_w": 117,
|
| 76 |
+
"latent_min_w": 88,
|
| 77 |
+
"latent_max_w": 117
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# Diversity Factors by Building Type
|
| 82 |
+
DIVERSITY_FACTORS = {
|
| 83 |
+
"Office": 0.80,
|
| 84 |
+
"Classroom": 1.00,
|
| 85 |
+
"Retail": 0.80,
|
| 86 |
+
"Restaurant Dining": 1.00,
|
| 87 |
+
"Restaurant Kitchen": 1.00,
|
| 88 |
+
"Hotel Guest Room": 0.50,
|
| 89 |
+
"Hotel Lobby": 0.75,
|
| 90 |
+
"Hospital Patient Room": 0.60,
|
| 91 |
+
"Hospital Operating Room": 1.00,
|
| 92 |
+
"Library": 0.75,
|
| 93 |
+
"Museum": 0.75,
|
| 94 |
+
"Courthouse": 1.00,
|
| 95 |
+
"Gymnasium": 1.00,
|
| 96 |
+
"Warehouse": 0.50,
|
| 97 |
+
"Residential Single-Family": 0.50,
|
| 98 |
+
"Residential Multi-Family": 0.60,
|
| 99 |
+
"Auditorium": 1.00,
|
| 100 |
+
"Place of Worship": 1.00,
|
| 101 |
+
"Laboratory": 0.80,
|
| 102 |
+
"Data Center": 0.10,
|
| 103 |
+
"Manufacturing Facility": 0.75,
|
| 104 |
+
"School Cafeteria": 1.00,
|
| 105 |
+
"Dormitory": 0.60,
|
| 106 |
+
"Conference Room": 1.00,
|
| 107 |
+
"Bank": 0.80,
|
| 108 |
+
"Post Office": 0.75,
|
| 109 |
+
"Supermarket": 0.70
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
# Lighting Power Density (LPD) by Building Type (W/m²)
|
| 113 |
+
LPD_VALUES = {
|
| 114 |
+
"Office": 8.82,
|
| 115 |
+
"Classroom": 13.34,
|
| 116 |
+
"Retail": 13.56,
|
| 117 |
+
"Restaurant Dining": 9.58,
|
| 118 |
+
"Restaurant Kitchen": 11.42,
|
| 119 |
+
"Hotel Guest Room": 9.47,
|
| 120 |
+
"Hotel Lobby": 13.57,
|
| 121 |
+
"Hospital Patient Room": 11.30,
|
| 122 |
+
"Hospital Operating Room": 17.11,
|
| 123 |
+
"Library": 12.70,
|
| 124 |
+
"Museum": 11.41,
|
| 125 |
+
"Courthouse": 11.30,
|
| 126 |
+
"Gymnasium": 10.76,
|
| 127 |
+
"Warehouse": 4.84,
|
| 128 |
+
"Residential Single-Family": 5.38,
|
| 129 |
+
"Residential Multi-Family": 6.46,
|
| 130 |
+
"Auditorium": 14.96,
|
| 131 |
+
"Place of Worship": 11.30,
|
| 132 |
+
"Laboratory": 15.61,
|
| 133 |
+
"Data Center": 8.61,
|
| 134 |
+
"Manufacturing Facility": 11.95,
|
| 135 |
+
"School Cafeteria": 9.69,
|
| 136 |
+
"Dormitory": 6.57,
|
| 137 |
+
"Conference Room": 13.24,
|
| 138 |
+
"Bank": 10.76,
|
| 139 |
+
"Post Office": 9.37,
|
| 140 |
+
"Supermarket": 13.35
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
# Lighting Fixture Types and Heat Gain Splits
|
| 144 |
+
LIGHTING_FIXTURE_TYPES = {
|
| 145 |
+
"Incandescent": {"radiative": 80, "convective": 20},
|
| 146 |
+
"Fluorescent": {"radiative": 60, "convective": 40},
|
| 147 |
+
"Compact Fluorescent (CFL)": {"radiative": 60, "convective": 40},
|
| 148 |
+
"LED (Light Emitting Diode)": {"radiative": 50, "convective": 50},
|
| 149 |
+
"High-Intensity Discharge (HID)": {"radiative": 70, "convective": 30},
|
| 150 |
+
"Halogen": {"radiative": 80, "convective": 20}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
# Equipment Heat Gains by Building Type
|
| 154 |
+
# Includes sensible, latent, convective, and radiant splits (all in %)
|
| 155 |
+
EQUIPMENT_HEAT_GAINS = {
|
| 156 |
+
"Office": {"sensible": 75, "latent": 25, "convective": 50, "radiant": 50},
|
| 157 |
+
"Classroom": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 158 |
+
"Retail": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 159 |
+
"Restaurant Dining": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
|
| 160 |
+
"Restaurant Kitchen": {"sensible": 50, "latent": 50, "convective": 70, "radiant": 30},
|
| 161 |
+
"Hotel Guest Room": {"sensible": 65, "latent": 35, "convective": 60, "radiant": 40},
|
| 162 |
+
"Hotel Lobby": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 163 |
+
"Hospital Patient Room": {"sensible": 65, "latent": 35, "convective": 60, "radiant": 40},
|
| 164 |
+
"Hospital Operating Room": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 165 |
+
"Library": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 166 |
+
"Museum": {"sensible": 75, "latent": 25, "convective": 40, "radiant": 60},
|
| 167 |
+
"Courthouse": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 168 |
+
"Gymnasium": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
|
| 169 |
+
"Warehouse": {"sensible": 80, "latent": 20, "convective": 70, "radiant": 30},
|
| 170 |
+
"Residential Single-Family": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
|
| 171 |
+
"Residential Multi-Family": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
|
| 172 |
+
"Auditorium": {"sensible": 65, "latent": 35, "convective": 50, "radiant": 50},
|
| 173 |
+
"Place of Worship": {"sensible": 65, "latent": 35, "convective": 50, "radiant": 50},
|
| 174 |
+
"Laboratory": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 175 |
+
"Data Center": {"sensible": 90, "latent": 10, "convective": 80, "radiant": 20},
|
| 176 |
+
"Manufacturing Facility": {"sensible": 75, "latent": 25, "convective": 60, "radiant": 40},
|
| 177 |
+
"School Cafeteria": {"sensible": 65, "latent": 35, "convective": 60, "radiant": 40},
|
| 178 |
+
"Dormitory": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
|
| 179 |
+
"Conference Room": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 180 |
+
"Bank": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 181 |
+
"Post Office": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
|
| 182 |
+
"Supermarket": {"sensible": 75, "latent": 25, "convective": 60, "radiant": 40}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
# Ventilation Rates by Building Type
|
| 186 |
+
# Includes people_rate (L/s/person) and area_rate (L/s/m²)
|
| 187 |
+
VENTILATION_RATES = {
|
| 188 |
+
"Office": {"people_rate": 2.5, "area_rate": 0.3},
|
| 189 |
+
"Classroom": {"people_rate": 5.0, "area_rate": 0.9},
|
| 190 |
+
"Retail": {"people_rate": 3.8, "area_rate": 0.9},
|
| 191 |
+
"Restaurant Dining": {"people_rate": 5.0, "area_rate": 1.8},
|
| 192 |
+
"Restaurant Kitchen": {"people_rate": 3.8, "area_rate": 1.0},
|
| 193 |
+
"Hotel Guest Room": {"people_rate": 2.5, "area_rate": 0.3},
|
| 194 |
+
"Hotel Lobby": {"people_rate": 3.8, "area_rate": 0.3},
|
| 195 |
+
"Hospital Patient Room": {"people_rate": 5.0, "area_rate": 0.6},
|
| 196 |
+
"Hospital Operating Room": {"people_rate": 10.0, "area_rate": 0.6},
|
| 197 |
+
"Library": {"people_rate": 2.5, "area_rate": 0.6},
|
| 198 |
+
"Museum": {"people_rate": 3.8, "area_rate": 0.6},
|
| 199 |
+
"Courthouse": {"people_rate": 2.5, "area_rate": 0.3},
|
| 200 |
+
"Gymnasium": {"people_rate": 10.0, "area_rate": 0.9},
|
| 201 |
+
"Warehouse": {"people_rate": 5.0, "area_rate": 0.3},
|
| 202 |
+
"Residential Single-Family": {"people_rate": 2.5, "area_rate": 0.3},
|
| 203 |
+
"Residential Multi-Family": {"people_rate": 2.5, "area_rate": 0.3},
|
| 204 |
+
"Auditorium": {"people_rate": 2.5, "area_rate": 0.3},
|
| 205 |
+
"Place of Worship": {"people_rate": 2.5, "area_rate": 0.3},
|
| 206 |
+
"Laboratory": {"people_rate": 5.0, "area_rate": 0.9},
|
| 207 |
+
"Data Center": {"people_rate": 5.0, "area_rate": 0.6},
|
| 208 |
+
"Manufacturing Facility": {"people_rate": 5.0, "area_rate": 0.9},
|
| 209 |
+
"School Cafeteria": {"people_rate": 5.0, "area_rate": 0.9},
|
| 210 |
+
"Dormitory": {"people_rate": 2.5, "area_rate": 0.3},
|
| 211 |
+
"Conference Room": {"people_rate": 2.5, "area_rate": 0.3},
|
| 212 |
+
"Bank": {"people_rate": 2.5, "area_rate": 0.3},
|
| 213 |
+
"Post Office": {"people_rate": 2.5, "area_rate": 0.3},
|
| 214 |
+
"Supermarket": {"people_rate": 3.8, "area_rate": 0.6},
|
| 215 |
+
"Custom": {"people_rate": 0.0, "area_rate": 0.0}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
BUILDING_TYPES = list(VENTILATION_RATES.keys())
|
| 219 |
+
|
| 220 |
+
# Infiltration Settings by Building Type and Method
|
| 221 |
+
# Methods: ACH, Crack Flow, Empirical Equations
|
| 222 |
+
# Each method has specific parameters for each building type
|
| 223 |
+
INFILTRATION_SETTINGS = {
|
| 224 |
+
"ACH": {
|
| 225 |
+
"Office": {"rate": 0.5},
|
| 226 |
+
"Classroom": {"rate": 0.3},
|
| 227 |
+
"Retail": {"rate": 0.4},
|
| 228 |
+
"Restaurant Dining": {"rate": 0.6},
|
| 229 |
+
"Restaurant Kitchen": {"rate": 0.8},
|
| 230 |
+
"Hotel Guest Room": {"rate": 0.2},
|
| 231 |
+
"Hotel Lobby": {"rate": 0.5},
|
| 232 |
+
"Hospital Patient Room": {"rate": 0.1},
|
| 233 |
+
"Hospital Operating Room": {"rate": 0.05},
|
| 234 |
+
"Library": {"rate": 0.3},
|
| 235 |
+
"Museum": {"rate": 0.2},
|
| 236 |
+
"Courthouse": {"rate": 0.4},
|
| 237 |
+
"Gymnasium": {"rate": 0.6},
|
| 238 |
+
"Warehouse": {"rate": 1.0},
|
| 239 |
+
"Residential Single-Family": {"rate": 0.5},
|
| 240 |
+
"Residential Multi-Family": {"rate": 0.4},
|
| 241 |
+
"Auditorium": {"rate": 0.3},
|
| 242 |
+
"Place of Worship": {"rate": 0.4},
|
| 243 |
+
"Laboratory": {"rate": 0.2},
|
| 244 |
+
"Data Center": {"rate": 0.1},
|
| 245 |
+
"Manufacturing Facility": {"rate": 0.7},
|
| 246 |
+
"School Cafeteria": {"rate": 0.5},
|
| 247 |
+
"Dormitory": {"rate": 0.4},
|
| 248 |
+
"Conference Room": {"rate": 0.3},
|
| 249 |
+
"Bank": {"rate": 0.4},
|
| 250 |
+
"Post Office": {"rate": 0.5},
|
| 251 |
+
"Supermarket": {"rate": 0.6}
|
| 252 |
+
},
|
| 253 |
+
"Crack Flow": {
|
| 254 |
+
"Office": {"ela": 0.0001}, # Effective Leakage Area (m²/m² of wall)
|
| 255 |
+
"Classroom": {"ela": 0.00008},
|
| 256 |
+
# Additional building types can be added as needed
|
| 257 |
+
},
|
| 258 |
+
"Empirical Equations": {
|
| 259 |
+
"Office": {"c": 0.1, "n": 0.65}, # Example coefficients for Sherman-Grimsrud model
|
| 260 |
+
"Classroom": {"c": 0.08, "n": 0.65},
|
| 261 |
+
# Additional building types can be added as needed
|
| 262 |
+
}
|
| 263 |
+
}
|
data/material_library.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Material Library for HVAC Load Calculator
|
| 3 |
+
Updated 2025-05-17: Removed original materials and constructions, deleted ASHRAE materials starting with 'R' followed by number (e.g., R01, R25), verified constructions use valid materials.
|
| 4 |
+
Updated 2025-05-17: Added all materials and constructions from ASHRAE_2005_HOF_Materials.idf with Australian-specific embodied carbon and prices.
|
| 5 |
+
Updated 2025-05-17: Added GlazingMaterial and DoorMaterial classes, included library_glazing_materials and library_door_materials with comprehensive data.
|
| 6 |
+
Updated 2025-05-16: Removed mass_per_meter, added thermal_mass method, updated CSV handling.
|
| 7 |
+
Updated 2025-05-16: Fixed U-value calculation in Material.get_u_value.
|
| 8 |
+
Updated 2025-05-16: Updated MaterialCategory to Finishing Materials, Structural Materials, Sub-Structural Materials, Insulation.
|
| 9 |
+
Updated 2025-05-16: Fixed Construction.get_thermal_mass to use avg_specific_heat.
|
| 10 |
+
|
| 11 |
+
Developed by: Dr Majed Abuseif, Deakin University
|
| 12 |
+
© 2025
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from typing import Dict, List, Optional, Tuple
|
| 16 |
+
from enum import Enum
|
| 17 |
+
import pandas as pd
|
| 18 |
+
|
| 19 |
+
class MaterialCategory(Enum):
|
| 20 |
+
FINISHING_MATERIALS = "Finishing Materials"
|
| 21 |
+
STRUCTURAL_MATERIALS = "Structural Materials"
|
| 22 |
+
SUB_STRUCTURAL_MATERIALS = "Sub-Structural Materials"
|
| 23 |
+
INSULATION = "Insulation"
|
| 24 |
+
|
| 25 |
+
class ThermalMass(Enum):
|
| 26 |
+
HIGH = "High"
|
| 27 |
+
MEDIUM = "Medium"
|
| 28 |
+
LOW = "Low"
|
| 29 |
+
NO_MASS = "No Mass"
|
| 30 |
+
|
| 31 |
+
class Material:
|
| 32 |
+
def __init__(self, name: str, category: MaterialCategory, conductivity: float, density: float,
|
| 33 |
+
specific_heat: float, default_thickness: float, embodied_carbon: float,
|
| 34 |
+
solar_absorption: float, price: float, is_library: bool = True):
|
| 35 |
+
self.name = name
|
| 36 |
+
self.category = category
|
| 37 |
+
self.conductivity = max(0.01, conductivity) # W/m·K
|
| 38 |
+
self.density = max(1.0, density) # kg/m³
|
| 39 |
+
self.specific_heat = max(100.0, specific_heat) # J/kg·K
|
| 40 |
+
self.default_thickness = max(0.01, default_thickness) # m
|
| 41 |
+
self.embodied_carbon = max(0.0, embodied_carbon) # kgCO₂e/kg
|
| 42 |
+
self.solar_absorption = min(max(0.0, solar_absorption), 1.0)
|
| 43 |
+
self.price = max(0.0, price) # USD/m²
|
| 44 |
+
self.is_library = is_library
|
| 45 |
+
|
| 46 |
+
def get_thermal_mass(self) -> ThermalMass:
|
| 47 |
+
if self.density < 100.0 or self.specific_heat < 800.0:
|
| 48 |
+
return ThermalMass.NO_MASS
|
| 49 |
+
elif self.density > 2000.0 and self.specific_heat > 800.0:
|
| 50 |
+
return ThermalMass.HIGH
|
| 51 |
+
elif 1000.0 <= self.density <= 2000.0 and 800.0 <= self.specific_heat <= 1200.0:
|
| 52 |
+
return ThermalMass.MEDIUM
|
| 53 |
+
else:
|
| 54 |
+
return ThermalMass.LOW
|
| 55 |
+
|
| 56 |
+
def get_u_value(self) -> float:
|
| 57 |
+
return self.conductivity / self.default_thickness if self.default_thickness > 0 else 0.1
|
| 58 |
+
|
| 59 |
+
class GlazingMaterial:
|
| 60 |
+
def __init__(self, name: str, u_value: float, shgc: float, embodied_carbon: float, price: float, is_library: bool = True):
|
| 61 |
+
self.name = name
|
| 62 |
+
self.u_value = max(0.1, u_value) # W/m²·K
|
| 63 |
+
self.shgc = min(max(0.0, shgc), 1.0) # Solar Heat Gain Coefficient
|
| 64 |
+
self.embodied_carbon = max(0.0, embodied_carbon) # kgCO₂e/m²
|
| 65 |
+
self.price = max(0.0, price) # USD/m²
|
| 66 |
+
self.is_library = is_library
|
| 67 |
+
|
| 68 |
+
class DoorMaterial:
|
| 69 |
+
def __init__(self, name: str, u_value: float, solar_absorption: float, embodied_carbon: float, price: float, is_library: bool = True):
|
| 70 |
+
self.name = name
|
| 71 |
+
self.u_value = max(0.1, u_value) # W/m²·K
|
| 72 |
+
self.solar_absorption = min(max(0.0, solar_absorption), 1.0)
|
| 73 |
+
self.embodied_carbon = max(0.0, embodied_carbon) # kgCO₂e/m²
|
| 74 |
+
self.price = max(0.0, price) # USD/m²
|
| 75 |
+
self.is_library = is_library
|
| 76 |
+
|
| 77 |
+
class Construction:
|
| 78 |
+
def __init__(self, name: str, component_type: str, layers: List[Dict], is_library: bool = True):
|
| 79 |
+
self.name = name
|
| 80 |
+
self.component_type = component_type
|
| 81 |
+
self.layers = layers or []
|
| 82 |
+
self.is_library = is_library
|
| 83 |
+
self.u_value = self.calculate_u_value()
|
| 84 |
+
self.total_thickness = sum(layer["thickness"] for layer in self.layers)
|
| 85 |
+
self.embodied_carbon = sum(layer["material"].embodied_carbon * layer["material"].density * layer["thickness"]
|
| 86 |
+
for layer in self.layers)
|
| 87 |
+
self.solar_absorption = max(layer["material"].solar_absorption for layer in self.layers) if self.layers else 0.6
|
| 88 |
+
self.price = sum(layer["material"].price * layer["thickness"] / layer["material"].default_thickness
|
| 89 |
+
for layer in self.layers)
|
| 90 |
+
|
| 91 |
+
def calculate_u_value(self) -> float:
|
| 92 |
+
if not self.layers:
|
| 93 |
+
return 0.1
|
| 94 |
+
r_total = sum(layer["thickness"] / layer["material"].conductivity for layer in self.layers)
|
| 95 |
+
return 1 / r_total if r_total > 0 else 0.1
|
| 96 |
+
|
| 97 |
+
def get_thermal_mass(self) -> ThermalMass:
|
| 98 |
+
if not self.layers:
|
| 99 |
+
return ThermalMass.NO_MASS
|
| 100 |
+
total_thickness = self.total_thickness
|
| 101 |
+
if total_thickness == 0:
|
| 102 |
+
return ThermalMass.NO_MASS
|
| 103 |
+
avg_density = sum(layer["material"].density * layer["thickness"] for layer in self.layers) / total_thickness
|
| 104 |
+
avg_specific_heat = sum(layer["material"].specific_heat * layer["thickness"] for layer in self.layers) / total_thickness
|
| 105 |
+
if avg_density < 100.0 or avg_specific_heat < 800.0:
|
| 106 |
+
return ThermalMass.NO_MASS
|
| 107 |
+
elif avg_density > 2000.0 and avg_specific_heat > 800.0:
|
| 108 |
+
return ThermalMass.HIGH
|
| 109 |
+
elif 1000.0 <= avg_density <= 2000.0 and 800.0 <= avg_specific_heat <= 1200.0:
|
| 110 |
+
return ThermalMass.MEDIUM
|
| 111 |
+
else:
|
| 112 |
+
return ThermalMass.LOW
|
| 113 |
+
|
| 114 |
+
class MaterialLibrary:
|
| 115 |
+
def __init__(self):
|
| 116 |
+
self.library_materials = self.initialize_materials()
|
| 117 |
+
self.library_constructions = self.initialize_constructions()
|
| 118 |
+
self.library_glazing_materials = self.initialize_glazing_materials()
|
| 119 |
+
self.library_door_materials = self.initialize_door_materials()
|
| 120 |
+
|
| 121 |
+
def initialize_materials(self) -> Dict[str, Material]:
|
| 122 |
+
materials = [
|
| 123 |
+
# ASHRAE 2005 HOF Materials (excluding 'R'-prefixed materials)
|
| 124 |
+
Material("F04 Wall air space resistance", MaterialCategory.INSULATION, 0.01, 1.0, 1000.0, 0.01, 0.01, 0.5, 0.5),
|
| 125 |
+
Material("F05 Ceiling air space resistance", MaterialCategory.INSULATION, 0.01, 1.0, 1000.0, 0.01, 0.01, 0.5, 0.5),
|
| 126 |
+
Material("F06 EIFS finish", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0095, 0.3, 0.5, 17.6),
|
| 127 |
+
Material("F07 25mm stucco", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0254, 0.2, 0.6, 14.1),
|
| 128 |
+
Material("F08 Metal surface", MaterialCategory.SUB_STRUCTURAL_MATERIALS, 45.28, 7824.0, 500.0, 0.001, 2.2, 0.7, 25.0),
|
| 129 |
+
Material("F09 25mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0254, 0.2, 0.6, 14.1),
|
| 130 |
+
Material("F10 13mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0127, 0.25, 0.4, 5.1),
|
| 131 |
+
Material("F11 16mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0159, 0.25, 0.4, 6.4),
|
| 132 |
+
Material("F12 19mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0191, 0.25, 0.4, 7.6),
|
| 133 |
+
Material("F13 13mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0127, 0.2, 0.6, 7.1),
|
| 134 |
+
Material("F14 13mm lime plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1600.0, 840.0, 0.0127, 0.2, 0.5, 6.1),
|
| 135 |
+
Material("F15 22mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0222, 0.2, 0.6, 12.4),
|
| 136 |
+
Material("F16 Acoustic tile", MaterialCategory.FINISHING_MATERIALS, 0.06, 368.0, 590.0, 0.0191, 1.0, 0.4, 14.0),
|
| 137 |
+
Material("F17 13mm slag", MaterialCategory.FINISHING_MATERIALS, 0.16, 960.0, 1090.0, 0.0127, 0.2, 0.5, 1.0),
|
| 138 |
+
Material("F18 25mm slag", MaterialCategory.FINISHING_MATERIALS, 0.16, 960.0, 1090.0, 0.0254, 0.2, 0.5, 1.9),
|
| 139 |
+
Material("G01 13mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0127, 0.25, 0.4, 5.1),
|
| 140 |
+
Material("G01a 19mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0191, 0.25, 0.4, 7.6),
|
| 141 |
+
Material("G02 25mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0254, 0.2, 0.6, 14.1),
|
| 142 |
+
Material("G03 13mm lime plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1600.0, 840.0, 0.0127, 0.25, 0.5, 6.1),
|
| 143 |
+
Material("G04 13mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0127, 0.2, 0.6, 7.1),
|
| 144 |
+
Material("G05 25mm wood", MaterialCategory.SUB_STRUCTURAL_MATERIALS, 0.15, 608.0, 1630.0, 0.0254, 0.3, 0.5, 15.4),
|
| 145 |
+
Material("G06 19mm wood", MaterialCategory.SUB_STRUCTURAL_MATERIALS, 0.15, 608.0, 1630.0, 0.0191, 0.3, 0.5, 11.6),
|
| 146 |
+
Material("I01 25mm insulation board", MaterialCategory.INSULATION, 0.03, 43.0, 1210.0, 0.0254, 2.5, 0.5, 1.1),
|
| 147 |
+
Material("I02 50mm insulation board", MaterialCategory.INSULATION, 0.03, 43.0, 1210.0, 0.0508, 2.5, 0.5, 2.2),
|
| 148 |
+
Material("I03 75mm insulation board", MaterialCategory.INSULATION, 0.03, 43.0, 1210.0, 0.0762, 2.5, 0.5, 3.3),
|
| 149 |
+
Material("M01 100mm brick", MaterialCategory.STRUCTURAL_MATERIALS, 0.89, 1920.0, 790.0, 0.1016, 0.3, 0.7, 19.5),
|
| 150 |
+
Material("M02 100mm face brick", MaterialCategory.STRUCTURAL_MATERIALS, 1.33, 2000.0, 790.0, 0.1016, 0.3, 0.7, 20.3),
|
| 151 |
+
Material("M03 150mm brick", MaterialCategory.STRUCTURAL_MATERIALS, 0.89, 1920.0, 790.0, 0.1524, 0.3, 0.7, 29.3),
|
| 152 |
+
Material("M04 200mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 0.51, 800.0, 920.0, 0.2032, 0.2, 0.65, 13.0),
|
| 153 |
+
Material("M05 200mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 1.11, 1280.0, 920.0, 0.2032, 0.2, 0.65, 20.8),
|
| 154 |
+
Material("M06 150mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 0.51, 800.0, 920.0, 0.1524, 0.2, 0.65, 9.8),
|
| 155 |
+
Material("M07 100mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 0.51, 800.0, 920.0, 0.1016, 0.2, 0.65, 6.5),
|
| 156 |
+
Material("M08 150mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 1.11, 1280.0, 920.0, 0.1524, 0.2, 0.65, 15.6),
|
| 157 |
+
Material("M09 100mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 1.11, 1280.0, 920.0, 0.1016, 0.2, 0.65, 10.4),
|
| 158 |
+
Material("M10 100mm lightweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 0.53, 1280.0, 840.0, 0.1016, 0.15, 0.65, 7.8),
|
| 159 |
+
Material("M11 100mm lightweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 0.53, 1280.0, 840.0, 0.1016, 0.15, 0.65, 7.8),
|
| 160 |
+
Material("M12 150mm lightweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 0.53, 1280.0, 840.0, 0.1524, 0.15, 0.65, 11.7),
|
| 161 |
+
Material("M13 200mm lightweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 0.53, 1280.0, 840.0, 0.2032, 0.15, 0.65, 15.6),
|
| 162 |
+
Material("M14 100mm heavyweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 1.95, 2240.0, 900.0, 0.1016, 0.2, 0.65, 18.2),
|
| 163 |
+
Material("M14a 100mm heavyweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 1.95, 2240.0, 900.0, 0.1016, 0.2, 0.65, 18.2),
|
| 164 |
+
Material("M15 200mm heavyweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 1.95, 2240.0, 900.0, 0.2032, 0.2, 0.65, 36.4),
|
| 165 |
+
Material("M16 300mm heavyweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 1.95, 2240.0, 900.0, 0.3048, 0.2, 0.65, 54.6),
|
| 166 |
+
Material("M17 100mm stone", MaterialCategory.STRUCTURAL_MATERIALS, 2.10, 2240.0, 880.0, 0.1016, 0.2, 0.7, 22.8),
|
| 167 |
+
Material("M18 150mm stone", MaterialCategory.STRUCTURAL_MATERIALS, 2.10, 2240.0, 880.0, 0.1524, 0.2, 0.7, 34.1),
|
| 168 |
+
Material("M19 100mm limestone", MaterialCategory.STRUCTURAL_MATERIALS, 1.80, 2320.0, 880.0, 0.1016, 0.2, 0.6, 23.6),
|
| 169 |
+
Material("M20 150mm limestone", MaterialCategory.STRUCTURAL_MATERIALS, 1.80, 2320.0, 880.0, 0.1524, 0.2, 0.6, 35.4),
|
| 170 |
+
Material("M21 200mm limestone", MaterialCategory.STRUCTURAL_MATERIALS, 1.80, 2320.0, 880.0, 0.2032, 0.2, 0.6, 47.1),
|
| 171 |
+
Material("M22 100mm granite", MaterialCategory.STRUCTURAL_MATERIALS, 2.80, 2640.0, 880.0, 0.1016, 0.2, 0.7, 26.8),
|
| 172 |
+
Material("M23 150mm granite", MaterialCategory.STRUCTURAL_MATERIALS, 2.80, 2640.0, 880.0, 0.1524, 0.2, 0.7, 40.2),
|
| 173 |
+
Material("M24 200mm granite", MaterialCategory.STRUCTURAL_MATERIALS, 2.80, 2640.0, 880.0, 0.2032, 0.2, 0.7, 53.6),
|
| 174 |
+
Material("M25 100mm marble", MaterialCategory.STRUCTURAL_MATERIALS, 2.50, 2720.0, 880.0, 0.1016, 0.2, 0.6, 27.6),
|
| 175 |
+
Material("M26 150mm marble", MaterialCategory.STRUCTURAL_MATERIALS, 2.50, 2720.0, 880.0, 0.1524, 0.2, 0.6, 41.4),
|
| 176 |
+
Material("M27 200mm marble", MaterialCategory.STRUCTURAL_MATERIALS, 2.50, 2720.0, 880.0, 0.2032, 0.2, 0.6, 55.3),
|
| 177 |
+
]
|
| 178 |
+
return {mat.name: mat for mat in materials}
|
| 179 |
+
|
| 180 |
+
def initialize_glazing_materials(self) -> Dict[str, GlazingMaterial]:
|
| 181 |
+
glazing_materials = [
|
| 182 |
+
# ASHRAE-based glazing materials with Australian pricing
|
| 183 |
+
GlazingMaterial("Single Clear 3mm", 5.8, 0.81, 25.0, 50.0), # High U-value, high SHGC
|
| 184 |
+
GlazingMaterial("Single Clear 6mm", 5.7, 0.78, 28.0, 60.0), # Slightly lower SHGC
|
| 185 |
+
GlazingMaterial("Single Tinted 6mm", 5.7, 0.55, 30.0, 70.0), # Reduced SHGC for solar control
|
| 186 |
+
GlazingMaterial("Double Clear 6mm/13mm Air", 2.7, 0.70, 40.0, 100.0), # Improved insulation
|
| 187 |
+
GlazingMaterial("Double Low-E 6mm/13mm Air", 1.8, 0.60, 45.0, 120.0), # Low-E coating
|
| 188 |
+
GlazingMaterial("Double Tinted 6mm/13mm Air", 2.7, 0.45, 42.0, 110.0), # Tinted for solar control
|
| 189 |
+
GlazingMaterial("Double Low-E 6mm/13mm Argon", 1.5, 0.55, 48.0, 130.0), # Argon-filled, better U-value
|
| 190 |
+
GlazingMaterial("Triple Clear 4mm/12mm Air", 1.8, 0.62, 55.0, 150.0), # Triple glazing
|
| 191 |
+
GlazingMaterial("Triple Low-E 4mm/12mm Argon", 0.9, 0.50, 60.0, 180.0), # High-performance
|
| 192 |
+
GlazingMaterial("Single Low-E Reflective 6mm", 5.6, 0.35, 35.0, 90.0), # Reflective coating
|
| 193 |
+
GlazingMaterial("Double Reflective 6mm/13mm Air", 2.5, 0.30, 50.0, 140.0), # Low SHGC
|
| 194 |
+
GlazingMaterial("Electrochromic 6mm/13mm Air", 2.0, 0.40, 70.0, 200.0), # Dynamic glazing
|
| 195 |
+
]
|
| 196 |
+
return {mat.name: mat for mat in glazing_materials}
|
| 197 |
+
|
| 198 |
+
def initialize_door_materials(self) -> Dict[str, DoorMaterial]:
|
| 199 |
+
door_materials = [
|
| 200 |
+
# Door materials with ASHRAE-based properties and Australian pricing
|
| 201 |
+
DoorMaterial("Solid Wood 45mm", 2.5, 0.50, 15.0, 200.0), # Standard wooden door
|
| 202 |
+
DoorMaterial("Insulated Wood 50mm", 1.8, 0.45, 18.0, 250.0), # Better insulation
|
| 203 |
+
DoorMaterial("Hollow Core Wood 40mm", 3.5, 0.50, 12.0, 150.0), # Less insulation
|
| 204 |
+
DoorMaterial("Steel Uninsulated 45mm", 5.0, 0.70, 20.0, 180.0), # High U-value
|
| 205 |
+
DoorMaterial("Steel Insulated 50mm", 2.0, 0.65, 25.0, 220.0), # Foam-insulated
|
| 206 |
+
DoorMaterial("Aluminum Uninsulated 45mm", 6.0, 0.75, 22.0, 200.0), # Poor insulation
|
| 207 |
+
DoorMaterial("Aluminum Insulated 50mm", 2.5, 0.70, 28.0, 240.0), # Thermal break
|
| 208 |
+
DoorMaterial("Glass Single 6mm", 5.7, 0.78, 28.0, 100.0), # Same as single glazing
|
| 209 |
+
DoorMaterial("Glass Double 6mm/13mm Air", 2.7, 0.70, 40.0, 150.0), # Double-glazed door
|
| 210 |
+
DoorMaterial("Fiberglass Insulated 50mm", 1.5, 0.60, 20.0, 230.0), # High performance
|
| 211 |
+
DoorMaterial("PVC Insulated 50mm", 1.7, 0.55, 18.0, 210.0), # Durable, insulated
|
| 212 |
+
DoorMaterial("Wood with Glass Insert", 3.0, 0.65, 16.0, 190.0), # Mixed properties
|
| 213 |
+
]
|
| 214 |
+
return {mat.name: mat for mat in door_materials}
|
| 215 |
+
|
| 216 |
+
def initialize_constructions(self) -> Dict[str, Construction]:
|
| 217 |
+
constructions = [
|
| 218 |
+
Construction("Light Exterior Wall", "Wall", [
|
| 219 |
+
{"material": self.library_materials["F08 Metal surface"], "thickness": 0.001},
|
| 220 |
+
{"material": self.library_materials["I02 50mm insulation board"], "thickness": 0.0508},
|
| 221 |
+
{"material": self.library_materials["F04 Wall air space resistance"], "thickness": 0.01},
|
| 222 |
+
{"material": self.library_materials["G01a 19mm gypsum board"], "thickness": 0.0191}
|
| 223 |
+
]),
|
| 224 |
+
Construction("Light Roof/Ceiling", "Roof", [
|
| 225 |
+
{"material": self.library_materials["M11 100mm lightweight concrete"], "thickness": 0.1016},
|
| 226 |
+
{"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
|
| 227 |
+
{"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191}
|
| 228 |
+
]),
|
| 229 |
+
Construction("Light Floor", "Floor", [
|
| 230 |
+
{"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191},
|
| 231 |
+
{"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
|
| 232 |
+
{"material": self.library_materials["M11 100mm lightweight concrete"], "thickness": 0.1016}
|
| 233 |
+
]),
|
| 234 |
+
Construction("Medium Exterior Wall", "Wall", [
|
| 235 |
+
{"material": self.library_materials["M01 100mm brick"], "thickness": 0.1016},
|
| 236 |
+
{"material": self.library_materials["I02 50mm insulation board"], "thickness": 0.0508},
|
| 237 |
+
{"material": self.library_materials["F04 Wall air space resistance"], "thickness": 0.01},
|
| 238 |
+
{"material": self.library_materials["G01a 19mm gypsum board"], "thickness": 0.0191}
|
| 239 |
+
]),
|
| 240 |
+
Construction("Medium Roof/Ceiling", "Roof", [
|
| 241 |
+
{"material": self.library_materials["M14a 100mm heavyweight concrete"], "thickness": 0.1016},
|
| 242 |
+
{"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
|
| 243 |
+
{"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191}
|
| 244 |
+
]),
|
| 245 |
+
Construction("Medium Floor", "Floor", [
|
| 246 |
+
{"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191},
|
| 247 |
+
{"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
|
| 248 |
+
{"material": self.library_materials["M14a 100mm heavyweight concrete"], "thickness": 0.1016}
|
| 249 |
+
]),
|
| 250 |
+
Construction("Heavy Exterior Wall", "Wall", [
|
| 251 |
+
{"material": self.library_materials["M01 100mm brick"], "thickness": 0.1016},
|
| 252 |
+
{"material": self.library_materials["M15 200mm heavyweight concrete"], "thickness": 0.2032},
|
| 253 |
+
{"material": self.library_materials["I02 50mm insulation board"], "thickness": 0.0508},
|
| 254 |
+
{"material": self.library_materials["F04 Wall air space resistance"], "thickness": 0.01},
|
| 255 |
+
{"material": self.library_materials["G01a 19mm gypsum board"], "thickness": 0.0191}
|
| 256 |
+
]),
|
| 257 |
+
Construction("Heavy Roof/Ceiling", "Roof", [
|
| 258 |
+
{"material": self.library_materials["M15 200mm heavyweight concrete"], "thickness": 0.2032},
|
| 259 |
+
{"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
|
| 260 |
+
{"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191}
|
| 261 |
+
]),
|
| 262 |
+
Construction("Heavy Floor", "Floor", [
|
| 263 |
+
{"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191},
|
| 264 |
+
{"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
|
| 265 |
+
{"material": self.library_materials["M15 200mm heavyweight concrete"], "thickness": 0.2032}
|
| 266 |
+
]),
|
| 267 |
+
]
|
| 268 |
+
return {cons.name: cons for cons in constructions}
|
| 269 |
+
|
| 270 |
+
def get_all_materials(self, project_materials: Optional[Dict[str, Material]] = None) -> List[Material]:
|
| 271 |
+
materials = list(self.library_materials.values())
|
| 272 |
+
if project_materials:
|
| 273 |
+
materials.extend(list(project_materials.values()))
|
| 274 |
+
return materials
|
| 275 |
+
|
| 276 |
+
def get_all_glazing_materials(self, project_glazing_materials: Optional[Dict[str, GlazingMaterial]] = None) -> List[GlazingMaterial]:
|
| 277 |
+
materials = list(self.library_glazing_materials.values())
|
| 278 |
+
if project_glazing_materials:
|
| 279 |
+
materials.extend(list(project_glazing_materials.values()))
|
| 280 |
+
return materials
|
| 281 |
+
|
| 282 |
+
def get_all_door_materials(self, project_door_materials: Optional[Dict[str, DoorMaterial]] = None) -> List[DoorMaterial]:
|
| 283 |
+
materials = list(self.library_door_materials.values())
|
| 284 |
+
if project_door_materials:
|
| 285 |
+
materials.extend(list(project_door_materials.values()))
|
| 286 |
+
return materials
|
| 287 |
+
|
| 288 |
+
def add_project_material(self, material: Material, project_materials: Dict[str, Material]) -> Tuple[bool, str]:
|
| 289 |
+
if len(project_materials) >= 20:
|
| 290 |
+
return False, "Maximum 20 project materials allowed."
|
| 291 |
+
if material.name in project_materials or material.name in self.library_materials:
|
| 292 |
+
return False, f"Material name '{material.name}' already exists."
|
| 293 |
+
project_materials[material.name] = material
|
| 294 |
+
return True, f"Material '{material.name}' added to project materials."
|
| 295 |
+
|
| 296 |
+
def add_project_glazing_material(self, material: GlazingMaterial, project_glazing_materials: Dict[str, GlazingMaterial]) -> Tuple[bool, str]:
|
| 297 |
+
if len(project_glazing_materials) >= 20:
|
| 298 |
+
return False, "Maximum 20 project glazing materials allowed."
|
| 299 |
+
if material.name in project_glazing_materials or material.name in self.library_glazing_materials:
|
| 300 |
+
return False, f"Glazing material name '{material.name}' already exists."
|
| 301 |
+
project_glazing_materials[material.name] = material
|
| 302 |
+
return True, f"Glazing material '{material.name}' added to project glazing materials."
|
| 303 |
+
|
| 304 |
+
def add_project_door_material(self, material: DoorMaterial, project_door_materials: Dict[str, DoorMaterial]) -> Tuple[bool, str]:
|
| 305 |
+
if len(project_door_materials) >= 20:
|
| 306 |
+
return False, "Maximum 20 project door materials allowed."
|
| 307 |
+
if material.name in project_door_materials or material.name in self.library_door_materials:
|
| 308 |
+
return False, f"Door material name '{material.name}' already exists."
|
| 309 |
+
project_door_materials[material.name] = material
|
| 310 |
+
return True, f"Door material '{material.name}' added to project door materials."
|
| 311 |
+
|
| 312 |
+
def edit_project_material(self, old_name: str, new_material: Material, project_materials: Dict[str, Material],
|
| 313 |
+
components: Dict[str, List]) -> Tuple[bool, str]:
|
| 314 |
+
if old_name not in project_materials:
|
| 315 |
+
return False, f"Material '{old_name}' not found in project materials."
|
| 316 |
+
if new_material.name != old_name and (new_material.name in project_materials or new_material.name in self.library_materials):
|
| 317 |
+
return False, f"Material name '{new_material.name}' already exists."
|
| 318 |
+
for comp_list in components.values():
|
| 319 |
+
for comp in comp_list:
|
| 320 |
+
if comp.construction and any(layer["material"].name == old_name for layer in comp.layers):
|
| 321 |
+
comp.layers = [{"material": new_material if layer["material"].name == old_name else layer["material"],
|
| 322 |
+
"thickness": layer["thickness"]} for layer in comp.layers]
|
| 323 |
+
comp.construction = Construction(
|
| 324 |
+
name=comp.construction.name,
|
| 325 |
+
component_type=comp.construction.component_type,
|
| 326 |
+
layers=comp.layers,
|
| 327 |
+
is_library=comp.construction.is_library
|
| 328 |
+
)
|
| 329 |
+
project_materials.pop(old_name)
|
| 330 |
+
project_materials[new_material.name] = new_material
|
| 331 |
+
return True, f"Material '{old_name}' updated to '{new_material.name}'."
|
| 332 |
+
|
| 333 |
+
def edit_project_glazing_material(self, old_name: str, new_material: GlazingMaterial,
|
| 334 |
+
project_glazing_materials: Dict[str, GlazingMaterial],
|
| 335 |
+
components: Dict[str, List]) -> Tuple[bool, str]:
|
| 336 |
+
if old_name not in project_glazing_materials:
|
| 337 |
+
return False, f"Glazing material '{old_name}' not found in project glazing materials."
|
| 338 |
+
if new_material.name != old_name and (new_material.name in project_glazing_materials or new_material.name in self.library_glazing_materials):
|
| 339 |
+
return False, f"Glazing material name '{new_material.name}' already exists."
|
| 340 |
+
for comp_list in components.values():
|
| 341 |
+
for comp in comp_list:
|
| 342 |
+
if comp.glazing_material and comp.glazing_material.name == old_name:
|
| 343 |
+
comp.glazing_material = new_material
|
| 344 |
+
comp.u_value = new_material.u_value
|
| 345 |
+
comp.shgc = new_material.shgc
|
| 346 |
+
project_glazing_materials.pop(old_name)
|
| 347 |
+
project_glazing_materials[new_material.name] = new_material
|
| 348 |
+
return True, f"Glazing material '{old_name}' updated to '{new_material.name}'."
|
| 349 |
+
|
| 350 |
+
def edit_project_door_material(self, old_name: str, new_material: DoorMaterial,
|
| 351 |
+
project_door_materials: Dict[str, DoorMaterial],
|
| 352 |
+
components: Dict[str, List]) -> Tuple[bool, str]:
|
| 353 |
+
if old_name not in project_door_materials:
|
| 354 |
+
return False, f"Door material '{old_name}' not found in project door materials."
|
| 355 |
+
if new_material.name != old_name and (new_material.name in project_door_materials or new_material.name in self.library_door_materials):
|
| 356 |
+
return False, f"Door material name '{new_material.name}' already exists."
|
| 357 |
+
for comp_list in components.values():
|
| 358 |
+
for comp in comp_list:
|
| 359 |
+
if comp.door_material and comp.door_material.name == old_name:
|
| 360 |
+
comp.door_material = new_material
|
| 361 |
+
comp.u_value = new_material.u_value
|
| 362 |
+
comp.solar_absorptivity = new_material.solar_absorption
|
| 363 |
+
project_door_materials.pop(old_name)
|
| 364 |
+
project_door_materials[new_material.name] = new_material
|
| 365 |
+
return True, f"Door material '{old_name}' updated to '{new_material.name}'."
|
| 366 |
+
|
| 367 |
+
def delete_project_material(self, name: str, project_materials: Dict[str, Material], components: Dict[str, List]) -> Tuple[bool, str]:
|
| 368 |
+
if name not in project_materials:
|
| 369 |
+
return False, f"Material '{name}' not found in project materials."
|
| 370 |
+
for cons in self.library_constructions.values():
|
| 371 |
+
if any(layer["material"].name == name for layer in cons.layers):
|
| 372 |
+
return False, f"Cannot delete '{name}' as it is used in library construction '{cons.name}'."
|
| 373 |
+
for comp_type, comp_list in components.items():
|
| 374 |
+
for comp in comp_list:
|
| 375 |
+
if 'layers' in comp and any(layer["material"].name == name for layer in comp["layers"]):
|
| 376 |
+
return False, f"Cannot delete '{name}' as it is used in component '{comp['name']}' ({comp_type})."
|
| 377 |
+
del project_materials[name]
|
| 378 |
+
return True, f"Material '{name}' deleted successfully."
|
| 379 |
+
|
| 380 |
+
def delete_project_glazing_material(self, name: str, project_glazing_materials: Dict[str, GlazingMaterial],
|
| 381 |
+
components: Dict[str, List]) -> Tuple[bool, str]:
|
| 382 |
+
if name not in project_glazing_materials:
|
| 383 |
+
return False, f"Glazing material '{name}' not found in project glazing materials."
|
| 384 |
+
for comp_list in components.values():
|
| 385 |
+
for comp in comp_list:
|
| 386 |
+
if comp.glazing_material and comp.glazing_material.name == name:
|
| 387 |
+
return False, f"Cannot delete '{name}' as it is used in component '{comp.name}'."
|
| 388 |
+
del project_glazing_materials[name]
|
| 389 |
+
return True, f"Glazing material '{name}' deleted successfully."
|
| 390 |
+
|
| 391 |
+
def delete_project_door_material(self, name: str, project_door_materials: Dict[str, DoorMaterial],
|
| 392 |
+
components: Dict[str, List]) -> Tuple[bool, str]:
|
| 393 |
+
if name not in project_door_materials:
|
| 394 |
+
return False, f"Door material '{name}' not found in project door materials."
|
| 395 |
+
for comp_list in components.values():
|
| 396 |
+
for comp in comp_list:
|
| 397 |
+
if comp.door_material and comp.door_material.name == name:
|
| 398 |
+
return False, f"Cannot delete '{name}' as it is used in component '{comp.name}'."
|
| 399 |
+
del project_door_materials[name]
|
| 400 |
+
return True, f"Door material '{name}' deleted successfully."
|
| 401 |
+
|
| 402 |
+
def add_project_construction(self, construction: Construction, project_constructions: Dict[str, Construction]) -> Tuple[bool, str]:
|
| 403 |
+
if len(project_constructions) >= 20:
|
| 404 |
+
return False, "Maximum 20 project constructions allowed."
|
| 405 |
+
if construction.name in project_constructions or construction.name in self.library_constructions:
|
| 406 |
+
return False, f"Construction name '{construction.name}' already exists."
|
| 407 |
+
project_constructions[construction.name] = construction
|
| 408 |
+
return True, f"Construction '{construction.name}' added to project constructions."
|
| 409 |
+
|
| 410 |
+
def edit_project_construction(self, old_name: str, new_construction: Construction,
|
| 411 |
+
project_constructions: Dict[str, Construction],
|
| 412 |
+
components: Dict[str, List]) -> Tuple[bool, str]:
|
| 413 |
+
if old_name not in project_constructions:
|
| 414 |
+
return False, f"Construction '{old_name}' not found in project constructions."
|
| 415 |
+
if new_construction.name != old_name and (new_construction.name in project_constructions or new_construction.name in self.library_constructions):
|
| 416 |
+
return False, f"Construction name '{new_construction.name}' already exists."
|
| 417 |
+
for comp_list in components.values():
|
| 418 |
+
for comp in comp_list:
|
| 419 |
+
if comp.construction and comp.construction.name == old_name:
|
| 420 |
+
comp.construction = new_construction
|
| 421 |
+
comp.layers = new_construction.layers
|
| 422 |
+
comp.u_value = new_construction.u_value
|
| 423 |
+
project_constructions.pop(old_name)
|
| 424 |
+
project_constructions[new_construction.name] = new_construction
|
| 425 |
+
return True, f"Construction '{old_name}' updated to '{new_construction.name}'."
|
| 426 |
+
|
| 427 |
+
def delete_project_construction(self, name: str, project_constructions: Dict[str, Construction],
|
| 428 |
+
components: Dict[str, List]) -> Tuple[bool, str]:
|
| 429 |
+
if name not in project_constructions:
|
| 430 |
+
return False, f"Construction '{name}' not found in project constructions."
|
| 431 |
+
for comp_list in components.values():
|
| 432 |
+
for comp in comp_list:
|
| 433 |
+
if comp.construction and comp.construction.name == name:
|
| 434 |
+
return False, f"Construction '{name}' is used in component '{comp.name}'."
|
| 435 |
+
project_constructions.pop(name)
|
| 436 |
+
return True, f"Construction '{name}' deleted."
|
| 437 |
+
|
| 438 |
+
def to_dataframe(self, data_type: str, project_materials: Optional[Dict[str, Material]] = None,
|
| 439 |
+
project_constructions: Optional[Dict[str, Construction]] = None,
|
| 440 |
+
project_glazing_materials: Optional[Dict[str, GlazingMaterial]] = None,
|
| 441 |
+
project_door_materials: Optional[Dict[str, DoorMaterial]] = None,
|
| 442 |
+
only_project: bool = False) -> pd.DataFrame:
|
| 443 |
+
if data_type == "materials":
|
| 444 |
+
data = []
|
| 445 |
+
materials = project_materials.values() if only_project else self.get_all_materials(project_materials)
|
| 446 |
+
for mat in materials:
|
| 447 |
+
data.append({
|
| 448 |
+
"Name": mat.name,
|
| 449 |
+
"Category": mat.category.value,
|
| 450 |
+
"Conductivity (W/m·K)": mat.conductivity,
|
| 451 |
+
"Density (kg/m³)": mat.density,
|
| 452 |
+
"Specific Heat (J/kg·K)": mat.specific_heat,
|
| 453 |
+
"Default Thickness (m)": mat.default_thickness,
|
| 454 |
+
"Embodied Carbon (kgCO₂e/kg)": mat.embodied_carbon,
|
| 455 |
+
"Solar Absorption": mat.solar_absorption,
|
| 456 |
+
"Price (USD/m²)": mat.price,
|
| 457 |
+
"Source": "Project" if not mat.is_library else "Library"
|
| 458 |
+
})
|
| 459 |
+
return pd.DataFrame(data)
|
| 460 |
+
elif data_type == "constructions":
|
| 461 |
+
data = []
|
| 462 |
+
constructions = project_constructions.values() if only_project else list(self.library_constructions.values())
|
| 463 |
+
if not only_project and project_constructions:
|
| 464 |
+
constructions.extend(list(project_constructions.values()))
|
| 465 |
+
for cons in constructions:
|
| 466 |
+
layers_str = "; ".join(f"{layer['material'].name} ({layer['thickness']}m)" for layer in cons.layers)
|
| 467 |
+
data.append({
|
| 468 |
+
"Name": cons.name,
|
| 469 |
+
"Component Type": cons.component_type,
|
| 470 |
+
"U-Value (W/m²·K)": cons.u_value,
|
| 471 |
+
"Total Thickness (m)": cons.total_thickness,
|
| 472 |
+
"Embodied Carbon (kgCO₂e/m²)": cons.embodied_carbon,
|
| 473 |
+
"Solar Absorption": cons.solar_absorption,
|
| 474 |
+
"Price (USD/m²)": cons.price,
|
| 475 |
+
"Layers": layers_str,
|
| 476 |
+
"Source": "Project" if not cons.is_library else "Library"
|
| 477 |
+
})
|
| 478 |
+
return pd.DataFrame(data)
|
| 479 |
+
elif data_type == "glazing_materials":
|
| 480 |
+
data = []
|
| 481 |
+
glazing_materials = project_glazing_materials.values() if only_project else self.get_all_glazing_materials(project_glazing_materials)
|
| 482 |
+
for mat in glazing_materials:
|
| 483 |
+
data.append({
|
| 484 |
+
"Name": mat.name,
|
| 485 |
+
"U-Value (W/m²·K)": mat.u_value,
|
| 486 |
+
"SHGC": mat.shgc,
|
| 487 |
+
"Embodied Carbon (kgCO₂e/m²)": mat.embodied_carbon,
|
| 488 |
+
"Price (USD/m²)": mat.price,
|
| 489 |
+
"Source": "Project" if not mat.is_library else "Library"
|
| 490 |
+
})
|
| 491 |
+
return pd.DataFrame(data)
|
| 492 |
+
elif data_type == "door_materials":
|
| 493 |
+
data = []
|
| 494 |
+
door_materials = project_door_materials.values() if only_project else self.get_all_door_materials(project_door_materials)
|
| 495 |
+
for mat in door_materials:
|
| 496 |
+
data.append({
|
| 497 |
+
"Name": mat.name,
|
| 498 |
+
"U-Value (W/m²·K)": mat.u_value,
|
| 499 |
+
"Solar Absorption": mat.solar_absorption,
|
| 500 |
+
"Embodied Carbon (kgCO₂e/m²)": mat.embodied_carbon,
|
| 501 |
+
"Price (USD/m²)": mat.price,
|
| 502 |
+
"Source": "Project" if not mat.is_library else "Library"
|
| 503 |
+
})
|
| 504 |
+
return pd.DataFrame(data)
|
| 505 |
+
return pd.DataFrame()
|