Spaces:
Sleeping
Sleeping
Upload 23 files
Browse files- app/building_info_form.py +332 -0
- app/component_selection.py +1674 -0
- app/data_export.py +870 -0
- app/data_persistence.py +540 -0
- app/data_validation.py +490 -0
- app/main.py +630 -0
- app/results_display.py +592 -0
- data/ashrae_tables.py +702 -0
- data/building_components.py +465 -0
- data/climate_data.py +428 -0
- data/reference_data.py +613 -0
- utils/area_calculation_system.py +523 -0
- utils/component_library.py +365 -0
- utils/component_visualization.py +721 -0
- utils/cooling_load.py +774 -0
- utils/heat_transfer.py +548 -0
- utils/heating_load.py +683 -0
- utils/psychrometric_visualization.py +635 -0
- utils/psychrometrics.py +502 -0
- utils/scenario_comparison.py +675 -0
- utils/shading_system.py +350 -0
- utils/time_based_visualization.py +745 -0
- utils/u_value_calculator.py +457 -0
app/building_info_form.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Building information input form for HVAC Load Calculator.
|
| 3 |
+
This module provides the UI components for entering building information.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
# Import data models
|
| 14 |
+
from data.building_components import Orientation, ComponentType
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class BuildingInfoForm:
|
| 18 |
+
"""Class for building information input form."""
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
def display_building_info_form(session_state: Dict[str, Any]) -> None:
|
| 22 |
+
"""
|
| 23 |
+
Display building information input form in Streamlit.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
session_state: Streamlit session state for storing form data
|
| 27 |
+
"""
|
| 28 |
+
st.header("Building Information")
|
| 29 |
+
|
| 30 |
+
# Initialize building info in session state if not exists
|
| 31 |
+
if "building_info" not in session_state:
|
| 32 |
+
session_state["building_info"] = {
|
| 33 |
+
"project_name": "",
|
| 34 |
+
"building_name": "",
|
| 35 |
+
"location": "",
|
| 36 |
+
"climate_zone": "",
|
| 37 |
+
"building_type": "",
|
| 38 |
+
"floor_area": 0.0,
|
| 39 |
+
"num_floors": 1,
|
| 40 |
+
"floor_height": 3.0,
|
| 41 |
+
"orientation": "NORTH",
|
| 42 |
+
"occupancy": 0,
|
| 43 |
+
"operating_hours": "8:00-18:00",
|
| 44 |
+
"design_conditions": {
|
| 45 |
+
"summer_outdoor_db": 35.0,
|
| 46 |
+
"summer_outdoor_wb": 25.0,
|
| 47 |
+
"summer_indoor_db": 24.0,
|
| 48 |
+
"summer_indoor_rh": 50.0,
|
| 49 |
+
"winter_outdoor_db": -5.0,
|
| 50 |
+
"winter_outdoor_rh": 80.0,
|
| 51 |
+
"winter_indoor_db": 21.0,
|
| 52 |
+
"winter_indoor_rh": 40.0
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# Create form
|
| 57 |
+
with st.form("building_info_form"):
|
| 58 |
+
st.subheader("Project Information")
|
| 59 |
+
|
| 60 |
+
col1, col2 = st.columns(2)
|
| 61 |
+
|
| 62 |
+
with col1:
|
| 63 |
+
session_state["building_info"]["project_name"] = st.text_input(
|
| 64 |
+
"Project Name",
|
| 65 |
+
value=session_state["building_info"]["project_name"],
|
| 66 |
+
help="Enter the name of the project"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
session_state["building_info"]["building_name"] = st.text_input(
|
| 70 |
+
"Building Name",
|
| 71 |
+
value=session_state["building_info"]["building_name"],
|
| 72 |
+
help="Enter the name of the building"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
with col2:
|
| 76 |
+
session_state["building_info"]["location"] = st.text_input(
|
| 77 |
+
"Location",
|
| 78 |
+
value=session_state["building_info"]["location"],
|
| 79 |
+
help="Enter the location of the building (city, country)"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
session_state["building_info"]["climate_zone"] = st.selectbox(
|
| 83 |
+
"Climate Zone",
|
| 84 |
+
["1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"],
|
| 85 |
+
index=4 if session_state["building_info"]["climate_zone"] == "" else
|
| 86 |
+
["1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"].index(session_state["building_info"]["climate_zone"]),
|
| 87 |
+
help="Select the ASHRAE climate zone for the building location"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
st.subheader("Building Characteristics")
|
| 91 |
+
|
| 92 |
+
col1, col2, col3 = st.columns(3)
|
| 93 |
+
|
| 94 |
+
with col1:
|
| 95 |
+
session_state["building_info"]["building_type"] = st.selectbox(
|
| 96 |
+
"Building Type",
|
| 97 |
+
["Residential", "Office", "Retail", "Educational", "Healthcare", "Industrial", "Other"],
|
| 98 |
+
index=1 if session_state["building_info"]["building_type"] == "" else
|
| 99 |
+
["Residential", "Office", "Retail", "Educational", "Healthcare", "Industrial", "Other"].index(session_state["building_info"]["building_type"]),
|
| 100 |
+
help="Select the type of building"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
with col2:
|
| 104 |
+
session_state["building_info"]["floor_area"] = st.number_input(
|
| 105 |
+
"Floor Area (m²)",
|
| 106 |
+
min_value=0.0,
|
| 107 |
+
value=float(session_state["building_info"]["floor_area"]),
|
| 108 |
+
step=10.0,
|
| 109 |
+
help="Enter the total floor area of the building in square meters"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
with col3:
|
| 113 |
+
session_state["building_info"]["num_floors"] = st.number_input(
|
| 114 |
+
"Number of Floors",
|
| 115 |
+
min_value=1,
|
| 116 |
+
value=int(session_state["building_info"]["num_floors"]),
|
| 117 |
+
step=1,
|
| 118 |
+
help="Enter the number of floors in the building"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
col1, col2, col3 = st.columns(3)
|
| 122 |
+
|
| 123 |
+
with col1:
|
| 124 |
+
session_state["building_info"]["floor_height"] = st.number_input(
|
| 125 |
+
"Floor Height (m)",
|
| 126 |
+
min_value=2.0,
|
| 127 |
+
max_value=10.0,
|
| 128 |
+
value=float(session_state["building_info"]["floor_height"]),
|
| 129 |
+
step=0.1,
|
| 130 |
+
help="Enter the floor-to-floor height in meters"
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
with col2:
|
| 134 |
+
session_state["building_info"]["orientation"] = st.selectbox(
|
| 135 |
+
"Building Orientation",
|
| 136 |
+
["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
|
| 137 |
+
index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["building_info"]["orientation"]),
|
| 138 |
+
help="Select the orientation of the building's main facade"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
with col3:
|
| 142 |
+
session_state["building_info"]["occupancy"] = st.number_input(
|
| 143 |
+
"Occupancy (people)",
|
| 144 |
+
min_value=0,
|
| 145 |
+
value=int(session_state["building_info"]["occupancy"]),
|
| 146 |
+
step=1,
|
| 147 |
+
help="Enter the total number of occupants"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
session_state["building_info"]["operating_hours"] = st.text_input(
|
| 151 |
+
"Operating Hours",
|
| 152 |
+
value=session_state["building_info"]["operating_hours"],
|
| 153 |
+
help="Enter the operating hours of the building (e.g., 8:00-18:00)"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
st.subheader("Design Conditions")
|
| 157 |
+
|
| 158 |
+
st.write("Summer Design Conditions")
|
| 159 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 160 |
+
|
| 161 |
+
with col1:
|
| 162 |
+
session_state["building_info"]["design_conditions"]["summer_outdoor_db"] = st.number_input(
|
| 163 |
+
"Outdoor Dry-Bulb (°C)",
|
| 164 |
+
min_value=-10.0,
|
| 165 |
+
max_value=50.0,
|
| 166 |
+
value=float(session_state["building_info"]["design_conditions"]["summer_outdoor_db"]),
|
| 167 |
+
step=0.5,
|
| 168 |
+
key="summer_outdoor_db",
|
| 169 |
+
help="Enter the summer outdoor design dry-bulb temperature"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
with col2:
|
| 173 |
+
session_state["building_info"]["design_conditions"]["summer_outdoor_wb"] = st.number_input(
|
| 174 |
+
"Outdoor Wet-Bulb (°C)",
|
| 175 |
+
min_value=-10.0,
|
| 176 |
+
max_value=40.0,
|
| 177 |
+
value=float(session_state["building_info"]["design_conditions"]["summer_outdoor_wb"]),
|
| 178 |
+
step=0.5,
|
| 179 |
+
key="summer_outdoor_wb",
|
| 180 |
+
help="Enter the summer outdoor design wet-bulb temperature"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
with col3:
|
| 184 |
+
session_state["building_info"]["design_conditions"]["summer_indoor_db"] = st.number_input(
|
| 185 |
+
"Indoor Dry-Bulb (°C)",
|
| 186 |
+
min_value=18.0,
|
| 187 |
+
max_value=30.0,
|
| 188 |
+
value=float(session_state["building_info"]["design_conditions"]["summer_indoor_db"]),
|
| 189 |
+
step=0.5,
|
| 190 |
+
key="summer_indoor_db",
|
| 191 |
+
help="Enter the summer indoor design dry-bulb temperature"
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
with col4:
|
| 195 |
+
session_state["building_info"]["design_conditions"]["summer_indoor_rh"] = st.number_input(
|
| 196 |
+
"Indoor RH (%)",
|
| 197 |
+
min_value=30.0,
|
| 198 |
+
max_value=70.0,
|
| 199 |
+
value=float(session_state["building_info"]["design_conditions"]["summer_indoor_rh"]),
|
| 200 |
+
step=5.0,
|
| 201 |
+
key="summer_indoor_rh",
|
| 202 |
+
help="Enter the summer indoor design relative humidity"
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
st.write("Winter Design Conditions")
|
| 206 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 207 |
+
|
| 208 |
+
with col1:
|
| 209 |
+
session_state["building_info"]["design_conditions"]["winter_outdoor_db"] = st.number_input(
|
| 210 |
+
"Outdoor Dry-Bulb (°C)",
|
| 211 |
+
min_value=-40.0,
|
| 212 |
+
max_value=20.0,
|
| 213 |
+
value=float(session_state["building_info"]["design_conditions"]["winter_outdoor_db"]),
|
| 214 |
+
step=0.5,
|
| 215 |
+
key="winter_outdoor_db",
|
| 216 |
+
help="Enter the winter outdoor design dry-bulb temperature"
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
with col2:
|
| 220 |
+
session_state["building_info"]["design_conditions"]["winter_outdoor_rh"] = st.number_input(
|
| 221 |
+
"Outdoor RH (%)",
|
| 222 |
+
min_value=0.0,
|
| 223 |
+
max_value=100.0,
|
| 224 |
+
value=float(session_state["building_info"]["design_conditions"]["winter_outdoor_rh"]),
|
| 225 |
+
step=5.0,
|
| 226 |
+
key="winter_outdoor_rh",
|
| 227 |
+
help="Enter the winter outdoor design relative humidity"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
with col3:
|
| 231 |
+
session_state["building_info"]["design_conditions"]["winter_indoor_db"] = st.number_input(
|
| 232 |
+
"Indoor Dry-Bulb (°C)",
|
| 233 |
+
min_value=18.0,
|
| 234 |
+
max_value=25.0,
|
| 235 |
+
value=float(session_state["building_info"]["design_conditions"]["winter_indoor_db"]),
|
| 236 |
+
step=0.5,
|
| 237 |
+
key="winter_indoor_db",
|
| 238 |
+
help="Enter the winter indoor design dry-bulb temperature"
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
with col4:
|
| 242 |
+
session_state["building_info"]["design_conditions"]["winter_indoor_rh"] = st.number_input(
|
| 243 |
+
"Indoor RH (%)",
|
| 244 |
+
min_value=20.0,
|
| 245 |
+
max_value=60.0,
|
| 246 |
+
value=float(session_state["building_info"]["design_conditions"]["winter_indoor_rh"]),
|
| 247 |
+
step=5.0,
|
| 248 |
+
key="winter_indoor_rh",
|
| 249 |
+
help="Enter the winter indoor design relative humidity"
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# Submit button
|
| 253 |
+
submitted = st.form_submit_button("Save Building Information")
|
| 254 |
+
|
| 255 |
+
if submitted:
|
| 256 |
+
st.success("Building information saved successfully!")
|
| 257 |
+
|
| 258 |
+
# Save to file
|
| 259 |
+
BuildingInfoForm.save_building_info(session_state["building_info"])
|
| 260 |
+
|
| 261 |
+
@staticmethod
|
| 262 |
+
def save_building_info(building_info: Dict[str, Any], file_path: str = "building_info.json") -> None:
|
| 263 |
+
"""
|
| 264 |
+
Save building information to a JSON file.
|
| 265 |
+
|
| 266 |
+
Args:
|
| 267 |
+
building_info: Dictionary with building information
|
| 268 |
+
file_path: Path to save the JSON file
|
| 269 |
+
"""
|
| 270 |
+
try:
|
| 271 |
+
with open(file_path, "w") as f:
|
| 272 |
+
json.dump(building_info, f, indent=4)
|
| 273 |
+
except Exception as e:
|
| 274 |
+
st.error(f"Error saving building information: {e}")
|
| 275 |
+
|
| 276 |
+
@staticmethod
|
| 277 |
+
def load_building_info(file_path: str = "building_info.json") -> Dict[str, Any]:
|
| 278 |
+
"""
|
| 279 |
+
Load building information from a JSON file.
|
| 280 |
+
|
| 281 |
+
Args:
|
| 282 |
+
file_path: Path to the JSON file
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
Dictionary with building information
|
| 286 |
+
"""
|
| 287 |
+
if os.path.exists(file_path):
|
| 288 |
+
try:
|
| 289 |
+
with open(file_path, "r") as f:
|
| 290 |
+
return json.load(f)
|
| 291 |
+
except Exception as e:
|
| 292 |
+
st.error(f"Error loading building information: {e}")
|
| 293 |
+
|
| 294 |
+
# Return default building info if file doesn't exist or error occurs
|
| 295 |
+
return {
|
| 296 |
+
"project_name": "",
|
| 297 |
+
"building_name": "",
|
| 298 |
+
"location": "",
|
| 299 |
+
"climate_zone": "",
|
| 300 |
+
"building_type": "",
|
| 301 |
+
"floor_area": 0.0,
|
| 302 |
+
"num_floors": 1,
|
| 303 |
+
"floor_height": 3.0,
|
| 304 |
+
"orientation": "NORTH",
|
| 305 |
+
"occupancy": 0,
|
| 306 |
+
"operating_hours": "8:00-18:00",
|
| 307 |
+
"design_conditions": {
|
| 308 |
+
"summer_outdoor_db": 35.0,
|
| 309 |
+
"summer_outdoor_wb": 25.0,
|
| 310 |
+
"summer_indoor_db": 24.0,
|
| 311 |
+
"summer_indoor_rh": 50.0,
|
| 312 |
+
"winter_outdoor_db": -5.0,
|
| 313 |
+
"winter_outdoor_rh": 80.0,
|
| 314 |
+
"winter_indoor_db": 21.0,
|
| 315 |
+
"winter_indoor_rh": 40.0
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# Create a singleton instance
|
| 321 |
+
building_info_form = BuildingInfoForm()
|
| 322 |
+
|
| 323 |
+
# Example usage
|
| 324 |
+
if __name__ == "__main__":
|
| 325 |
+
import streamlit as st
|
| 326 |
+
|
| 327 |
+
# Initialize session state
|
| 328 |
+
if "building_info" not in st.session_state:
|
| 329 |
+
st.session_state["building_info"] = BuildingInfoForm.load_building_info()
|
| 330 |
+
|
| 331 |
+
# Display building information form
|
| 332 |
+
building_info_form.display_building_info_form(st.session_state)
|
app/component_selection.py
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Component selection interface for HVAC Load Calculator.
|
| 3 |
+
This module provides the UI components for selecting building components.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import uuid
|
| 13 |
+
|
| 14 |
+
# Import data models
|
| 15 |
+
from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
|
| 16 |
+
from utils.component_library import ComponentLibrary
|
| 17 |
+
from utils.u_value_calculator import UValueCalculator
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ComponentSelectionInterface:
|
| 21 |
+
"""Class for component selection interface."""
|
| 22 |
+
|
| 23 |
+
def __init__(self):
|
| 24 |
+
"""Initialize component selection interface."""
|
| 25 |
+
self.component_library = ComponentLibrary()
|
| 26 |
+
self.u_value_calculator = UValueCalculator()
|
| 27 |
+
|
| 28 |
+
def display_component_selection(self, session_state: Dict[str, Any]) -> None:
|
| 29 |
+
"""
|
| 30 |
+
Display component selection interface in Streamlit.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
session_state: Streamlit session state for storing form data
|
| 34 |
+
"""
|
| 35 |
+
st.header("Building Components")
|
| 36 |
+
|
| 37 |
+
# Initialize components in session state if not exists
|
| 38 |
+
if "components" not in session_state:
|
| 39 |
+
session_state["components"] = {
|
| 40 |
+
"walls": [],
|
| 41 |
+
"roofs": [],
|
| 42 |
+
"floors": [],
|
| 43 |
+
"windows": [],
|
| 44 |
+
"doors": []
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
# Create tabs for different component types
|
| 48 |
+
tab1, tab2, tab3, tab4, tab5 = st.tabs([
|
| 49 |
+
"Walls",
|
| 50 |
+
"Roofs",
|
| 51 |
+
"Floors",
|
| 52 |
+
"Windows",
|
| 53 |
+
"Doors"
|
| 54 |
+
])
|
| 55 |
+
|
| 56 |
+
with tab1:
|
| 57 |
+
self._display_wall_selection(session_state)
|
| 58 |
+
|
| 59 |
+
with tab2:
|
| 60 |
+
self._display_roof_selection(session_state)
|
| 61 |
+
|
| 62 |
+
with tab3:
|
| 63 |
+
self._display_floor_selection(session_state)
|
| 64 |
+
|
| 65 |
+
with tab4:
|
| 66 |
+
self._display_window_selection(session_state)
|
| 67 |
+
|
| 68 |
+
with tab5:
|
| 69 |
+
self._display_door_selection(session_state)
|
| 70 |
+
|
| 71 |
+
def _display_wall_selection(self, session_state: Dict[str, Any]) -> None:
|
| 72 |
+
"""
|
| 73 |
+
Display wall selection interface.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
session_state: Streamlit session state for storing form data
|
| 77 |
+
"""
|
| 78 |
+
st.subheader("Wall Components")
|
| 79 |
+
|
| 80 |
+
# Display existing walls
|
| 81 |
+
if session_state["components"]["walls"]:
|
| 82 |
+
st.write("Existing Walls:")
|
| 83 |
+
|
| 84 |
+
# Create a DataFrame for display
|
| 85 |
+
wall_data = []
|
| 86 |
+
for i, wall in enumerate(session_state["components"]["walls"]):
|
| 87 |
+
wall_data.append({
|
| 88 |
+
"ID": wall.id,
|
| 89 |
+
"Name": wall.name,
|
| 90 |
+
"Orientation": wall.orientation.name,
|
| 91 |
+
"Area (m²)": wall.area,
|
| 92 |
+
"U-Value (W/m²·K)": wall.u_value,
|
| 93 |
+
"Wall Type": wall.wall_type,
|
| 94 |
+
"Wall Group": wall.wall_group
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
wall_df = pd.DataFrame(wall_data)
|
| 98 |
+
st.dataframe(wall_df, use_container_width=True)
|
| 99 |
+
|
| 100 |
+
# Add edit and delete buttons
|
| 101 |
+
col1, col2 = st.columns(2)
|
| 102 |
+
|
| 103 |
+
with col1:
|
| 104 |
+
wall_to_edit = st.selectbox(
|
| 105 |
+
"Select Wall to Edit",
|
| 106 |
+
options=[f"#{i+1}: {wall.name}" for i, wall in enumerate(session_state["components"]["walls"])],
|
| 107 |
+
key="wall_to_edit"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
if st.button("Edit Selected Wall", key="edit_wall_button"):
|
| 111 |
+
# Get index of wall to edit
|
| 112 |
+
wall_index = int(wall_to_edit.split(":")[0][1:]) - 1
|
| 113 |
+
|
| 114 |
+
# Set edit flag and index
|
| 115 |
+
session_state["edit_wall"] = True
|
| 116 |
+
session_state["edit_wall_index"] = wall_index
|
| 117 |
+
|
| 118 |
+
# Set form values from selected wall
|
| 119 |
+
wall = session_state["components"]["walls"][wall_index]
|
| 120 |
+
session_state["wall_name"] = wall.name
|
| 121 |
+
session_state["wall_orientation"] = wall.orientation.name
|
| 122 |
+
session_state["wall_area"] = wall.area
|
| 123 |
+
session_state["wall_u_value"] = wall.u_value
|
| 124 |
+
session_state["wall_type"] = wall.wall_type
|
| 125 |
+
session_state["wall_group"] = wall.wall_group
|
| 126 |
+
|
| 127 |
+
st.experimental_rerun()
|
| 128 |
+
|
| 129 |
+
with col2:
|
| 130 |
+
wall_to_delete = st.selectbox(
|
| 131 |
+
"Select Wall to Delete",
|
| 132 |
+
options=[f"#{i+1}: {wall.name}" for i, wall in enumerate(session_state["components"]["walls"])],
|
| 133 |
+
key="wall_to_delete"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
if st.button("Delete Selected Wall", key="delete_wall_button"):
|
| 137 |
+
# Get index of wall to delete
|
| 138 |
+
wall_index = int(wall_to_delete.split(":")[0][1:]) - 1
|
| 139 |
+
|
| 140 |
+
# Remove wall from session state
|
| 141 |
+
session_state["components"]["walls"].pop(wall_index)
|
| 142 |
+
|
| 143 |
+
st.success(f"Wall {wall_to_delete} deleted successfully!")
|
| 144 |
+
st.experimental_rerun()
|
| 145 |
+
|
| 146 |
+
# Add new wall form
|
| 147 |
+
st.write("Add New Wall:")
|
| 148 |
+
|
| 149 |
+
# Initialize edit flag if not exists
|
| 150 |
+
if "edit_wall" not in session_state:
|
| 151 |
+
session_state["edit_wall"] = False
|
| 152 |
+
session_state["edit_wall_index"] = -1
|
| 153 |
+
|
| 154 |
+
# Initialize form values if not exists
|
| 155 |
+
if "wall_name" not in session_state:
|
| 156 |
+
session_state["wall_name"] = ""
|
| 157 |
+
session_state["wall_orientation"] = "NORTH"
|
| 158 |
+
session_state["wall_area"] = 0.0
|
| 159 |
+
session_state["wall_u_value"] = 0.5
|
| 160 |
+
session_state["wall_type"] = "Brick"
|
| 161 |
+
session_state["wall_group"] = "B"
|
| 162 |
+
|
| 163 |
+
# Create form
|
| 164 |
+
with st.form("wall_form"):
|
| 165 |
+
col1, col2 = st.columns(2)
|
| 166 |
+
|
| 167 |
+
with col1:
|
| 168 |
+
wall_name = st.text_input(
|
| 169 |
+
"Wall Name",
|
| 170 |
+
value=session_state["wall_name"],
|
| 171 |
+
key="wall_name_input",
|
| 172 |
+
help="Enter a descriptive name for the wall"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
wall_orientation = st.selectbox(
|
| 176 |
+
"Orientation",
|
| 177 |
+
options=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
|
| 178 |
+
index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["wall_orientation"]),
|
| 179 |
+
key="wall_orientation_input",
|
| 180 |
+
help="Select the orientation of the wall"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
wall_area = st.number_input(
|
| 184 |
+
"Area (m²)",
|
| 185 |
+
min_value=0.0,
|
| 186 |
+
value=session_state["wall_area"],
|
| 187 |
+
step=0.1,
|
| 188 |
+
key="wall_area_input",
|
| 189 |
+
help="Enter the area of the wall in square meters"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
with col2:
|
| 193 |
+
# Option to select from preset or custom
|
| 194 |
+
wall_selection_method = st.radio(
|
| 195 |
+
"Wall Selection Method",
|
| 196 |
+
options=["Select from Presets", "Custom U-Value"],
|
| 197 |
+
key="wall_selection_method"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
if wall_selection_method == "Select from Presets":
|
| 201 |
+
# Get preset wall types from component library
|
| 202 |
+
preset_walls = self.component_library.get_preset_walls()
|
| 203 |
+
wall_types = [wall["name"] for wall in preset_walls]
|
| 204 |
+
|
| 205 |
+
wall_type = st.selectbox(
|
| 206 |
+
"Wall Type",
|
| 207 |
+
options=wall_types,
|
| 208 |
+
index=wall_types.index(session_state["wall_type"]) if session_state["wall_type"] in wall_types else 0,
|
| 209 |
+
key="wall_type_input",
|
| 210 |
+
help="Select the type of wall construction"
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Get U-value from selected preset
|
| 214 |
+
selected_wall = next((wall for wall in preset_walls if wall["name"] == wall_type), None)
|
| 215 |
+
if selected_wall:
|
| 216 |
+
wall_u_value = selected_wall["u_value"]
|
| 217 |
+
wall_group = selected_wall["wall_group"]
|
| 218 |
+
else:
|
| 219 |
+
wall_u_value = session_state["wall_u_value"]
|
| 220 |
+
wall_group = session_state["wall_group"]
|
| 221 |
+
|
| 222 |
+
# Display U-value (read-only)
|
| 223 |
+
st.number_input(
|
| 224 |
+
"U-Value (W/m²·K)",
|
| 225 |
+
min_value=0.0,
|
| 226 |
+
value=wall_u_value,
|
| 227 |
+
step=0.01,
|
| 228 |
+
key="wall_u_value_display",
|
| 229 |
+
disabled=True,
|
| 230 |
+
help="U-value of the selected wall type"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# Display wall group (read-only)
|
| 234 |
+
st.text_input(
|
| 235 |
+
"Wall Group",
|
| 236 |
+
value=wall_group,
|
| 237 |
+
key="wall_group_display",
|
| 238 |
+
disabled=True,
|
| 239 |
+
help="ASHRAE wall group for cooling load calculations"
|
| 240 |
+
)
|
| 241 |
+
else:
|
| 242 |
+
# Custom U-value input
|
| 243 |
+
wall_u_value = st.number_input(
|
| 244 |
+
"U-Value (W/m²·K)",
|
| 245 |
+
min_value=0.0,
|
| 246 |
+
value=session_state["wall_u_value"],
|
| 247 |
+
step=0.01,
|
| 248 |
+
key="wall_u_value_input",
|
| 249 |
+
help="Enter the U-value of the wall"
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# Wall group selection
|
| 253 |
+
wall_group = st.selectbox(
|
| 254 |
+
"Wall Group",
|
| 255 |
+
options=["A", "B", "C", "D", "E", "F", "G", "H"],
|
| 256 |
+
index=["A", "B", "C", "D", "E", "F", "G", "H"].index(session_state["wall_group"]),
|
| 257 |
+
key="wall_group_input",
|
| 258 |
+
help="Select the ASHRAE wall group for cooling load calculations"
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
# Set wall type to "Custom"
|
| 262 |
+
wall_type = "Custom"
|
| 263 |
+
|
| 264 |
+
# Submit button
|
| 265 |
+
if session_state["edit_wall"]:
|
| 266 |
+
submit_label = "Update Wall"
|
| 267 |
+
else:
|
| 268 |
+
submit_label = "Add Wall"
|
| 269 |
+
|
| 270 |
+
submitted = st.form_submit_button(submit_label)
|
| 271 |
+
|
| 272 |
+
if submitted:
|
| 273 |
+
# Validate inputs
|
| 274 |
+
if not wall_name:
|
| 275 |
+
st.error("Wall name is required!")
|
| 276 |
+
elif wall_area <= 0:
|
| 277 |
+
st.error("Wall area must be greater than zero!")
|
| 278 |
+
elif wall_u_value <= 0:
|
| 279 |
+
st.error("Wall U-value must be greater than zero!")
|
| 280 |
+
else:
|
| 281 |
+
# Create wall object
|
| 282 |
+
wall = Wall(
|
| 283 |
+
id=str(uuid.uuid4()) if not session_state["edit_wall"] else session_state["components"]["walls"][session_state["edit_wall_index"]].id,
|
| 284 |
+
name=wall_name,
|
| 285 |
+
component_type=ComponentType.WALL,
|
| 286 |
+
u_value=wall_u_value,
|
| 287 |
+
area=wall_area,
|
| 288 |
+
orientation=Orientation[wall_orientation],
|
| 289 |
+
wall_type=wall_type,
|
| 290 |
+
wall_group=wall_group
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
if session_state["edit_wall"]:
|
| 294 |
+
# Update existing wall
|
| 295 |
+
session_state["components"]["walls"][session_state["edit_wall_index"]] = wall
|
| 296 |
+
st.success(f"Wall '{wall_name}' updated successfully!")
|
| 297 |
+
|
| 298 |
+
# Reset edit flag
|
| 299 |
+
session_state["edit_wall"] = False
|
| 300 |
+
session_state["edit_wall_index"] = -1
|
| 301 |
+
else:
|
| 302 |
+
# Add new wall
|
| 303 |
+
session_state["components"]["walls"].append(wall)
|
| 304 |
+
st.success(f"Wall '{wall_name}' added successfully!")
|
| 305 |
+
|
| 306 |
+
# Reset form values
|
| 307 |
+
session_state["wall_name"] = ""
|
| 308 |
+
session_state["wall_orientation"] = "NORTH"
|
| 309 |
+
session_state["wall_area"] = 0.0
|
| 310 |
+
session_state["wall_u_value"] = 0.5
|
| 311 |
+
session_state["wall_type"] = "Brick"
|
| 312 |
+
session_state["wall_group"] = "B"
|
| 313 |
+
|
| 314 |
+
# Save components to file
|
| 315 |
+
self.save_components(session_state["components"])
|
| 316 |
+
|
| 317 |
+
st.experimental_rerun()
|
| 318 |
+
|
| 319 |
+
# Add U-value calculator button
|
| 320 |
+
if st.button("Open U-Value Calculator", key="open_u_value_calc_wall"):
|
| 321 |
+
session_state["show_u_value_calculator"] = True
|
| 322 |
+
session_state["u_value_calc_component_type"] = "wall"
|
| 323 |
+
st.experimental_rerun()
|
| 324 |
+
|
| 325 |
+
def _display_roof_selection(self, session_state: Dict[str, Any]) -> None:
|
| 326 |
+
"""
|
| 327 |
+
Display roof selection interface.
|
| 328 |
+
|
| 329 |
+
Args:
|
| 330 |
+
session_state: Streamlit session state for storing form data
|
| 331 |
+
"""
|
| 332 |
+
st.subheader("Roof Components")
|
| 333 |
+
|
| 334 |
+
# Display existing roofs
|
| 335 |
+
if session_state["components"]["roofs"]:
|
| 336 |
+
st.write("Existing Roofs:")
|
| 337 |
+
|
| 338 |
+
# Create a DataFrame for display
|
| 339 |
+
roof_data = []
|
| 340 |
+
for i, roof in enumerate(session_state["components"]["roofs"]):
|
| 341 |
+
roof_data.append({
|
| 342 |
+
"ID": roof.id,
|
| 343 |
+
"Name": roof.name,
|
| 344 |
+
"Orientation": roof.orientation.name,
|
| 345 |
+
"Area (m²)": roof.area,
|
| 346 |
+
"U-Value (W/m²·K)": roof.u_value,
|
| 347 |
+
"Roof Type": roof.roof_type,
|
| 348 |
+
"Roof Group": roof.roof_group
|
| 349 |
+
})
|
| 350 |
+
|
| 351 |
+
roof_df = pd.DataFrame(roof_data)
|
| 352 |
+
st.dataframe(roof_df, use_container_width=True)
|
| 353 |
+
|
| 354 |
+
# Add edit and delete buttons
|
| 355 |
+
col1, col2 = st.columns(2)
|
| 356 |
+
|
| 357 |
+
with col1:
|
| 358 |
+
roof_to_edit = st.selectbox(
|
| 359 |
+
"Select Roof to Edit",
|
| 360 |
+
options=[f"#{i+1}: {roof.name}" for i, roof in enumerate(session_state["components"]["roofs"])],
|
| 361 |
+
key="roof_to_edit"
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
if st.button("Edit Selected Roof", key="edit_roof_button"):
|
| 365 |
+
# Get index of roof to edit
|
| 366 |
+
roof_index = int(roof_to_edit.split(":")[0][1:]) - 1
|
| 367 |
+
|
| 368 |
+
# Set edit flag and index
|
| 369 |
+
session_state["edit_roof"] = True
|
| 370 |
+
session_state["edit_roof_index"] = roof_index
|
| 371 |
+
|
| 372 |
+
# Set form values from selected roof
|
| 373 |
+
roof = session_state["components"]["roofs"][roof_index]
|
| 374 |
+
session_state["roof_name"] = roof.name
|
| 375 |
+
session_state["roof_orientation"] = roof.orientation.name
|
| 376 |
+
session_state["roof_area"] = roof.area
|
| 377 |
+
session_state["roof_u_value"] = roof.u_value
|
| 378 |
+
session_state["roof_type"] = roof.roof_type
|
| 379 |
+
session_state["roof_group"] = roof.roof_group
|
| 380 |
+
|
| 381 |
+
st.experimental_rerun()
|
| 382 |
+
|
| 383 |
+
with col2:
|
| 384 |
+
roof_to_delete = st.selectbox(
|
| 385 |
+
"Select Roof to Delete",
|
| 386 |
+
options=[f"#{i+1}: {roof.name}" for i, roof in enumerate(session_state["components"]["roofs"])],
|
| 387 |
+
key="roof_to_delete"
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
if st.button("Delete Selected Roof", key="delete_roof_button"):
|
| 391 |
+
# Get index of roof to delete
|
| 392 |
+
roof_index = int(roof_to_delete.split(":")[0][1:]) - 1
|
| 393 |
+
|
| 394 |
+
# Remove roof from session state
|
| 395 |
+
session_state["components"]["roofs"].pop(roof_index)
|
| 396 |
+
|
| 397 |
+
st.success(f"Roof {roof_to_delete} deleted successfully!")
|
| 398 |
+
st.experimental_rerun()
|
| 399 |
+
|
| 400 |
+
# Add new roof form
|
| 401 |
+
st.write("Add New Roof:")
|
| 402 |
+
|
| 403 |
+
# Initialize edit flag if not exists
|
| 404 |
+
if "edit_roof" not in session_state:
|
| 405 |
+
session_state["edit_roof"] = False
|
| 406 |
+
session_state["edit_roof_index"] = -1
|
| 407 |
+
|
| 408 |
+
# Initialize form values if not exists
|
| 409 |
+
if "roof_name" not in session_state:
|
| 410 |
+
session_state["roof_name"] = ""
|
| 411 |
+
session_state["roof_orientation"] = "HORIZONTAL"
|
| 412 |
+
session_state["roof_area"] = 0.0
|
| 413 |
+
session_state["roof_u_value"] = 0.3
|
| 414 |
+
session_state["roof_type"] = "Concrete"
|
| 415 |
+
session_state["roof_group"] = "C"
|
| 416 |
+
|
| 417 |
+
# Create form
|
| 418 |
+
with st.form("roof_form"):
|
| 419 |
+
col1, col2 = st.columns(2)
|
| 420 |
+
|
| 421 |
+
with col1:
|
| 422 |
+
roof_name = st.text_input(
|
| 423 |
+
"Roof Name",
|
| 424 |
+
value=session_state["roof_name"],
|
| 425 |
+
key="roof_name_input",
|
| 426 |
+
help="Enter a descriptive name for the roof"
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
roof_orientation = st.selectbox(
|
| 430 |
+
"Orientation",
|
| 431 |
+
options=["HORIZONTAL", "NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
|
| 432 |
+
index=["HORIZONTAL", "NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["roof_orientation"]),
|
| 433 |
+
key="roof_orientation_input",
|
| 434 |
+
help="Select the orientation of the roof (HORIZONTAL for flat roofs)"
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
roof_area = st.number_input(
|
| 438 |
+
"Area (m²)",
|
| 439 |
+
min_value=0.0,
|
| 440 |
+
value=session_state["roof_area"],
|
| 441 |
+
step=0.1,
|
| 442 |
+
key="roof_area_input",
|
| 443 |
+
help="Enter the area of the roof in square meters"
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
with col2:
|
| 447 |
+
# Option to select from preset or custom
|
| 448 |
+
roof_selection_method = st.radio(
|
| 449 |
+
"Roof Selection Method",
|
| 450 |
+
options=["Select from Presets", "Custom U-Value"],
|
| 451 |
+
key="roof_selection_method"
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
if roof_selection_method == "Select from Presets":
|
| 455 |
+
# Get preset roof types from component library
|
| 456 |
+
preset_roofs = self.component_library.get_preset_roofs()
|
| 457 |
+
roof_types = [roof["name"] for roof in preset_roofs]
|
| 458 |
+
|
| 459 |
+
roof_type = st.selectbox(
|
| 460 |
+
"Roof Type",
|
| 461 |
+
options=roof_types,
|
| 462 |
+
index=roof_types.index(session_state["roof_type"]) if session_state["roof_type"] in roof_types else 0,
|
| 463 |
+
key="roof_type_input",
|
| 464 |
+
help="Select the type of roof construction"
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
# Get U-value from selected preset
|
| 468 |
+
selected_roof = next((roof for roof in preset_roofs if roof["name"] == roof_type), None)
|
| 469 |
+
if selected_roof:
|
| 470 |
+
roof_u_value = selected_roof["u_value"]
|
| 471 |
+
roof_group = selected_roof["roof_group"]
|
| 472 |
+
else:
|
| 473 |
+
roof_u_value = session_state["roof_u_value"]
|
| 474 |
+
roof_group = session_state["roof_group"]
|
| 475 |
+
|
| 476 |
+
# Display U-value (read-only)
|
| 477 |
+
st.number_input(
|
| 478 |
+
"U-Value (W/m²·K)",
|
| 479 |
+
min_value=0.0,
|
| 480 |
+
value=roof_u_value,
|
| 481 |
+
step=0.01,
|
| 482 |
+
key="roof_u_value_display",
|
| 483 |
+
disabled=True,
|
| 484 |
+
help="U-value of the selected roof type"
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
# Display roof group (read-only)
|
| 488 |
+
st.text_input(
|
| 489 |
+
"Roof Group",
|
| 490 |
+
value=roof_group,
|
| 491 |
+
key="roof_group_display",
|
| 492 |
+
disabled=True,
|
| 493 |
+
help="ASHRAE roof group for cooling load calculations"
|
| 494 |
+
)
|
| 495 |
+
else:
|
| 496 |
+
# Custom U-value input
|
| 497 |
+
roof_u_value = st.number_input(
|
| 498 |
+
"U-Value (W/m²·K)",
|
| 499 |
+
min_value=0.0,
|
| 500 |
+
value=session_state["roof_u_value"],
|
| 501 |
+
step=0.01,
|
| 502 |
+
key="roof_u_value_input",
|
| 503 |
+
help="Enter the U-value of the roof"
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
# Roof group selection
|
| 507 |
+
roof_group = st.selectbox(
|
| 508 |
+
"Roof Group",
|
| 509 |
+
options=["A", "B", "C", "D", "E", "F", "G"],
|
| 510 |
+
index=["A", "B", "C", "D", "E", "F", "G"].index(session_state["roof_group"]),
|
| 511 |
+
key="roof_group_input",
|
| 512 |
+
help="Select the ASHRAE roof group for cooling load calculations"
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
# Set roof type to "Custom"
|
| 516 |
+
roof_type = "Custom"
|
| 517 |
+
|
| 518 |
+
# Submit button
|
| 519 |
+
if session_state["edit_roof"]:
|
| 520 |
+
submit_label = "Update Roof"
|
| 521 |
+
else:
|
| 522 |
+
submit_label = "Add Roof"
|
| 523 |
+
|
| 524 |
+
submitted = st.form_submit_button(submit_label)
|
| 525 |
+
|
| 526 |
+
if submitted:
|
| 527 |
+
# Validate inputs
|
| 528 |
+
if not roof_name:
|
| 529 |
+
st.error("Roof name is required!")
|
| 530 |
+
elif roof_area <= 0:
|
| 531 |
+
st.error("Roof area must be greater than zero!")
|
| 532 |
+
elif roof_u_value <= 0:
|
| 533 |
+
st.error("Roof U-value must be greater than zero!")
|
| 534 |
+
else:
|
| 535 |
+
# Create roof object
|
| 536 |
+
roof = Roof(
|
| 537 |
+
id=str(uuid.uuid4()) if not session_state["edit_roof"] else session_state["components"]["roofs"][session_state["edit_roof_index"]].id,
|
| 538 |
+
name=roof_name,
|
| 539 |
+
component_type=ComponentType.ROOF,
|
| 540 |
+
u_value=roof_u_value,
|
| 541 |
+
area=roof_area,
|
| 542 |
+
orientation=Orientation[roof_orientation],
|
| 543 |
+
roof_type=roof_type,
|
| 544 |
+
roof_group=roof_group
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
if session_state["edit_roof"]:
|
| 548 |
+
# Update existing roof
|
| 549 |
+
session_state["components"]["roofs"][session_state["edit_roof_index"]] = roof
|
| 550 |
+
st.success(f"Roof '{roof_name}' updated successfully!")
|
| 551 |
+
|
| 552 |
+
# Reset edit flag
|
| 553 |
+
session_state["edit_roof"] = False
|
| 554 |
+
session_state["edit_roof_index"] = -1
|
| 555 |
+
else:
|
| 556 |
+
# Add new roof
|
| 557 |
+
session_state["components"]["roofs"].append(roof)
|
| 558 |
+
st.success(f"Roof '{roof_name}' added successfully!")
|
| 559 |
+
|
| 560 |
+
# Reset form values
|
| 561 |
+
session_state["roof_name"] = ""
|
| 562 |
+
session_state["roof_orientation"] = "HORIZONTAL"
|
| 563 |
+
session_state["roof_area"] = 0.0
|
| 564 |
+
session_state["roof_u_value"] = 0.3
|
| 565 |
+
session_state["roof_type"] = "Concrete"
|
| 566 |
+
session_state["roof_group"] = "C"
|
| 567 |
+
|
| 568 |
+
# Save components to file
|
| 569 |
+
self.save_components(session_state["components"])
|
| 570 |
+
|
| 571 |
+
st.experimental_rerun()
|
| 572 |
+
|
| 573 |
+
# Add U-value calculator button
|
| 574 |
+
if st.button("Open U-Value Calculator", key="open_u_value_calc_roof"):
|
| 575 |
+
session_state["show_u_value_calculator"] = True
|
| 576 |
+
session_state["u_value_calc_component_type"] = "roof"
|
| 577 |
+
st.experimental_rerun()
|
| 578 |
+
|
| 579 |
+
def _display_floor_selection(self, session_state: Dict[str, Any]) -> None:
|
| 580 |
+
"""
|
| 581 |
+
Display floor selection interface.
|
| 582 |
+
|
| 583 |
+
Args:
|
| 584 |
+
session_state: Streamlit session state for storing form data
|
| 585 |
+
"""
|
| 586 |
+
st.subheader("Floor Components")
|
| 587 |
+
|
| 588 |
+
# Display existing floors
|
| 589 |
+
if session_state["components"]["floors"]:
|
| 590 |
+
st.write("Existing Floors:")
|
| 591 |
+
|
| 592 |
+
# Create a DataFrame for display
|
| 593 |
+
floor_data = []
|
| 594 |
+
for i, floor in enumerate(session_state["components"]["floors"]):
|
| 595 |
+
floor_data.append({
|
| 596 |
+
"ID": floor.id,
|
| 597 |
+
"Name": floor.name,
|
| 598 |
+
"Area (m²)": floor.area,
|
| 599 |
+
"U-Value (W/m²·K)": floor.u_value,
|
| 600 |
+
"Floor Type": floor.floor_type
|
| 601 |
+
})
|
| 602 |
+
|
| 603 |
+
floor_df = pd.DataFrame(floor_data)
|
| 604 |
+
st.dataframe(floor_df, use_container_width=True)
|
| 605 |
+
|
| 606 |
+
# Add edit and delete buttons
|
| 607 |
+
col1, col2 = st.columns(2)
|
| 608 |
+
|
| 609 |
+
with col1:
|
| 610 |
+
floor_to_edit = st.selectbox(
|
| 611 |
+
"Select Floor to Edit",
|
| 612 |
+
options=[f"#{i+1}: {floor.name}" for i, floor in enumerate(session_state["components"]["floors"])],
|
| 613 |
+
key="floor_to_edit"
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
if st.button("Edit Selected Floor", key="edit_floor_button"):
|
| 617 |
+
# Get index of floor to edit
|
| 618 |
+
floor_index = int(floor_to_edit.split(":")[0][1:]) - 1
|
| 619 |
+
|
| 620 |
+
# Set edit flag and index
|
| 621 |
+
session_state["edit_floor"] = True
|
| 622 |
+
session_state["edit_floor_index"] = floor_index
|
| 623 |
+
|
| 624 |
+
# Set form values from selected floor
|
| 625 |
+
floor = session_state["components"]["floors"][floor_index]
|
| 626 |
+
session_state["floor_name"] = floor.name
|
| 627 |
+
session_state["floor_area"] = floor.area
|
| 628 |
+
session_state["floor_u_value"] = floor.u_value
|
| 629 |
+
session_state["floor_type"] = floor.floor_type
|
| 630 |
+
|
| 631 |
+
st.experimental_rerun()
|
| 632 |
+
|
| 633 |
+
with col2:
|
| 634 |
+
floor_to_delete = st.selectbox(
|
| 635 |
+
"Select Floor to Delete",
|
| 636 |
+
options=[f"#{i+1}: {floor.name}" for i, floor in enumerate(session_state["components"]["floors"])],
|
| 637 |
+
key="floor_to_delete"
|
| 638 |
+
)
|
| 639 |
+
|
| 640 |
+
if st.button("Delete Selected Floor", key="delete_floor_button"):
|
| 641 |
+
# Get index of floor to delete
|
| 642 |
+
floor_index = int(floor_to_delete.split(":")[0][1:]) - 1
|
| 643 |
+
|
| 644 |
+
# Remove floor from session state
|
| 645 |
+
session_state["components"]["floors"].pop(floor_index)
|
| 646 |
+
|
| 647 |
+
st.success(f"Floor {floor_to_delete} deleted successfully!")
|
| 648 |
+
st.experimental_rerun()
|
| 649 |
+
|
| 650 |
+
# Add new floor form
|
| 651 |
+
st.write("Add New Floor:")
|
| 652 |
+
|
| 653 |
+
# Initialize edit flag if not exists
|
| 654 |
+
if "edit_floor" not in session_state:
|
| 655 |
+
session_state["edit_floor"] = False
|
| 656 |
+
session_state["edit_floor_index"] = -1
|
| 657 |
+
|
| 658 |
+
# Initialize form values if not exists
|
| 659 |
+
if "floor_name" not in session_state:
|
| 660 |
+
session_state["floor_name"] = ""
|
| 661 |
+
session_state["floor_area"] = 0.0
|
| 662 |
+
session_state["floor_u_value"] = 0.4
|
| 663 |
+
session_state["floor_type"] = "Concrete"
|
| 664 |
+
|
| 665 |
+
# Create form
|
| 666 |
+
with st.form("floor_form"):
|
| 667 |
+
col1, col2 = st.columns(2)
|
| 668 |
+
|
| 669 |
+
with col1:
|
| 670 |
+
floor_name = st.text_input(
|
| 671 |
+
"Floor Name",
|
| 672 |
+
value=session_state["floor_name"],
|
| 673 |
+
key="floor_name_input",
|
| 674 |
+
help="Enter a descriptive name for the floor"
|
| 675 |
+
)
|
| 676 |
+
|
| 677 |
+
floor_area = st.number_input(
|
| 678 |
+
"Area (m²)",
|
| 679 |
+
min_value=0.0,
|
| 680 |
+
value=session_state["floor_area"],
|
| 681 |
+
step=0.1,
|
| 682 |
+
key="floor_area_input",
|
| 683 |
+
help="Enter the area of the floor in square meters"
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
with col2:
|
| 687 |
+
# Option to select from preset or custom
|
| 688 |
+
floor_selection_method = st.radio(
|
| 689 |
+
"Floor Selection Method",
|
| 690 |
+
options=["Select from Presets", "Custom U-Value"],
|
| 691 |
+
key="floor_selection_method"
|
| 692 |
+
)
|
| 693 |
+
|
| 694 |
+
if floor_selection_method == "Select from Presets":
|
| 695 |
+
# Get preset floor types from component library
|
| 696 |
+
preset_floors = self.component_library.get_preset_floors()
|
| 697 |
+
floor_types = [floor["name"] for floor in preset_floors]
|
| 698 |
+
|
| 699 |
+
floor_type = st.selectbox(
|
| 700 |
+
"Floor Type",
|
| 701 |
+
options=floor_types,
|
| 702 |
+
index=floor_types.index(session_state["floor_type"]) if session_state["floor_type"] in floor_types else 0,
|
| 703 |
+
key="floor_type_input",
|
| 704 |
+
help="Select the type of floor construction"
|
| 705 |
+
)
|
| 706 |
+
|
| 707 |
+
# Get U-value from selected preset
|
| 708 |
+
selected_floor = next((floor for floor in preset_floors if floor["name"] == floor_type), None)
|
| 709 |
+
if selected_floor:
|
| 710 |
+
floor_u_value = selected_floor["u_value"]
|
| 711 |
+
else:
|
| 712 |
+
floor_u_value = session_state["floor_u_value"]
|
| 713 |
+
|
| 714 |
+
# Display U-value (read-only)
|
| 715 |
+
st.number_input(
|
| 716 |
+
"U-Value (W/m²·K)",
|
| 717 |
+
min_value=0.0,
|
| 718 |
+
value=floor_u_value,
|
| 719 |
+
step=0.01,
|
| 720 |
+
key="floor_u_value_display",
|
| 721 |
+
disabled=True,
|
| 722 |
+
help="U-value of the selected floor type"
|
| 723 |
+
)
|
| 724 |
+
else:
|
| 725 |
+
# Custom U-value input
|
| 726 |
+
floor_u_value = st.number_input(
|
| 727 |
+
"U-Value (W/m²·K)",
|
| 728 |
+
min_value=0.0,
|
| 729 |
+
value=session_state["floor_u_value"],
|
| 730 |
+
step=0.01,
|
| 731 |
+
key="floor_u_value_input",
|
| 732 |
+
help="Enter the U-value of the floor"
|
| 733 |
+
)
|
| 734 |
+
|
| 735 |
+
# Set floor type to "Custom"
|
| 736 |
+
floor_type = "Custom"
|
| 737 |
+
|
| 738 |
+
# Submit button
|
| 739 |
+
if session_state["edit_floor"]:
|
| 740 |
+
submit_label = "Update Floor"
|
| 741 |
+
else:
|
| 742 |
+
submit_label = "Add Floor"
|
| 743 |
+
|
| 744 |
+
submitted = st.form_submit_button(submit_label)
|
| 745 |
+
|
| 746 |
+
if submitted:
|
| 747 |
+
# Validate inputs
|
| 748 |
+
if not floor_name:
|
| 749 |
+
st.error("Floor name is required!")
|
| 750 |
+
elif floor_area <= 0:
|
| 751 |
+
st.error("Floor area must be greater than zero!")
|
| 752 |
+
elif floor_u_value <= 0:
|
| 753 |
+
st.error("Floor U-value must be greater than zero!")
|
| 754 |
+
else:
|
| 755 |
+
# Create floor object
|
| 756 |
+
floor = Floor(
|
| 757 |
+
id=str(uuid.uuid4()) if not session_state["edit_floor"] else session_state["components"]["floors"][session_state["edit_floor_index"]].id,
|
| 758 |
+
name=floor_name,
|
| 759 |
+
component_type=ComponentType.FLOOR,
|
| 760 |
+
u_value=floor_u_value,
|
| 761 |
+
area=floor_area,
|
| 762 |
+
floor_type=floor_type
|
| 763 |
+
)
|
| 764 |
+
|
| 765 |
+
if session_state["edit_floor"]:
|
| 766 |
+
# Update existing floor
|
| 767 |
+
session_state["components"]["floors"][session_state["edit_floor_index"]] = floor
|
| 768 |
+
st.success(f"Floor '{floor_name}' updated successfully!")
|
| 769 |
+
|
| 770 |
+
# Reset edit flag
|
| 771 |
+
session_state["edit_floor"] = False
|
| 772 |
+
session_state["edit_floor_index"] = -1
|
| 773 |
+
else:
|
| 774 |
+
# Add new floor
|
| 775 |
+
session_state["components"]["floors"].append(floor)
|
| 776 |
+
st.success(f"Floor '{floor_name}' added successfully!")
|
| 777 |
+
|
| 778 |
+
# Reset form values
|
| 779 |
+
session_state["floor_name"] = ""
|
| 780 |
+
session_state["floor_area"] = 0.0
|
| 781 |
+
session_state["floor_u_value"] = 0.4
|
| 782 |
+
session_state["floor_type"] = "Concrete"
|
| 783 |
+
|
| 784 |
+
# Save components to file
|
| 785 |
+
self.save_components(session_state["components"])
|
| 786 |
+
|
| 787 |
+
st.experimental_rerun()
|
| 788 |
+
|
| 789 |
+
# Add U-value calculator button
|
| 790 |
+
if st.button("Open U-Value Calculator", key="open_u_value_calc_floor"):
|
| 791 |
+
session_state["show_u_value_calculator"] = True
|
| 792 |
+
session_state["u_value_calc_component_type"] = "floor"
|
| 793 |
+
st.experimental_rerun()
|
| 794 |
+
|
| 795 |
+
def _display_window_selection(self, session_state: Dict[str, Any]) -> None:
|
| 796 |
+
"""
|
| 797 |
+
Display window selection interface.
|
| 798 |
+
|
| 799 |
+
Args:
|
| 800 |
+
session_state: Streamlit session state for storing form data
|
| 801 |
+
"""
|
| 802 |
+
st.subheader("Window Components")
|
| 803 |
+
|
| 804 |
+
# Display existing windows
|
| 805 |
+
if session_state["components"]["windows"]:
|
| 806 |
+
st.write("Existing Windows:")
|
| 807 |
+
|
| 808 |
+
# Create a DataFrame for display
|
| 809 |
+
window_data = []
|
| 810 |
+
for i, window in enumerate(session_state["components"]["windows"]):
|
| 811 |
+
window_data.append({
|
| 812 |
+
"ID": window.id,
|
| 813 |
+
"Name": window.name,
|
| 814 |
+
"Orientation": window.orientation.name,
|
| 815 |
+
"Area (m²)": window.area,
|
| 816 |
+
"U-Value (W/m²·K)": window.u_value,
|
| 817 |
+
"SHGC": window.shgc,
|
| 818 |
+
"Window Type": window.window_type
|
| 819 |
+
})
|
| 820 |
+
|
| 821 |
+
window_df = pd.DataFrame(window_data)
|
| 822 |
+
st.dataframe(window_df, use_container_width=True)
|
| 823 |
+
|
| 824 |
+
# Add edit and delete buttons
|
| 825 |
+
col1, col2 = st.columns(2)
|
| 826 |
+
|
| 827 |
+
with col1:
|
| 828 |
+
window_to_edit = st.selectbox(
|
| 829 |
+
"Select Window to Edit",
|
| 830 |
+
options=[f"#{i+1}: {window.name}" for i, window in enumerate(session_state["components"]["windows"])],
|
| 831 |
+
key="window_to_edit"
|
| 832 |
+
)
|
| 833 |
+
|
| 834 |
+
if st.button("Edit Selected Window", key="edit_window_button"):
|
| 835 |
+
# Get index of window to edit
|
| 836 |
+
window_index = int(window_to_edit.split(":")[0][1:]) - 1
|
| 837 |
+
|
| 838 |
+
# Set edit flag and index
|
| 839 |
+
session_state["edit_window"] = True
|
| 840 |
+
session_state["edit_window_index"] = window_index
|
| 841 |
+
|
| 842 |
+
# Set form values from selected window
|
| 843 |
+
window = session_state["components"]["windows"][window_index]
|
| 844 |
+
session_state["window_name"] = window.name
|
| 845 |
+
session_state["window_orientation"] = window.orientation.name
|
| 846 |
+
session_state["window_area"] = window.area
|
| 847 |
+
session_state["window_u_value"] = window.u_value
|
| 848 |
+
session_state["window_shgc"] = window.shgc
|
| 849 |
+
session_state["window_vt"] = window.vt
|
| 850 |
+
session_state["window_type"] = window.window_type
|
| 851 |
+
session_state["window_glazing_layers"] = window.glazing_layers
|
| 852 |
+
session_state["window_gas_fill"] = window.gas_fill
|
| 853 |
+
session_state["window_low_e_coating"] = window.low_e_coating
|
| 854 |
+
|
| 855 |
+
st.experimental_rerun()
|
| 856 |
+
|
| 857 |
+
with col2:
|
| 858 |
+
window_to_delete = st.selectbox(
|
| 859 |
+
"Select Window to Delete",
|
| 860 |
+
options=[f"#{i+1}: {window.name}" for i, window in enumerate(session_state["components"]["windows"])],
|
| 861 |
+
key="window_to_delete"
|
| 862 |
+
)
|
| 863 |
+
|
| 864 |
+
if st.button("Delete Selected Window", key="delete_window_button"):
|
| 865 |
+
# Get index of window to delete
|
| 866 |
+
window_index = int(window_to_delete.split(":")[0][1:]) - 1
|
| 867 |
+
|
| 868 |
+
# Remove window from session state
|
| 869 |
+
session_state["components"]["windows"].pop(window_index)
|
| 870 |
+
|
| 871 |
+
st.success(f"Window {window_to_delete} deleted successfully!")
|
| 872 |
+
st.experimental_rerun()
|
| 873 |
+
|
| 874 |
+
# Add new window form
|
| 875 |
+
st.write("Add New Window:")
|
| 876 |
+
|
| 877 |
+
# Initialize edit flag if not exists
|
| 878 |
+
if "edit_window" not in session_state:
|
| 879 |
+
session_state["edit_window"] = False
|
| 880 |
+
session_state["edit_window_index"] = -1
|
| 881 |
+
|
| 882 |
+
# Initialize form values if not exists
|
| 883 |
+
if "window_name" not in session_state:
|
| 884 |
+
session_state["window_name"] = ""
|
| 885 |
+
session_state["window_orientation"] = "NORTH"
|
| 886 |
+
session_state["window_area"] = 0.0
|
| 887 |
+
session_state["window_u_value"] = 2.8
|
| 888 |
+
session_state["window_shgc"] = 0.7
|
| 889 |
+
session_state["window_vt"] = 0.8
|
| 890 |
+
session_state["window_type"] = "Double Glazed"
|
| 891 |
+
session_state["window_glazing_layers"] = 2
|
| 892 |
+
session_state["window_gas_fill"] = "Air"
|
| 893 |
+
session_state["window_low_e_coating"] = False
|
| 894 |
+
|
| 895 |
+
# Create form
|
| 896 |
+
with st.form("window_form"):
|
| 897 |
+
col1, col2 = st.columns(2)
|
| 898 |
+
|
| 899 |
+
with col1:
|
| 900 |
+
window_name = st.text_input(
|
| 901 |
+
"Window Name",
|
| 902 |
+
value=session_state["window_name"],
|
| 903 |
+
key="window_name_input",
|
| 904 |
+
help="Enter a descriptive name for the window"
|
| 905 |
+
)
|
| 906 |
+
|
| 907 |
+
window_orientation = st.selectbox(
|
| 908 |
+
"Orientation",
|
| 909 |
+
options=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
|
| 910 |
+
index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["window_orientation"]),
|
| 911 |
+
key="window_orientation_input",
|
| 912 |
+
help="Select the orientation of the window"
|
| 913 |
+
)
|
| 914 |
+
|
| 915 |
+
window_area = st.number_input(
|
| 916 |
+
"Area (m²)",
|
| 917 |
+
min_value=0.0,
|
| 918 |
+
value=session_state["window_area"],
|
| 919 |
+
step=0.1,
|
| 920 |
+
key="window_area_input",
|
| 921 |
+
help="Enter the area of the window in square meters"
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
with col2:
|
| 925 |
+
# Option to select from preset or custom
|
| 926 |
+
window_selection_method = st.radio(
|
| 927 |
+
"Window Selection Method",
|
| 928 |
+
options=["Select from Presets", "Custom Properties"],
|
| 929 |
+
key="window_selection_method"
|
| 930 |
+
)
|
| 931 |
+
|
| 932 |
+
if window_selection_method == "Select from Presets":
|
| 933 |
+
# Get preset window types from component library
|
| 934 |
+
preset_windows = self.component_library.get_preset_windows()
|
| 935 |
+
window_types = [window["name"] for window in preset_windows]
|
| 936 |
+
|
| 937 |
+
window_type = st.selectbox(
|
| 938 |
+
"Window Type",
|
| 939 |
+
options=window_types,
|
| 940 |
+
index=window_types.index(session_state["window_type"]) if session_state["window_type"] in window_types else 0,
|
| 941 |
+
key="window_type_input",
|
| 942 |
+
help="Select the type of window"
|
| 943 |
+
)
|
| 944 |
+
|
| 945 |
+
# Get properties from selected preset
|
| 946 |
+
selected_window = next((window for window in preset_windows if window["name"] == window_type), None)
|
| 947 |
+
if selected_window:
|
| 948 |
+
window_u_value = selected_window["u_value"]
|
| 949 |
+
window_shgc = selected_window["shgc"]
|
| 950 |
+
window_vt = selected_window["vt"]
|
| 951 |
+
window_glazing_layers = selected_window["glazing_layers"]
|
| 952 |
+
window_gas_fill = selected_window["gas_fill"]
|
| 953 |
+
window_low_e_coating = selected_window["low_e_coating"]
|
| 954 |
+
else:
|
| 955 |
+
window_u_value = session_state["window_u_value"]
|
| 956 |
+
window_shgc = session_state["window_shgc"]
|
| 957 |
+
window_vt = session_state["window_vt"]
|
| 958 |
+
window_glazing_layers = session_state["window_glazing_layers"]
|
| 959 |
+
window_gas_fill = session_state["window_gas_fill"]
|
| 960 |
+
window_low_e_coating = session_state["window_low_e_coating"]
|
| 961 |
+
|
| 962 |
+
# Display properties (read-only)
|
| 963 |
+
st.number_input(
|
| 964 |
+
"U-Value (W/m²·K)",
|
| 965 |
+
min_value=0.0,
|
| 966 |
+
value=window_u_value,
|
| 967 |
+
step=0.01,
|
| 968 |
+
key="window_u_value_display",
|
| 969 |
+
disabled=True,
|
| 970 |
+
help="U-value of the selected window type"
|
| 971 |
+
)
|
| 972 |
+
|
| 973 |
+
st.number_input(
|
| 974 |
+
"SHGC",
|
| 975 |
+
min_value=0.0,
|
| 976 |
+
max_value=1.0,
|
| 977 |
+
value=window_shgc,
|
| 978 |
+
step=0.01,
|
| 979 |
+
key="window_shgc_display",
|
| 980 |
+
disabled=True,
|
| 981 |
+
help="Solar Heat Gain Coefficient of the selected window type"
|
| 982 |
+
)
|
| 983 |
+
else:
|
| 984 |
+
# Custom properties input
|
| 985 |
+
window_u_value = st.number_input(
|
| 986 |
+
"U-Value (W/m²·K)",
|
| 987 |
+
min_value=0.0,
|
| 988 |
+
value=session_state["window_u_value"],
|
| 989 |
+
step=0.01,
|
| 990 |
+
key="window_u_value_input",
|
| 991 |
+
help="Enter the U-value of the window"
|
| 992 |
+
)
|
| 993 |
+
|
| 994 |
+
window_shgc = st.number_input(
|
| 995 |
+
"SHGC",
|
| 996 |
+
min_value=0.0,
|
| 997 |
+
max_value=1.0,
|
| 998 |
+
value=session_state["window_shgc"],
|
| 999 |
+
step=0.01,
|
| 1000 |
+
key="window_shgc_input",
|
| 1001 |
+
help="Enter the Solar Heat Gain Coefficient of the window"
|
| 1002 |
+
)
|
| 1003 |
+
|
| 1004 |
+
window_vt = st.number_input(
|
| 1005 |
+
"Visible Transmittance",
|
| 1006 |
+
min_value=0.0,
|
| 1007 |
+
max_value=1.0,
|
| 1008 |
+
value=session_state["window_vt"],
|
| 1009 |
+
step=0.01,
|
| 1010 |
+
key="window_vt_input",
|
| 1011 |
+
help="Enter the Visible Transmittance of the window"
|
| 1012 |
+
)
|
| 1013 |
+
|
| 1014 |
+
window_glazing_layers = st.selectbox(
|
| 1015 |
+
"Glazing Layers",
|
| 1016 |
+
options=[1, 2, 3],
|
| 1017 |
+
index=[1, 2, 3].index(session_state["window_glazing_layers"]),
|
| 1018 |
+
key="window_glazing_layers_input",
|
| 1019 |
+
help="Select the number of glazing layers"
|
| 1020 |
+
)
|
| 1021 |
+
|
| 1022 |
+
window_gas_fill = st.selectbox(
|
| 1023 |
+
"Gas Fill",
|
| 1024 |
+
options=["Air", "Argon", "Krypton"],
|
| 1025 |
+
index=["Air", "Argon", "Krypton"].index(session_state["window_gas_fill"]),
|
| 1026 |
+
key="window_gas_fill_input",
|
| 1027 |
+
help="Select the gas fill between glazing layers"
|
| 1028 |
+
)
|
| 1029 |
+
|
| 1030 |
+
window_low_e_coating = st.checkbox(
|
| 1031 |
+
"Low-E Coating",
|
| 1032 |
+
value=session_state["window_low_e_coating"],
|
| 1033 |
+
key="window_low_e_coating_input",
|
| 1034 |
+
help="Check if the window has low-emissivity coating"
|
| 1035 |
+
)
|
| 1036 |
+
|
| 1037 |
+
# Set window type to "Custom"
|
| 1038 |
+
window_type = "Custom"
|
| 1039 |
+
|
| 1040 |
+
# Submit button
|
| 1041 |
+
if session_state["edit_window"]:
|
| 1042 |
+
submit_label = "Update Window"
|
| 1043 |
+
else:
|
| 1044 |
+
submit_label = "Add Window"
|
| 1045 |
+
|
| 1046 |
+
submitted = st.form_submit_button(submit_label)
|
| 1047 |
+
|
| 1048 |
+
if submitted:
|
| 1049 |
+
# Validate inputs
|
| 1050 |
+
if not window_name:
|
| 1051 |
+
st.error("Window name is required!")
|
| 1052 |
+
elif window_area <= 0:
|
| 1053 |
+
st.error("Window area must be greater than zero!")
|
| 1054 |
+
elif window_u_value <= 0:
|
| 1055 |
+
st.error("Window U-value must be greater than zero!")
|
| 1056 |
+
elif window_shgc <= 0 or window_shgc > 1:
|
| 1057 |
+
st.error("Window SHGC must be between 0 and 1!")
|
| 1058 |
+
else:
|
| 1059 |
+
# Create window object
|
| 1060 |
+
window = Window(
|
| 1061 |
+
id=str(uuid.uuid4()) if not session_state["edit_window"] else session_state["components"]["windows"][session_state["edit_window_index"]].id,
|
| 1062 |
+
name=window_name,
|
| 1063 |
+
component_type=ComponentType.WINDOW,
|
| 1064 |
+
u_value=window_u_value,
|
| 1065 |
+
area=window_area,
|
| 1066 |
+
orientation=Orientation[window_orientation],
|
| 1067 |
+
shgc=window_shgc,
|
| 1068 |
+
vt=window_vt,
|
| 1069 |
+
window_type=window_type,
|
| 1070 |
+
glazing_layers=window_glazing_layers,
|
| 1071 |
+
gas_fill=window_gas_fill,
|
| 1072 |
+
low_e_coating=window_low_e_coating
|
| 1073 |
+
)
|
| 1074 |
+
|
| 1075 |
+
if session_state["edit_window"]:
|
| 1076 |
+
# Update existing window
|
| 1077 |
+
session_state["components"]["windows"][session_state["edit_window_index"]] = window
|
| 1078 |
+
st.success(f"Window '{window_name}' updated successfully!")
|
| 1079 |
+
|
| 1080 |
+
# Reset edit flag
|
| 1081 |
+
session_state["edit_window"] = False
|
| 1082 |
+
session_state["edit_window_index"] = -1
|
| 1083 |
+
else:
|
| 1084 |
+
# Add new window
|
| 1085 |
+
session_state["components"]["windows"].append(window)
|
| 1086 |
+
st.success(f"Window '{window_name}' added successfully!")
|
| 1087 |
+
|
| 1088 |
+
# Reset form values
|
| 1089 |
+
session_state["window_name"] = ""
|
| 1090 |
+
session_state["window_orientation"] = "NORTH"
|
| 1091 |
+
session_state["window_area"] = 0.0
|
| 1092 |
+
session_state["window_u_value"] = 2.8
|
| 1093 |
+
session_state["window_shgc"] = 0.7
|
| 1094 |
+
session_state["window_vt"] = 0.8
|
| 1095 |
+
session_state["window_type"] = "Double Glazed"
|
| 1096 |
+
session_state["window_glazing_layers"] = 2
|
| 1097 |
+
session_state["window_gas_fill"] = "Air"
|
| 1098 |
+
session_state["window_low_e_coating"] = False
|
| 1099 |
+
|
| 1100 |
+
# Save components to file
|
| 1101 |
+
self.save_components(session_state["components"])
|
| 1102 |
+
|
| 1103 |
+
st.experimental_rerun()
|
| 1104 |
+
|
| 1105 |
+
def _display_door_selection(self, session_state: Dict[str, Any]) -> None:
|
| 1106 |
+
"""
|
| 1107 |
+
Display door selection interface.
|
| 1108 |
+
|
| 1109 |
+
Args:
|
| 1110 |
+
session_state: Streamlit session state for storing form data
|
| 1111 |
+
"""
|
| 1112 |
+
st.subheader("Door Components")
|
| 1113 |
+
|
| 1114 |
+
# Display existing doors
|
| 1115 |
+
if session_state["components"]["doors"]:
|
| 1116 |
+
st.write("Existing Doors:")
|
| 1117 |
+
|
| 1118 |
+
# Create a DataFrame for display
|
| 1119 |
+
door_data = []
|
| 1120 |
+
for i, door in enumerate(session_state["components"]["doors"]):
|
| 1121 |
+
door_data.append({
|
| 1122 |
+
"ID": door.id,
|
| 1123 |
+
"Name": door.name,
|
| 1124 |
+
"Orientation": door.orientation.name,
|
| 1125 |
+
"Area (m²)": door.area,
|
| 1126 |
+
"U-Value (W/m²·K)": door.u_value,
|
| 1127 |
+
"Door Type": door.door_type
|
| 1128 |
+
})
|
| 1129 |
+
|
| 1130 |
+
door_df = pd.DataFrame(door_data)
|
| 1131 |
+
st.dataframe(door_df, use_container_width=True)
|
| 1132 |
+
|
| 1133 |
+
# Add edit and delete buttons
|
| 1134 |
+
col1, col2 = st.columns(2)
|
| 1135 |
+
|
| 1136 |
+
with col1:
|
| 1137 |
+
door_to_edit = st.selectbox(
|
| 1138 |
+
"Select Door to Edit",
|
| 1139 |
+
options=[f"#{i+1}: {door.name}" for i, door in enumerate(session_state["components"]["doors"])],
|
| 1140 |
+
key="door_to_edit"
|
| 1141 |
+
)
|
| 1142 |
+
|
| 1143 |
+
if st.button("Edit Selected Door", key="edit_door_button"):
|
| 1144 |
+
# Get index of door to edit
|
| 1145 |
+
door_index = int(door_to_edit.split(":")[0][1:]) - 1
|
| 1146 |
+
|
| 1147 |
+
# Set edit flag and index
|
| 1148 |
+
session_state["edit_door"] = True
|
| 1149 |
+
session_state["edit_door_index"] = door_index
|
| 1150 |
+
|
| 1151 |
+
# Set form values from selected door
|
| 1152 |
+
door = session_state["components"]["doors"][door_index]
|
| 1153 |
+
session_state["door_name"] = door.name
|
| 1154 |
+
session_state["door_orientation"] = door.orientation.name
|
| 1155 |
+
session_state["door_area"] = door.area
|
| 1156 |
+
session_state["door_u_value"] = door.u_value
|
| 1157 |
+
session_state["door_type"] = door.door_type
|
| 1158 |
+
|
| 1159 |
+
st.experimental_rerun()
|
| 1160 |
+
|
| 1161 |
+
with col2:
|
| 1162 |
+
door_to_delete = st.selectbox(
|
| 1163 |
+
"Select Door to Delete",
|
| 1164 |
+
options=[f"#{i+1}: {door.name}" for i, door in enumerate(session_state["components"]["doors"])],
|
| 1165 |
+
key="door_to_delete"
|
| 1166 |
+
)
|
| 1167 |
+
|
| 1168 |
+
if st.button("Delete Selected Door", key="delete_door_button"):
|
| 1169 |
+
# Get index of door to delete
|
| 1170 |
+
door_index = int(door_to_delete.split(":")[0][1:]) - 1
|
| 1171 |
+
|
| 1172 |
+
# Remove door from session state
|
| 1173 |
+
session_state["components"]["doors"].pop(door_index)
|
| 1174 |
+
|
| 1175 |
+
st.success(f"Door {door_to_delete} deleted successfully!")
|
| 1176 |
+
st.experimental_rerun()
|
| 1177 |
+
|
| 1178 |
+
# Add new door form
|
| 1179 |
+
st.write("Add New Door:")
|
| 1180 |
+
|
| 1181 |
+
# Initialize edit flag if not exists
|
| 1182 |
+
if "edit_door" not in session_state:
|
| 1183 |
+
session_state["edit_door"] = False
|
| 1184 |
+
session_state["edit_door_index"] = -1
|
| 1185 |
+
|
| 1186 |
+
# Initialize form values if not exists
|
| 1187 |
+
if "door_name" not in session_state:
|
| 1188 |
+
session_state["door_name"] = ""
|
| 1189 |
+
session_state["door_orientation"] = "NORTH"
|
| 1190 |
+
session_state["door_area"] = 0.0
|
| 1191 |
+
session_state["door_u_value"] = 2.0
|
| 1192 |
+
session_state["door_type"] = "Solid Wood"
|
| 1193 |
+
|
| 1194 |
+
# Create form
|
| 1195 |
+
with st.form("door_form"):
|
| 1196 |
+
col1, col2 = st.columns(2)
|
| 1197 |
+
|
| 1198 |
+
with col1:
|
| 1199 |
+
door_name = st.text_input(
|
| 1200 |
+
"Door Name",
|
| 1201 |
+
value=session_state["door_name"],
|
| 1202 |
+
key="door_name_input",
|
| 1203 |
+
help="Enter a descriptive name for the door"
|
| 1204 |
+
)
|
| 1205 |
+
|
| 1206 |
+
door_orientation = st.selectbox(
|
| 1207 |
+
"Orientation",
|
| 1208 |
+
options=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
|
| 1209 |
+
index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["door_orientation"]),
|
| 1210 |
+
key="door_orientation_input",
|
| 1211 |
+
help="Select the orientation of the door"
|
| 1212 |
+
)
|
| 1213 |
+
|
| 1214 |
+
door_area = st.number_input(
|
| 1215 |
+
"Area (m²)",
|
| 1216 |
+
min_value=0.0,
|
| 1217 |
+
value=session_state["door_area"],
|
| 1218 |
+
step=0.1,
|
| 1219 |
+
key="door_area_input",
|
| 1220 |
+
help="Enter the area of the door in square meters"
|
| 1221 |
+
)
|
| 1222 |
+
|
| 1223 |
+
with col2:
|
| 1224 |
+
# Option to select from preset or custom
|
| 1225 |
+
door_selection_method = st.radio(
|
| 1226 |
+
"Door Selection Method",
|
| 1227 |
+
options=["Select from Presets", "Custom U-Value"],
|
| 1228 |
+
key="door_selection_method"
|
| 1229 |
+
)
|
| 1230 |
+
|
| 1231 |
+
if door_selection_method == "Select from Presets":
|
| 1232 |
+
# Get preset door types from component library
|
| 1233 |
+
preset_doors = self.component_library.get_preset_doors()
|
| 1234 |
+
door_types = [door["name"] for door in preset_doors]
|
| 1235 |
+
|
| 1236 |
+
door_type = st.selectbox(
|
| 1237 |
+
"Door Type",
|
| 1238 |
+
options=door_types,
|
| 1239 |
+
index=door_types.index(session_state["door_type"]) if session_state["door_type"] in door_types else 0,
|
| 1240 |
+
key="door_type_input",
|
| 1241 |
+
help="Select the type of door"
|
| 1242 |
+
)
|
| 1243 |
+
|
| 1244 |
+
# Get U-value from selected preset
|
| 1245 |
+
selected_door = next((door for door in preset_doors if door["name"] == door_type), None)
|
| 1246 |
+
if selected_door:
|
| 1247 |
+
door_u_value = selected_door["u_value"]
|
| 1248 |
+
else:
|
| 1249 |
+
door_u_value = session_state["door_u_value"]
|
| 1250 |
+
|
| 1251 |
+
# Display U-value (read-only)
|
| 1252 |
+
st.number_input(
|
| 1253 |
+
"U-Value (W/m²·K)",
|
| 1254 |
+
min_value=0.0,
|
| 1255 |
+
value=door_u_value,
|
| 1256 |
+
step=0.01,
|
| 1257 |
+
key="door_u_value_display",
|
| 1258 |
+
disabled=True,
|
| 1259 |
+
help="U-value of the selected door type"
|
| 1260 |
+
)
|
| 1261 |
+
else:
|
| 1262 |
+
# Custom U-value input
|
| 1263 |
+
door_u_value = st.number_input(
|
| 1264 |
+
"U-Value (W/m²·K)",
|
| 1265 |
+
min_value=0.0,
|
| 1266 |
+
value=session_state["door_u_value"],
|
| 1267 |
+
step=0.01,
|
| 1268 |
+
key="door_u_value_input",
|
| 1269 |
+
help="Enter the U-value of the door"
|
| 1270 |
+
)
|
| 1271 |
+
|
| 1272 |
+
# Set door type to "Custom"
|
| 1273 |
+
door_type = "Custom"
|
| 1274 |
+
|
| 1275 |
+
# Submit button
|
| 1276 |
+
if session_state["edit_door"]:
|
| 1277 |
+
submit_label = "Update Door"
|
| 1278 |
+
else:
|
| 1279 |
+
submit_label = "Add Door"
|
| 1280 |
+
|
| 1281 |
+
submitted = st.form_submit_button(submit_label)
|
| 1282 |
+
|
| 1283 |
+
if submitted:
|
| 1284 |
+
# Validate inputs
|
| 1285 |
+
if not door_name:
|
| 1286 |
+
st.error("Door name is required!")
|
| 1287 |
+
elif door_area <= 0:
|
| 1288 |
+
st.error("Door area must be greater than zero!")
|
| 1289 |
+
elif door_u_value <= 0:
|
| 1290 |
+
st.error("Door U-value must be greater than zero!")
|
| 1291 |
+
else:
|
| 1292 |
+
# Create door object
|
| 1293 |
+
door = Door(
|
| 1294 |
+
id=str(uuid.uuid4()) if not session_state["edit_door"] else session_state["components"]["doors"][session_state["edit_door_index"]].id,
|
| 1295 |
+
name=door_name,
|
| 1296 |
+
component_type=ComponentType.DOOR,
|
| 1297 |
+
u_value=door_u_value,
|
| 1298 |
+
area=door_area,
|
| 1299 |
+
orientation=Orientation[door_orientation],
|
| 1300 |
+
door_type=door_type
|
| 1301 |
+
)
|
| 1302 |
+
|
| 1303 |
+
if session_state["edit_door"]:
|
| 1304 |
+
# Update existing door
|
| 1305 |
+
session_state["components"]["doors"][session_state["edit_door_index"]] = door
|
| 1306 |
+
st.success(f"Door '{door_name}' updated successfully!")
|
| 1307 |
+
|
| 1308 |
+
# Reset edit flag
|
| 1309 |
+
session_state["edit_door"] = False
|
| 1310 |
+
session_state["edit_door_index"] = -1
|
| 1311 |
+
else:
|
| 1312 |
+
# Add new door
|
| 1313 |
+
session_state["components"]["doors"].append(door)
|
| 1314 |
+
st.success(f"Door '{door_name}' added successfully!")
|
| 1315 |
+
|
| 1316 |
+
# Reset form values
|
| 1317 |
+
session_state["door_name"] = ""
|
| 1318 |
+
session_state["door_orientation"] = "NORTH"
|
| 1319 |
+
session_state["door_area"] = 0.0
|
| 1320 |
+
session_state["door_u_value"] = 2.0
|
| 1321 |
+
session_state["door_type"] = "Solid Wood"
|
| 1322 |
+
|
| 1323 |
+
# Save components to file
|
| 1324 |
+
self.save_components(session_state["components"])
|
| 1325 |
+
|
| 1326 |
+
st.experimental_rerun()
|
| 1327 |
+
|
| 1328 |
+
# Add U-value calculator button
|
| 1329 |
+
if st.button("Open U-Value Calculator", key="open_u_value_calc_door"):
|
| 1330 |
+
session_state["show_u_value_calculator"] = True
|
| 1331 |
+
session_state["u_value_calc_component_type"] = "door"
|
| 1332 |
+
st.experimental_rerun()
|
| 1333 |
+
|
| 1334 |
+
def display_u_value_calculator(self, session_state: Dict[str, Any]) -> None:
|
| 1335 |
+
"""
|
| 1336 |
+
Display U-value calculator in Streamlit.
|
| 1337 |
+
|
| 1338 |
+
Args:
|
| 1339 |
+
session_state: Streamlit session state for storing form data
|
| 1340 |
+
"""
|
| 1341 |
+
st.header("U-Value Calculator")
|
| 1342 |
+
|
| 1343 |
+
# Get component type
|
| 1344 |
+
component_type = session_state.get("u_value_calc_component_type", "wall")
|
| 1345 |
+
|
| 1346 |
+
# Display component type
|
| 1347 |
+
st.write(f"Calculating U-value for: **{component_type.title()}**")
|
| 1348 |
+
|
| 1349 |
+
# Initialize material layers if not exists
|
| 1350 |
+
if "material_layers" not in session_state:
|
| 1351 |
+
session_state["material_layers"] = []
|
| 1352 |
+
|
| 1353 |
+
# Display existing layers
|
| 1354 |
+
if session_state["material_layers"]:
|
| 1355 |
+
st.write("Current Material Layers (from outside to inside):")
|
| 1356 |
+
|
| 1357 |
+
# Create a DataFrame for display
|
| 1358 |
+
layer_data = []
|
| 1359 |
+
for i, layer in enumerate(session_state["material_layers"]):
|
| 1360 |
+
layer_data.append({
|
| 1361 |
+
"Layer": i + 1,
|
| 1362 |
+
"Material": layer["name"],
|
| 1363 |
+
"Thickness (mm)": layer["thickness"],
|
| 1364 |
+
"Conductivity (W/m·K)": layer["conductivity"],
|
| 1365 |
+
"R-Value (m²·K/W)": layer["thickness"] / 1000 / layer["conductivity"]
|
| 1366 |
+
})
|
| 1367 |
+
|
| 1368 |
+
layer_df = pd.DataFrame(layer_data)
|
| 1369 |
+
st.dataframe(layer_df, use_container_width=True)
|
| 1370 |
+
|
| 1371 |
+
# Calculate total R-value and U-value
|
| 1372 |
+
r_outside = 0.04 # m²·K/W (outside surface resistance)
|
| 1373 |
+
r_inside = 0.13 # m²·K/W (inside surface resistance)
|
| 1374 |
+
|
| 1375 |
+
r_layers = sum([layer["thickness"] / 1000 / layer["conductivity"] for layer in session_state["material_layers"]])
|
| 1376 |
+
r_total = r_outside + r_layers + r_inside
|
| 1377 |
+
u_value = 1 / r_total
|
| 1378 |
+
|
| 1379 |
+
# Display results
|
| 1380 |
+
col1, col2, col3 = st.columns(3)
|
| 1381 |
+
|
| 1382 |
+
with col1:
|
| 1383 |
+
st.metric("Total R-Value", f"{r_total:.3f} m²·K/W")
|
| 1384 |
+
|
| 1385 |
+
with col2:
|
| 1386 |
+
st.metric("U-Value", f"{u_value:.3f} W/m²·K")
|
| 1387 |
+
|
| 1388 |
+
with col3:
|
| 1389 |
+
if st.button("Use This U-Value"):
|
| 1390 |
+
# Set U-value in the appropriate form
|
| 1391 |
+
if component_type == "wall":
|
| 1392 |
+
session_state["wall_u_value"] = u_value
|
| 1393 |
+
elif component_type == "roof":
|
| 1394 |
+
session_state["roof_u_value"] = u_value
|
| 1395 |
+
elif component_type == "floor":
|
| 1396 |
+
session_state["floor_u_value"] = u_value
|
| 1397 |
+
elif component_type == "door":
|
| 1398 |
+
session_state["door_u_value"] = u_value
|
| 1399 |
+
|
| 1400 |
+
# Close calculator
|
| 1401 |
+
session_state["show_u_value_calculator"] = False
|
| 1402 |
+
st.success(f"U-value set to {u_value:.3f} W/m²·K")
|
| 1403 |
+
st.experimental_rerun()
|
| 1404 |
+
|
| 1405 |
+
# Add delete button
|
| 1406 |
+
if st.button("Remove Last Layer"):
|
| 1407 |
+
if session_state["material_layers"]:
|
| 1408 |
+
session_state["material_layers"].pop()
|
| 1409 |
+
st.experimental_rerun()
|
| 1410 |
+
|
| 1411 |
+
# Add new layer form
|
| 1412 |
+
st.write("Add New Material Layer:")
|
| 1413 |
+
|
| 1414 |
+
with st.form("material_layer_form"):
|
| 1415 |
+
col1, col2 = st.columns(2)
|
| 1416 |
+
|
| 1417 |
+
with col1:
|
| 1418 |
+
# Option to select from preset or custom
|
| 1419 |
+
material_selection_method = st.radio(
|
| 1420 |
+
"Material Selection Method",
|
| 1421 |
+
options=["Select from Presets", "Custom Material"],
|
| 1422 |
+
key="material_selection_method"
|
| 1423 |
+
)
|
| 1424 |
+
|
| 1425 |
+
if material_selection_method == "Select from Presets":
|
| 1426 |
+
# Get preset materials from U-value calculator
|
| 1427 |
+
preset_materials = self.u_value_calculator.get_preset_materials()
|
| 1428 |
+
material_names = [material["name"] for material in preset_materials]
|
| 1429 |
+
|
| 1430 |
+
material_name = st.selectbox(
|
| 1431 |
+
"Material",
|
| 1432 |
+
options=material_names,
|
| 1433 |
+
key="material_name_input",
|
| 1434 |
+
help="Select a material from the presets"
|
| 1435 |
+
)
|
| 1436 |
+
|
| 1437 |
+
# Get conductivity from selected preset
|
| 1438 |
+
selected_material = next((material for material in preset_materials if material["name"] == material_name), None)
|
| 1439 |
+
if selected_material:
|
| 1440 |
+
material_conductivity = selected_material["conductivity"]
|
| 1441 |
+
else:
|
| 1442 |
+
material_conductivity = 1.0
|
| 1443 |
+
|
| 1444 |
+
# Display conductivity (read-only)
|
| 1445 |
+
st.number_input(
|
| 1446 |
+
"Thermal Conductivity (W/m·K)",
|
| 1447 |
+
min_value=0.0,
|
| 1448 |
+
value=material_conductivity,
|
| 1449 |
+
step=0.01,
|
| 1450 |
+
key="material_conductivity_display",
|
| 1451 |
+
disabled=True,
|
| 1452 |
+
help="Thermal conductivity of the selected material"
|
| 1453 |
+
)
|
| 1454 |
+
else:
|
| 1455 |
+
# Custom material input
|
| 1456 |
+
material_name = st.text_input(
|
| 1457 |
+
"Material Name",
|
| 1458 |
+
key="custom_material_name_input",
|
| 1459 |
+
help="Enter a name for the custom material"
|
| 1460 |
+
)
|
| 1461 |
+
|
| 1462 |
+
material_conductivity = st.number_input(
|
| 1463 |
+
"Thermal Conductivity (W/m·K)",
|
| 1464 |
+
min_value=0.0,
|
| 1465 |
+
value=1.0,
|
| 1466 |
+
step=0.01,
|
| 1467 |
+
key="material_conductivity_input",
|
| 1468 |
+
help="Enter the thermal conductivity of the material"
|
| 1469 |
+
)
|
| 1470 |
+
|
| 1471 |
+
with col2:
|
| 1472 |
+
material_thickness = st.number_input(
|
| 1473 |
+
"Thickness (mm)",
|
| 1474 |
+
min_value=0.0,
|
| 1475 |
+
value=100.0,
|
| 1476 |
+
step=1.0,
|
| 1477 |
+
key="material_thickness_input",
|
| 1478 |
+
help="Enter the thickness of the material layer in millimeters"
|
| 1479 |
+
)
|
| 1480 |
+
|
| 1481 |
+
# Calculate and display R-value
|
| 1482 |
+
if material_conductivity > 0:
|
| 1483 |
+
r_value = material_thickness / 1000 / material_conductivity
|
| 1484 |
+
st.metric("Layer R-Value", f"{r_value:.3f} m²·K/W")
|
| 1485 |
+
|
| 1486 |
+
# Submit button
|
| 1487 |
+
submitted = st.form_submit_button("Add Layer")
|
| 1488 |
+
|
| 1489 |
+
if submitted:
|
| 1490 |
+
# Validate inputs
|
| 1491 |
+
if not material_name:
|
| 1492 |
+
st.error("Material name is required!")
|
| 1493 |
+
elif material_thickness <= 0:
|
| 1494 |
+
st.error("Material thickness must be greater than zero!")
|
| 1495 |
+
elif material_conductivity <= 0:
|
| 1496 |
+
st.error("Material conductivity must be greater than zero!")
|
| 1497 |
+
else:
|
| 1498 |
+
# Add material layer
|
| 1499 |
+
session_state["material_layers"].append({
|
| 1500 |
+
"name": material_name,
|
| 1501 |
+
"thickness": material_thickness,
|
| 1502 |
+
"conductivity": material_conductivity
|
| 1503 |
+
})
|
| 1504 |
+
|
| 1505 |
+
st.success(f"Material layer '{material_name}' added successfully!")
|
| 1506 |
+
st.experimental_rerun()
|
| 1507 |
+
|
| 1508 |
+
# Add close button
|
| 1509 |
+
if st.button("Close Calculator"):
|
| 1510 |
+
session_state["show_u_value_calculator"] = False
|
| 1511 |
+
session_state["material_layers"] = []
|
| 1512 |
+
st.experimental_rerun()
|
| 1513 |
+
|
| 1514 |
+
def save_components(self, components: Dict[str, List[Any]], file_path: str = "components.json") -> None:
|
| 1515 |
+
"""
|
| 1516 |
+
Save components to a JSON file.
|
| 1517 |
+
|
| 1518 |
+
Args:
|
| 1519 |
+
components: Dictionary with building components
|
| 1520 |
+
file_path: Path to save the JSON file
|
| 1521 |
+
"""
|
| 1522 |
+
try:
|
| 1523 |
+
# Convert components to serializable format
|
| 1524 |
+
serializable_components = {
|
| 1525 |
+
"walls": [wall.__dict__ for wall in components["walls"]],
|
| 1526 |
+
"roofs": [roof.__dict__ for roof in components["roofs"]],
|
| 1527 |
+
"floors": [floor.__dict__ for floor in components["floors"]],
|
| 1528 |
+
"windows": [window.__dict__ for window in components["windows"]],
|
| 1529 |
+
"doors": [door.__dict__ for door in components["doors"]]
|
| 1530 |
+
}
|
| 1531 |
+
|
| 1532 |
+
# Convert Orientation enum to string
|
| 1533 |
+
for component_type in serializable_components:
|
| 1534 |
+
for component in serializable_components[component_type]:
|
| 1535 |
+
if "orientation" in component and hasattr(component["orientation"], "name"):
|
| 1536 |
+
component["orientation"] = component["orientation"].name
|
| 1537 |
+
if "component_type" in component and hasattr(component["component_type"], "name"):
|
| 1538 |
+
component["component_type"] = component["component_type"].name
|
| 1539 |
+
|
| 1540 |
+
with open(file_path, "w") as f:
|
| 1541 |
+
json.dump(serializable_components, f, indent=4)
|
| 1542 |
+
except Exception as e:
|
| 1543 |
+
st.error(f"Error saving components: {e}")
|
| 1544 |
+
|
| 1545 |
+
def load_components(self, file_path: str = "components.json") -> Dict[str, List[Any]]:
|
| 1546 |
+
"""
|
| 1547 |
+
Load components from a JSON file.
|
| 1548 |
+
|
| 1549 |
+
Args:
|
| 1550 |
+
file_path: Path to the JSON file
|
| 1551 |
+
|
| 1552 |
+
Returns:
|
| 1553 |
+
Dictionary with building components
|
| 1554 |
+
"""
|
| 1555 |
+
if os.path.exists(file_path):
|
| 1556 |
+
try:
|
| 1557 |
+
with open(file_path, "r") as f:
|
| 1558 |
+
serialized_components = json.load(f)
|
| 1559 |
+
|
| 1560 |
+
# Convert serialized components back to objects
|
| 1561 |
+
components = {
|
| 1562 |
+
"walls": [],
|
| 1563 |
+
"roofs": [],
|
| 1564 |
+
"floors": [],
|
| 1565 |
+
"windows": [],
|
| 1566 |
+
"doors": []
|
| 1567 |
+
}
|
| 1568 |
+
|
| 1569 |
+
# Convert walls
|
| 1570 |
+
for wall_dict in serialized_components.get("walls", []):
|
| 1571 |
+
wall = Wall(
|
| 1572 |
+
id=wall_dict.get("id", str(uuid.uuid4())),
|
| 1573 |
+
name=wall_dict.get("name", ""),
|
| 1574 |
+
component_type=ComponentType[wall_dict.get("component_type", "WALL")],
|
| 1575 |
+
u_value=wall_dict.get("u_value", 0.0),
|
| 1576 |
+
area=wall_dict.get("area", 0.0),
|
| 1577 |
+
orientation=Orientation[wall_dict.get("orientation", "NORTH")],
|
| 1578 |
+
wall_type=wall_dict.get("wall_type", ""),
|
| 1579 |
+
wall_group=wall_dict.get("wall_group", "")
|
| 1580 |
+
)
|
| 1581 |
+
components["walls"].append(wall)
|
| 1582 |
+
|
| 1583 |
+
# Convert roofs
|
| 1584 |
+
for roof_dict in serialized_components.get("roofs", []):
|
| 1585 |
+
roof = Roof(
|
| 1586 |
+
id=roof_dict.get("id", str(uuid.uuid4())),
|
| 1587 |
+
name=roof_dict.get("name", ""),
|
| 1588 |
+
component_type=ComponentType[roof_dict.get("component_type", "ROOF")],
|
| 1589 |
+
u_value=roof_dict.get("u_value", 0.0),
|
| 1590 |
+
area=roof_dict.get("area", 0.0),
|
| 1591 |
+
orientation=Orientation[roof_dict.get("orientation", "HORIZONTAL")],
|
| 1592 |
+
roof_type=roof_dict.get("roof_type", ""),
|
| 1593 |
+
roof_group=roof_dict.get("roof_group", "")
|
| 1594 |
+
)
|
| 1595 |
+
components["roofs"].append(roof)
|
| 1596 |
+
|
| 1597 |
+
# Convert floors
|
| 1598 |
+
for floor_dict in serialized_components.get("floors", []):
|
| 1599 |
+
floor = Floor(
|
| 1600 |
+
id=floor_dict.get("id", str(uuid.uuid4())),
|
| 1601 |
+
name=floor_dict.get("name", ""),
|
| 1602 |
+
component_type=ComponentType[floor_dict.get("component_type", "FLOOR")],
|
| 1603 |
+
u_value=floor_dict.get("u_value", 0.0),
|
| 1604 |
+
area=floor_dict.get("area", 0.0),
|
| 1605 |
+
floor_type=floor_dict.get("floor_type", "")
|
| 1606 |
+
)
|
| 1607 |
+
components["floors"].append(floor)
|
| 1608 |
+
|
| 1609 |
+
# Convert windows
|
| 1610 |
+
for window_dict in serialized_components.get("windows", []):
|
| 1611 |
+
window = Window(
|
| 1612 |
+
id=window_dict.get("id", str(uuid.uuid4())),
|
| 1613 |
+
name=window_dict.get("name", ""),
|
| 1614 |
+
component_type=ComponentType[window_dict.get("component_type", "WINDOW")],
|
| 1615 |
+
u_value=window_dict.get("u_value", 0.0),
|
| 1616 |
+
area=window_dict.get("area", 0.0),
|
| 1617 |
+
orientation=Orientation[window_dict.get("orientation", "NORTH")],
|
| 1618 |
+
shgc=window_dict.get("shgc", 0.0),
|
| 1619 |
+
vt=window_dict.get("vt", 0.0),
|
| 1620 |
+
window_type=window_dict.get("window_type", ""),
|
| 1621 |
+
glazing_layers=window_dict.get("glazing_layers", 1),
|
| 1622 |
+
gas_fill=window_dict.get("gas_fill", ""),
|
| 1623 |
+
low_e_coating=window_dict.get("low_e_coating", False)
|
| 1624 |
+
)
|
| 1625 |
+
components["windows"].append(window)
|
| 1626 |
+
|
| 1627 |
+
# Convert doors
|
| 1628 |
+
for door_dict in serialized_components.get("doors", []):
|
| 1629 |
+
door = Door(
|
| 1630 |
+
id=door_dict.get("id", str(uuid.uuid4())),
|
| 1631 |
+
name=door_dict.get("name", ""),
|
| 1632 |
+
component_type=ComponentType[door_dict.get("component_type", "DOOR")],
|
| 1633 |
+
u_value=door_dict.get("u_value", 0.0),
|
| 1634 |
+
area=door_dict.get("area", 0.0),
|
| 1635 |
+
orientation=Orientation[door_dict.get("orientation", "NORTH")],
|
| 1636 |
+
door_type=door_dict.get("door_type", "")
|
| 1637 |
+
)
|
| 1638 |
+
components["doors"].append(door)
|
| 1639 |
+
|
| 1640 |
+
return components
|
| 1641 |
+
except Exception as e:
|
| 1642 |
+
st.error(f"Error loading components: {e}")
|
| 1643 |
+
|
| 1644 |
+
# Return empty components if file doesn't exist or error occurs
|
| 1645 |
+
return {
|
| 1646 |
+
"walls": [],
|
| 1647 |
+
"roofs": [],
|
| 1648 |
+
"floors": [],
|
| 1649 |
+
"windows": [],
|
| 1650 |
+
"doors": []
|
| 1651 |
+
}
|
| 1652 |
+
|
| 1653 |
+
|
| 1654 |
+
# Create a singleton instance
|
| 1655 |
+
component_selection = ComponentSelectionInterface()
|
| 1656 |
+
|
| 1657 |
+
# Example usage
|
| 1658 |
+
if __name__ == "__main__":
|
| 1659 |
+
import streamlit as st
|
| 1660 |
+
|
| 1661 |
+
# Initialize session state
|
| 1662 |
+
if "components" not in st.session_state:
|
| 1663 |
+
st.session_state["components"] = component_selection.load_components()
|
| 1664 |
+
|
| 1665 |
+
# Check if U-value calculator should be shown
|
| 1666 |
+
if "show_u_value_calculator" not in st.session_state:
|
| 1667 |
+
st.session_state["show_u_value_calculator"] = False
|
| 1668 |
+
|
| 1669 |
+
if st.session_state["show_u_value_calculator"]:
|
| 1670 |
+
# Display U-value calculator
|
| 1671 |
+
component_selection.display_u_value_calculator(st.session_state)
|
| 1672 |
+
else:
|
| 1673 |
+
# Display component selection interface
|
| 1674 |
+
component_selection.display_component_selection(st.session_state)
|
app/data_export.py
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data export module for HVAC Load Calculator.
|
| 3 |
+
This module provides functionality for exporting calculation results.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import base64
|
| 13 |
+
import io
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
import xlsxwriter
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class DataExport:
|
| 19 |
+
"""Class for data export functionality."""
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def export_to_csv(data: Dict[str, Any], file_path: str = None) -> Optional[str]:
|
| 23 |
+
"""
|
| 24 |
+
Export data to CSV format.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
data: Dictionary with data to export
|
| 28 |
+
file_path: Optional path to save the CSV file
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
CSV string if file_path is None, otherwise None
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
# Create DataFrame from data
|
| 35 |
+
df = pd.DataFrame(data)
|
| 36 |
+
|
| 37 |
+
# Convert to CSV
|
| 38 |
+
csv_data = df.to_csv(index=False)
|
| 39 |
+
|
| 40 |
+
# Save to file if path provided
|
| 41 |
+
if file_path:
|
| 42 |
+
df.to_csv(file_path, index=False)
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
# Return CSV string if no path provided
|
| 46 |
+
return csv_data
|
| 47 |
+
|
| 48 |
+
except Exception as e:
|
| 49 |
+
st.error(f"Error exporting to CSV: {e}")
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
@staticmethod
|
| 53 |
+
def export_to_excel(data_dict: Dict[str, pd.DataFrame], file_path: str = None) -> Optional[bytes]:
|
| 54 |
+
"""
|
| 55 |
+
Export data to Excel format.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
data_dict: Dictionary with sheet names and DataFrames
|
| 59 |
+
file_path: Optional path to save the Excel file
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Excel bytes if file_path is None, otherwise None
|
| 63 |
+
"""
|
| 64 |
+
try:
|
| 65 |
+
# Create Excel file in memory or on disk
|
| 66 |
+
if file_path:
|
| 67 |
+
writer = pd.ExcelWriter(file_path, engine='xlsxwriter')
|
| 68 |
+
else:
|
| 69 |
+
output = io.BytesIO()
|
| 70 |
+
writer = pd.ExcelWriter(output, engine='xlsxwriter')
|
| 71 |
+
|
| 72 |
+
# Write each DataFrame to a different sheet
|
| 73 |
+
for sheet_name, df in data_dict.items():
|
| 74 |
+
df.to_excel(writer, sheet_name=sheet_name, index=False)
|
| 75 |
+
|
| 76 |
+
# Auto-adjust column widths
|
| 77 |
+
worksheet = writer.sheets[sheet_name]
|
| 78 |
+
for i, col in enumerate(df.columns):
|
| 79 |
+
max_width = max(
|
| 80 |
+
df[col].astype(str).map(len).max(),
|
| 81 |
+
len(col)
|
| 82 |
+
) + 2
|
| 83 |
+
worksheet.set_column(i, i, max_width)
|
| 84 |
+
|
| 85 |
+
# Save the Excel file
|
| 86 |
+
writer.close()
|
| 87 |
+
|
| 88 |
+
# Return Excel bytes if no path provided
|
| 89 |
+
if not file_path:
|
| 90 |
+
output.seek(0)
|
| 91 |
+
return output.getvalue()
|
| 92 |
+
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
st.error(f"Error exporting to Excel: {e}")
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
@staticmethod
|
| 100 |
+
def export_scenario_to_json(scenario: Dict[str, Any], file_path: str = None) -> Optional[str]:
|
| 101 |
+
"""
|
| 102 |
+
Export scenario data to JSON format.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
scenario: Dictionary with scenario data
|
| 106 |
+
file_path: Optional path to save the JSON file
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
JSON string if file_path is None, otherwise None
|
| 110 |
+
"""
|
| 111 |
+
try:
|
| 112 |
+
# Convert to JSON
|
| 113 |
+
json_data = json.dumps(scenario, indent=4)
|
| 114 |
+
|
| 115 |
+
# Save to file if path provided
|
| 116 |
+
if file_path:
|
| 117 |
+
with open(file_path, "w") as f:
|
| 118 |
+
f.write(json_data)
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
# Return JSON string if no path provided
|
| 122 |
+
return json_data
|
| 123 |
+
|
| 124 |
+
except Exception as e:
|
| 125 |
+
st.error(f"Error exporting scenario to JSON: {e}")
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def get_download_link(data: Any, filename: str, text: str, mime_type: str = "text/csv") -> str:
|
| 130 |
+
"""
|
| 131 |
+
Generate a download link for data.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
data: Data to download
|
| 135 |
+
filename: Name of the file to download
|
| 136 |
+
text: Text to display for the download link
|
| 137 |
+
mime_type: MIME type of the file
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
HTML string with download link
|
| 141 |
+
"""
|
| 142 |
+
if isinstance(data, str):
|
| 143 |
+
b64 = base64.b64encode(data.encode()).decode()
|
| 144 |
+
else:
|
| 145 |
+
b64 = base64.b64encode(data).decode()
|
| 146 |
+
|
| 147 |
+
href = f'<a href="data:{mime_type};base64,{b64}" download="{filename}">{text}</a>'
|
| 148 |
+
return href
|
| 149 |
+
|
| 150 |
+
@staticmethod
|
| 151 |
+
def create_cooling_load_dataframes(results: Dict[str, Any]) -> Dict[str, pd.DataFrame]:
|
| 152 |
+
"""
|
| 153 |
+
Create DataFrames for cooling load results.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
results: Dictionary with calculation results
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
Dictionary with DataFrames for Excel export
|
| 160 |
+
"""
|
| 161 |
+
dataframes = {}
|
| 162 |
+
|
| 163 |
+
# Create summary DataFrame
|
| 164 |
+
summary_data = {
|
| 165 |
+
"Metric": [
|
| 166 |
+
"Total Cooling Load",
|
| 167 |
+
"Sensible Cooling Load",
|
| 168 |
+
"Latent Cooling Load",
|
| 169 |
+
"Cooling Load per Area"
|
| 170 |
+
],
|
| 171 |
+
"Value": [
|
| 172 |
+
results["cooling"]["total_load"],
|
| 173 |
+
results["cooling"]["sensible_load"],
|
| 174 |
+
results["cooling"]["latent_load"],
|
| 175 |
+
results["cooling"]["load_per_area"]
|
| 176 |
+
],
|
| 177 |
+
"Unit": [
|
| 178 |
+
"kW",
|
| 179 |
+
"kW",
|
| 180 |
+
"kW",
|
| 181 |
+
"W/m²"
|
| 182 |
+
]
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
dataframes["Cooling Summary"] = pd.DataFrame(summary_data)
|
| 186 |
+
|
| 187 |
+
# Create component breakdown DataFrame
|
| 188 |
+
component_data = {
|
| 189 |
+
"Component": [
|
| 190 |
+
"Walls",
|
| 191 |
+
"Roof",
|
| 192 |
+
"Windows",
|
| 193 |
+
"Doors",
|
| 194 |
+
"People",
|
| 195 |
+
"Lighting",
|
| 196 |
+
"Equipment",
|
| 197 |
+
"Infiltration",
|
| 198 |
+
"Ventilation"
|
| 199 |
+
],
|
| 200 |
+
"Load (kW)": [
|
| 201 |
+
results["cooling"]["component_loads"]["walls"],
|
| 202 |
+
results["cooling"]["component_loads"]["roof"],
|
| 203 |
+
results["cooling"]["component_loads"]["windows"],
|
| 204 |
+
results["cooling"]["component_loads"]["doors"],
|
| 205 |
+
results["cooling"]["component_loads"]["people"],
|
| 206 |
+
results["cooling"]["component_loads"]["lighting"],
|
| 207 |
+
results["cooling"]["component_loads"]["equipment"],
|
| 208 |
+
results["cooling"]["component_loads"]["infiltration"],
|
| 209 |
+
results["cooling"]["component_loads"]["ventilation"]
|
| 210 |
+
],
|
| 211 |
+
"Percentage (%)": [
|
| 212 |
+
results["cooling"]["component_loads"]["walls"] / results["cooling"]["total_load"] * 100,
|
| 213 |
+
results["cooling"]["component_loads"]["roof"] / results["cooling"]["total_load"] * 100,
|
| 214 |
+
results["cooling"]["component_loads"]["windows"] / results["cooling"]["total_load"] * 100,
|
| 215 |
+
results["cooling"]["component_loads"]["doors"] / results["cooling"]["total_load"] * 100,
|
| 216 |
+
results["cooling"]["component_loads"]["people"] / results["cooling"]["total_load"] * 100,
|
| 217 |
+
results["cooling"]["component_loads"]["lighting"] / results["cooling"]["total_load"] * 100,
|
| 218 |
+
results["cooling"]["component_loads"]["equipment"] / results["cooling"]["total_load"] * 100,
|
| 219 |
+
results["cooling"]["component_loads"]["infiltration"] / results["cooling"]["total_load"] * 100,
|
| 220 |
+
results["cooling"]["component_loads"]["ventilation"] / results["cooling"]["total_load"] * 100
|
| 221 |
+
]
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
dataframes["Cooling Components"] = pd.DataFrame(component_data)
|
| 225 |
+
|
| 226 |
+
# Create detailed loads DataFrames
|
| 227 |
+
|
| 228 |
+
# Walls
|
| 229 |
+
wall_data = []
|
| 230 |
+
for wall in results["cooling"]["detailed_loads"]["walls"]:
|
| 231 |
+
wall_data.append({
|
| 232 |
+
"Name": wall["name"],
|
| 233 |
+
"Orientation": wall["orientation"],
|
| 234 |
+
"Area (m²)": wall["area"],
|
| 235 |
+
"U-Value (W/m²·K)": wall["u_value"],
|
| 236 |
+
"CLTD (°C)": wall["cltd"],
|
| 237 |
+
"Load (kW)": wall["load"]
|
| 238 |
+
})
|
| 239 |
+
|
| 240 |
+
if wall_data:
|
| 241 |
+
dataframes["Cooling Walls"] = pd.DataFrame(wall_data)
|
| 242 |
+
|
| 243 |
+
# Roofs
|
| 244 |
+
roof_data = []
|
| 245 |
+
for roof in results["cooling"]["detailed_loads"]["roofs"]:
|
| 246 |
+
roof_data.append({
|
| 247 |
+
"Name": roof["name"],
|
| 248 |
+
"Orientation": roof["orientation"],
|
| 249 |
+
"Area (m²)": roof["area"],
|
| 250 |
+
"U-Value (W/m²·K)": roof["u_value"],
|
| 251 |
+
"CLTD (°C)": roof["cltd"],
|
| 252 |
+
"Load (kW)": roof["load"]
|
| 253 |
+
})
|
| 254 |
+
|
| 255 |
+
if roof_data:
|
| 256 |
+
dataframes["Cooling Roofs"] = pd.DataFrame(roof_data)
|
| 257 |
+
|
| 258 |
+
# Windows
|
| 259 |
+
window_data = []
|
| 260 |
+
for window in results["cooling"]["detailed_loads"]["windows"]:
|
| 261 |
+
window_data.append({
|
| 262 |
+
"Name": window["name"],
|
| 263 |
+
"Orientation": window["orientation"],
|
| 264 |
+
"Area (m²)": window["area"],
|
| 265 |
+
"U-Value (W/m²·K)": window["u_value"],
|
| 266 |
+
"SHGC": window["shgc"],
|
| 267 |
+
"SCL (W/m²)": window["scl"],
|
| 268 |
+
"Load (kW)": window["load"]
|
| 269 |
+
})
|
| 270 |
+
|
| 271 |
+
if window_data:
|
| 272 |
+
dataframes["Cooling Windows"] = pd.DataFrame(window_data)
|
| 273 |
+
|
| 274 |
+
# Doors
|
| 275 |
+
door_data = []
|
| 276 |
+
for door in results["cooling"]["detailed_loads"]["doors"]:
|
| 277 |
+
door_data.append({
|
| 278 |
+
"Name": door["name"],
|
| 279 |
+
"Orientation": door["orientation"],
|
| 280 |
+
"Area (m²)": door["area"],
|
| 281 |
+
"U-Value (W/m²·K)": door["u_value"],
|
| 282 |
+
"CLTD (°C)": door["cltd"],
|
| 283 |
+
"Load (kW)": door["load"]
|
| 284 |
+
})
|
| 285 |
+
|
| 286 |
+
if door_data:
|
| 287 |
+
dataframes["Cooling Doors"] = pd.DataFrame(door_data)
|
| 288 |
+
|
| 289 |
+
# Internal loads
|
| 290 |
+
internal_data = []
|
| 291 |
+
for internal_load in results["cooling"]["detailed_loads"]["internal"]:
|
| 292 |
+
internal_data.append({
|
| 293 |
+
"Type": internal_load["type"],
|
| 294 |
+
"Name": internal_load["name"],
|
| 295 |
+
"Quantity": internal_load["quantity"],
|
| 296 |
+
"Heat Gain (W)": internal_load["heat_gain"],
|
| 297 |
+
"CLF": internal_load["clf"],
|
| 298 |
+
"Load (kW)": internal_load["load"]
|
| 299 |
+
})
|
| 300 |
+
|
| 301 |
+
if internal_data:
|
| 302 |
+
dataframes["Cooling Internal Loads"] = pd.DataFrame(internal_data)
|
| 303 |
+
|
| 304 |
+
# Infiltration and ventilation
|
| 305 |
+
air_data = [
|
| 306 |
+
{
|
| 307 |
+
"Type": "Infiltration",
|
| 308 |
+
"Air Flow (m³/s)": results["cooling"]["detailed_loads"]["infiltration"]["air_flow"],
|
| 309 |
+
"Sensible Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["sensible_load"],
|
| 310 |
+
"Latent Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["latent_load"],
|
| 311 |
+
"Total Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["total_load"]
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
"Type": "Ventilation",
|
| 315 |
+
"Air Flow (m³/s)": results["cooling"]["detailed_loads"]["ventilation"]["air_flow"],
|
| 316 |
+
"Sensible Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["sensible_load"],
|
| 317 |
+
"Latent Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["latent_load"],
|
| 318 |
+
"Total Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["total_load"]
|
| 319 |
+
}
|
| 320 |
+
]
|
| 321 |
+
|
| 322 |
+
dataframes["Cooling Air Exchange"] = pd.DataFrame(air_data)
|
| 323 |
+
|
| 324 |
+
return dataframes
|
| 325 |
+
|
| 326 |
+
@staticmethod
|
| 327 |
+
def create_heating_load_dataframes(results: Dict[str, Any]) -> Dict[str, pd.DataFrame]:
|
| 328 |
+
"""
|
| 329 |
+
Create DataFrames for heating load results.
|
| 330 |
+
|
| 331 |
+
Args:
|
| 332 |
+
results: Dictionary with calculation results
|
| 333 |
+
|
| 334 |
+
Returns:
|
| 335 |
+
Dictionary with DataFrames for Excel export
|
| 336 |
+
"""
|
| 337 |
+
dataframes = {}
|
| 338 |
+
|
| 339 |
+
# Create summary DataFrame
|
| 340 |
+
summary_data = {
|
| 341 |
+
"Metric": [
|
| 342 |
+
"Total Heating Load",
|
| 343 |
+
"Heating Load per Area",
|
| 344 |
+
"Design Heat Loss",
|
| 345 |
+
"Safety Factor"
|
| 346 |
+
],
|
| 347 |
+
"Value": [
|
| 348 |
+
results["heating"]["total_load"],
|
| 349 |
+
results["heating"]["load_per_area"],
|
| 350 |
+
results["heating"]["design_heat_loss"],
|
| 351 |
+
results["heating"]["safety_factor"]
|
| 352 |
+
],
|
| 353 |
+
"Unit": [
|
| 354 |
+
"kW",
|
| 355 |
+
"W/m²",
|
| 356 |
+
"kW",
|
| 357 |
+
"%"
|
| 358 |
+
]
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
dataframes["Heating Summary"] = pd.DataFrame(summary_data)
|
| 362 |
+
|
| 363 |
+
# Create component breakdown DataFrame
|
| 364 |
+
component_data = {
|
| 365 |
+
"Component": [
|
| 366 |
+
"Walls",
|
| 367 |
+
"Roof",
|
| 368 |
+
"Floor",
|
| 369 |
+
"Windows",
|
| 370 |
+
"Doors",
|
| 371 |
+
"Infiltration",
|
| 372 |
+
"Ventilation"
|
| 373 |
+
],
|
| 374 |
+
"Load (kW)": [
|
| 375 |
+
results["heating"]["component_loads"]["walls"],
|
| 376 |
+
results["heating"]["component_loads"]["roof"],
|
| 377 |
+
results["heating"]["component_loads"]["floor"],
|
| 378 |
+
results["heating"]["component_loads"]["windows"],
|
| 379 |
+
results["heating"]["component_loads"]["doors"],
|
| 380 |
+
results["heating"]["component_loads"]["infiltration"],
|
| 381 |
+
results["heating"]["component_loads"]["ventilation"]
|
| 382 |
+
],
|
| 383 |
+
"Percentage (%)": [
|
| 384 |
+
results["heating"]["component_loads"]["walls"] / results["heating"]["total_load"] * 100,
|
| 385 |
+
results["heating"]["component_loads"]["roof"] / results["heating"]["total_load"] * 100,
|
| 386 |
+
results["heating"]["component_loads"]["floor"] / results["heating"]["total_load"] * 100,
|
| 387 |
+
results["heating"]["component_loads"]["windows"] / results["heating"]["total_load"] * 100,
|
| 388 |
+
results["heating"]["component_loads"]["doors"] / results["heating"]["total_load"] * 100,
|
| 389 |
+
results["heating"]["component_loads"]["infiltration"] / results["heating"]["total_load"] * 100,
|
| 390 |
+
results["heating"]["component_loads"]["ventilation"] / results["heating"]["total_load"] * 100
|
| 391 |
+
]
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
dataframes["Heating Components"] = pd.DataFrame(component_data)
|
| 395 |
+
|
| 396 |
+
# Create detailed loads DataFrames
|
| 397 |
+
|
| 398 |
+
# Walls
|
| 399 |
+
wall_data = []
|
| 400 |
+
for wall in results["heating"]["detailed_loads"]["walls"]:
|
| 401 |
+
wall_data.append({
|
| 402 |
+
"Name": wall["name"],
|
| 403 |
+
"Orientation": wall["orientation"],
|
| 404 |
+
"Area (m²)": wall["area"],
|
| 405 |
+
"U-Value (W/m²·K)": wall["u_value"],
|
| 406 |
+
"Temperature Difference (°C)": wall["delta_t"],
|
| 407 |
+
"Load (kW)": wall["load"]
|
| 408 |
+
})
|
| 409 |
+
|
| 410 |
+
if wall_data:
|
| 411 |
+
dataframes["Heating Walls"] = pd.DataFrame(wall_data)
|
| 412 |
+
|
| 413 |
+
# Roofs
|
| 414 |
+
roof_data = []
|
| 415 |
+
for roof in results["heating"]["detailed_loads"]["roofs"]:
|
| 416 |
+
roof_data.append({
|
| 417 |
+
"Name": roof["name"],
|
| 418 |
+
"Orientation": roof["orientation"],
|
| 419 |
+
"Area (m²)": roof["area"],
|
| 420 |
+
"U-Value (W/m²·K)": roof["u_value"],
|
| 421 |
+
"Temperature Difference (°C)": roof["delta_t"],
|
| 422 |
+
"Load (kW)": roof["load"]
|
| 423 |
+
})
|
| 424 |
+
|
| 425 |
+
if roof_data:
|
| 426 |
+
dataframes["Heating Roofs"] = pd.DataFrame(roof_data)
|
| 427 |
+
|
| 428 |
+
# Floors
|
| 429 |
+
floor_data = []
|
| 430 |
+
for floor in results["heating"]["detailed_loads"]["floors"]:
|
| 431 |
+
floor_data.append({
|
| 432 |
+
"Name": floor["name"],
|
| 433 |
+
"Area (m²)": floor["area"],
|
| 434 |
+
"U-Value (W/m²·K)": floor["u_value"],
|
| 435 |
+
"Temperature Difference (°C)": floor["delta_t"],
|
| 436 |
+
"Load (kW)": floor["load"]
|
| 437 |
+
})
|
| 438 |
+
|
| 439 |
+
if floor_data:
|
| 440 |
+
dataframes["Heating Floors"] = pd.DataFrame(floor_data)
|
| 441 |
+
|
| 442 |
+
# Windows
|
| 443 |
+
window_data = []
|
| 444 |
+
for window in results["heating"]["detailed_loads"]["windows"]:
|
| 445 |
+
window_data.append({
|
| 446 |
+
"Name": window["name"],
|
| 447 |
+
"Orientation": window["orientation"],
|
| 448 |
+
"Area (m²)": window["area"],
|
| 449 |
+
"U-Value (W/m²·K)": window["u_value"],
|
| 450 |
+
"Temperature Difference (°C)": window["delta_t"],
|
| 451 |
+
"Load (kW)": window["load"]
|
| 452 |
+
})
|
| 453 |
+
|
| 454 |
+
if window_data:
|
| 455 |
+
dataframes["Heating Windows"] = pd.DataFrame(window_data)
|
| 456 |
+
|
| 457 |
+
# Doors
|
| 458 |
+
door_data = []
|
| 459 |
+
for door in results["heating"]["detailed_loads"]["doors"]:
|
| 460 |
+
door_data.append({
|
| 461 |
+
"Name": door["name"],
|
| 462 |
+
"Orientation": door["orientation"],
|
| 463 |
+
"Area (m²)": door["area"],
|
| 464 |
+
"U-Value (W/m²·K)": door["u_value"],
|
| 465 |
+
"Temperature Difference (°C)": door["delta_t"],
|
| 466 |
+
"Load (kW)": door["load"]
|
| 467 |
+
})
|
| 468 |
+
|
| 469 |
+
if door_data:
|
| 470 |
+
dataframes["Heating Doors"] = pd.DataFrame(door_data)
|
| 471 |
+
|
| 472 |
+
# Infiltration and ventilation
|
| 473 |
+
air_data = [
|
| 474 |
+
{
|
| 475 |
+
"Type": "Infiltration",
|
| 476 |
+
"Air Flow (m³/s)": results["heating"]["detailed_loads"]["infiltration"]["air_flow"],
|
| 477 |
+
"Temperature Difference (°C)": results["heating"]["detailed_loads"]["infiltration"]["delta_t"],
|
| 478 |
+
"Load (kW)": results["heating"]["detailed_loads"]["infiltration"]["load"]
|
| 479 |
+
},
|
| 480 |
+
{
|
| 481 |
+
"Type": "Ventilation",
|
| 482 |
+
"Air Flow (m³/s)": results["heating"]["detailed_loads"]["ventilation"]["air_flow"],
|
| 483 |
+
"Temperature Difference (°C)": results["heating"]["detailed_loads"]["ventilation"]["delta_t"],
|
| 484 |
+
"Load (kW)": results["heating"]["detailed_loads"]["ventilation"]["load"]
|
| 485 |
+
}
|
| 486 |
+
]
|
| 487 |
+
|
| 488 |
+
dataframes["Heating Air Exchange"] = pd.DataFrame(air_data)
|
| 489 |
+
|
| 490 |
+
return dataframes
|
| 491 |
+
|
| 492 |
+
@staticmethod
|
| 493 |
+
def display_export_interface(session_state: Dict[str, Any]) -> None:
|
| 494 |
+
"""
|
| 495 |
+
Display export interface in Streamlit.
|
| 496 |
+
|
| 497 |
+
Args:
|
| 498 |
+
session_state: Streamlit session state containing calculation results
|
| 499 |
+
"""
|
| 500 |
+
st.header("Export Results")
|
| 501 |
+
|
| 502 |
+
# Check if calculations have been performed
|
| 503 |
+
if "calculation_results" not in session_state or not session_state["calculation_results"]:
|
| 504 |
+
st.warning("No calculation results available. Please run calculations first.")
|
| 505 |
+
return
|
| 506 |
+
|
| 507 |
+
# Create tabs for different export options
|
| 508 |
+
tab1, tab2, tab3 = st.tabs(["CSV Export", "Excel Export", "Scenario Export"])
|
| 509 |
+
|
| 510 |
+
with tab1:
|
| 511 |
+
DataExport._display_csv_export(session_state)
|
| 512 |
+
|
| 513 |
+
with tab2:
|
| 514 |
+
DataExport._display_excel_export(session_state)
|
| 515 |
+
|
| 516 |
+
with tab3:
|
| 517 |
+
DataExport._display_scenario_export(session_state)
|
| 518 |
+
|
| 519 |
+
@staticmethod
|
| 520 |
+
def _display_csv_export(session_state: Dict[str, Any]) -> None:
|
| 521 |
+
"""
|
| 522 |
+
Display CSV export interface.
|
| 523 |
+
|
| 524 |
+
Args:
|
| 525 |
+
session_state: Streamlit session state containing calculation results
|
| 526 |
+
"""
|
| 527 |
+
st.subheader("CSV Export")
|
| 528 |
+
|
| 529 |
+
# Get results
|
| 530 |
+
results = session_state["calculation_results"]
|
| 531 |
+
|
| 532 |
+
# Create tabs for cooling and heating loads
|
| 533 |
+
tab1, tab2 = st.tabs(["Cooling Load CSV", "Heating Load CSV"])
|
| 534 |
+
|
| 535 |
+
with tab1:
|
| 536 |
+
# Create cooling load DataFrames
|
| 537 |
+
cooling_dfs = DataExport.create_cooling_load_dataframes(results)
|
| 538 |
+
|
| 539 |
+
# Display and export each DataFrame
|
| 540 |
+
for sheet_name, df in cooling_dfs.items():
|
| 541 |
+
st.write(f"### {sheet_name}")
|
| 542 |
+
st.dataframe(df)
|
| 543 |
+
|
| 544 |
+
# Add download button
|
| 545 |
+
csv_data = DataExport.export_to_csv(df)
|
| 546 |
+
if csv_data:
|
| 547 |
+
filename = f"{sheet_name.replace(' ', '_').lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
| 548 |
+
download_link = DataExport.get_download_link(csv_data, filename, f"Download {sheet_name} CSV")
|
| 549 |
+
st.markdown(download_link, unsafe_allow_html=True)
|
| 550 |
+
|
| 551 |
+
with tab2:
|
| 552 |
+
# Create heating load DataFrames
|
| 553 |
+
heating_dfs = DataExport.create_heating_load_dataframes(results)
|
| 554 |
+
|
| 555 |
+
# Display and export each DataFrame
|
| 556 |
+
for sheet_name, df in heating_dfs.items():
|
| 557 |
+
st.write(f"### {sheet_name}")
|
| 558 |
+
st.dataframe(df)
|
| 559 |
+
|
| 560 |
+
# Add download button
|
| 561 |
+
csv_data = DataExport.export_to_csv(df)
|
| 562 |
+
if csv_data:
|
| 563 |
+
filename = f"{sheet_name.replace(' ', '_').lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
| 564 |
+
download_link = DataExport.get_download_link(csv_data, filename, f"Download {sheet_name} CSV")
|
| 565 |
+
st.markdown(download_link, unsafe_allow_html=True)
|
| 566 |
+
|
| 567 |
+
@staticmethod
|
| 568 |
+
def _display_excel_export(session_state: Dict[str, Any]) -> None:
|
| 569 |
+
"""
|
| 570 |
+
Display Excel export interface.
|
| 571 |
+
|
| 572 |
+
Args:
|
| 573 |
+
session_state: Streamlit session state containing calculation results
|
| 574 |
+
"""
|
| 575 |
+
st.subheader("Excel Export")
|
| 576 |
+
|
| 577 |
+
# Get results
|
| 578 |
+
results = session_state["calculation_results"]
|
| 579 |
+
|
| 580 |
+
# Create tabs for cooling, heating, and combined loads
|
| 581 |
+
tab1, tab2, tab3 = st.tabs(["Cooling Load Excel", "Heating Load Excel", "Combined Excel"])
|
| 582 |
+
|
| 583 |
+
with tab1:
|
| 584 |
+
# Create cooling load DataFrames
|
| 585 |
+
cooling_dfs = DataExport.create_cooling_load_dataframes(results)
|
| 586 |
+
|
| 587 |
+
# Add download button
|
| 588 |
+
excel_data = DataExport.export_to_excel(cooling_dfs)
|
| 589 |
+
if excel_data:
|
| 590 |
+
filename = f"cooling_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
| 591 |
+
download_link = DataExport.get_download_link(
|
| 592 |
+
excel_data,
|
| 593 |
+
filename,
|
| 594 |
+
"Download Cooling Load Excel",
|
| 595 |
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
| 596 |
+
)
|
| 597 |
+
st.markdown(download_link, unsafe_allow_html=True)
|
| 598 |
+
|
| 599 |
+
# Display preview
|
| 600 |
+
st.write("### Excel Preview")
|
| 601 |
+
st.write("The Excel file will contain the following sheets:")
|
| 602 |
+
for sheet_name in cooling_dfs.keys():
|
| 603 |
+
st.write(f"- {sheet_name}")
|
| 604 |
+
|
| 605 |
+
with tab2:
|
| 606 |
+
# Create heating load DataFrames
|
| 607 |
+
heating_dfs = DataExport.create_heating_load_dataframes(results)
|
| 608 |
+
|
| 609 |
+
# Add download button
|
| 610 |
+
excel_data = DataExport.export_to_excel(heating_dfs)
|
| 611 |
+
if excel_data:
|
| 612 |
+
filename = f"heating_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
| 613 |
+
download_link = DataExport.get_download_link(
|
| 614 |
+
excel_data,
|
| 615 |
+
filename,
|
| 616 |
+
"Download Heating Load Excel",
|
| 617 |
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
| 618 |
+
)
|
| 619 |
+
st.markdown(download_link, unsafe_allow_html=True)
|
| 620 |
+
|
| 621 |
+
# Display preview
|
| 622 |
+
st.write("### Excel Preview")
|
| 623 |
+
st.write("The Excel file will contain the following sheets:")
|
| 624 |
+
for sheet_name in heating_dfs.keys():
|
| 625 |
+
st.write(f"- {sheet_name}")
|
| 626 |
+
|
| 627 |
+
with tab3:
|
| 628 |
+
# Create combined DataFrames
|
| 629 |
+
combined_dfs = {}
|
| 630 |
+
|
| 631 |
+
# Add project information
|
| 632 |
+
if "building_info" in session_state:
|
| 633 |
+
project_info = [
|
| 634 |
+
{"Parameter": "Project Name", "Value": session_state["building_info"].get("project_name", "")},
|
| 635 |
+
{"Parameter": "Building Name", "Value": session_state["building_info"].get("building_name", "")},
|
| 636 |
+
{"Parameter": "Location", "Value": session_state["building_info"].get("location", "")},
|
| 637 |
+
{"Parameter": "Climate Zone", "Value": session_state["building_info"].get("climate_zone", "")},
|
| 638 |
+
{"Parameter": "Building Type", "Value": session_state["building_info"].get("building_type", "")},
|
| 639 |
+
{"Parameter": "Floor Area", "Value": session_state["building_info"].get("floor_area", "")},
|
| 640 |
+
{"Parameter": "Number of Floors", "Value": session_state["building_info"].get("num_floors", "")},
|
| 641 |
+
{"Parameter": "Floor Height", "Value": session_state["building_info"].get("floor_height", "")},
|
| 642 |
+
{"Parameter": "Orientation", "Value": session_state["building_info"].get("orientation", "")},
|
| 643 |
+
{"Parameter": "Occupancy", "Value": session_state["building_info"].get("occupancy", "")},
|
| 644 |
+
{"Parameter": "Operating Hours", "Value": session_state["building_info"].get("operating_hours", "")},
|
| 645 |
+
{"Parameter": "Date", "Value": datetime.now().strftime("%Y-%m-%d")},
|
| 646 |
+
{"Parameter": "Time", "Value": datetime.now().strftime("%H:%M:%S")}
|
| 647 |
+
]
|
| 648 |
+
|
| 649 |
+
combined_dfs["Project Information"] = pd.DataFrame(project_info)
|
| 650 |
+
|
| 651 |
+
# Add cooling load DataFrames
|
| 652 |
+
cooling_dfs = DataExport.create_cooling_load_dataframes(results)
|
| 653 |
+
for sheet_name, df in cooling_dfs.items():
|
| 654 |
+
combined_dfs[sheet_name] = df
|
| 655 |
+
|
| 656 |
+
# Add heating load DataFrames
|
| 657 |
+
heating_dfs = DataExport.create_heating_load_dataframes(results)
|
| 658 |
+
for sheet_name, df in heating_dfs.items():
|
| 659 |
+
combined_dfs[sheet_name] = df
|
| 660 |
+
|
| 661 |
+
# Add download button
|
| 662 |
+
excel_data = DataExport.export_to_excel(combined_dfs)
|
| 663 |
+
if excel_data:
|
| 664 |
+
filename = f"hvac_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
| 665 |
+
download_link = DataExport.get_download_link(
|
| 666 |
+
excel_data,
|
| 667 |
+
filename,
|
| 668 |
+
"Download Combined Excel Report",
|
| 669 |
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
| 670 |
+
)
|
| 671 |
+
st.markdown(download_link, unsafe_allow_html=True)
|
| 672 |
+
|
| 673 |
+
# Display preview
|
| 674 |
+
st.write("### Excel Preview")
|
| 675 |
+
st.write("The Excel file will contain the following sheets:")
|
| 676 |
+
for sheet_name in combined_dfs.keys():
|
| 677 |
+
st.write(f"- {sheet_name}")
|
| 678 |
+
|
| 679 |
+
@staticmethod
|
| 680 |
+
def _display_scenario_export(session_state: Dict[str, Any]) -> None:
|
| 681 |
+
"""
|
| 682 |
+
Display scenario export interface.
|
| 683 |
+
|
| 684 |
+
Args:
|
| 685 |
+
session_state: Streamlit session state containing calculation results
|
| 686 |
+
"""
|
| 687 |
+
st.subheader("Scenario Export")
|
| 688 |
+
|
| 689 |
+
# Check if there are saved scenarios
|
| 690 |
+
if "saved_scenarios" not in session_state or not session_state["saved_scenarios"]:
|
| 691 |
+
st.info("No saved scenarios available for export. Save the current results as a scenario to enable export.")
|
| 692 |
+
|
| 693 |
+
# Add button to save current results as a scenario
|
| 694 |
+
scenario_name = st.text_input("Scenario Name", value="Baseline")
|
| 695 |
+
|
| 696 |
+
if st.button("Save Current Results as Scenario"):
|
| 697 |
+
if "saved_scenarios" not in session_state:
|
| 698 |
+
session_state["saved_scenarios"] = {}
|
| 699 |
+
|
| 700 |
+
# Save current results as a scenario
|
| 701 |
+
session_state["saved_scenarios"][scenario_name] = {
|
| 702 |
+
"results": session_state["calculation_results"],
|
| 703 |
+
"building_info": session_state["building_info"],
|
| 704 |
+
"components": session_state["components"],
|
| 705 |
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
st.success(f"Scenario '{scenario_name}' saved successfully!")
|
| 709 |
+
st.experimental_rerun()
|
| 710 |
+
else:
|
| 711 |
+
# Display saved scenarios
|
| 712 |
+
st.write("### Saved Scenarios")
|
| 713 |
+
|
| 714 |
+
# Create selectbox for scenarios
|
| 715 |
+
scenario_names = list(session_state["saved_scenarios"].keys())
|
| 716 |
+
selected_scenario = st.selectbox("Select Scenario to Export", scenario_names)
|
| 717 |
+
|
| 718 |
+
if selected_scenario:
|
| 719 |
+
# Get selected scenario
|
| 720 |
+
scenario = session_state["saved_scenarios"][selected_scenario]
|
| 721 |
+
|
| 722 |
+
# Display scenario information
|
| 723 |
+
st.write(f"**Scenario:** {selected_scenario}")
|
| 724 |
+
st.write(f"**Timestamp:** {scenario['timestamp']}")
|
| 725 |
+
|
| 726 |
+
# Add download button
|
| 727 |
+
json_data = DataExport.export_scenario_to_json(scenario)
|
| 728 |
+
if json_data:
|
| 729 |
+
filename = f"{selected_scenario.replace(' ', '_').lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
| 730 |
+
download_link = DataExport.get_download_link(
|
| 731 |
+
json_data,
|
| 732 |
+
filename,
|
| 733 |
+
"Download Scenario JSON",
|
| 734 |
+
"application/json"
|
| 735 |
+
)
|
| 736 |
+
st.markdown(download_link, unsafe_allow_html=True)
|
| 737 |
+
|
| 738 |
+
# Add button to export all scenarios
|
| 739 |
+
if st.button("Export All Scenarios"):
|
| 740 |
+
# Create a zip file in memory
|
| 741 |
+
import zipfile
|
| 742 |
+
from io import BytesIO
|
| 743 |
+
|
| 744 |
+
zip_buffer = BytesIO()
|
| 745 |
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
| 746 |
+
for scenario_name, scenario in session_state["saved_scenarios"].items():
|
| 747 |
+
# Export scenario to JSON
|
| 748 |
+
json_data = DataExport.export_scenario_to_json(scenario)
|
| 749 |
+
if json_data:
|
| 750 |
+
filename = f"{scenario_name.replace(' ', '_').lower()}.json"
|
| 751 |
+
zip_file.writestr(filename, json_data)
|
| 752 |
+
|
| 753 |
+
# Add download button for zip file
|
| 754 |
+
zip_buffer.seek(0)
|
| 755 |
+
zip_data = zip_buffer.getvalue()
|
| 756 |
+
|
| 757 |
+
filename = f"all_scenarios_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
| 758 |
+
download_link = DataExport.get_download_link(
|
| 759 |
+
zip_data,
|
| 760 |
+
filename,
|
| 761 |
+
"Download All Scenarios (ZIP)",
|
| 762 |
+
"application/zip"
|
| 763 |
+
)
|
| 764 |
+
st.markdown(download_link, unsafe_allow_html=True)
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
# Create a singleton instance
|
| 768 |
+
data_export = DataExport()
|
| 769 |
+
|
| 770 |
+
# Example usage
|
| 771 |
+
if __name__ == "__main__":
|
| 772 |
+
import streamlit as st
|
| 773 |
+
|
| 774 |
+
# Initialize session state with dummy data for testing
|
| 775 |
+
if "calculation_results" not in st.session_state:
|
| 776 |
+
st.session_state["calculation_results"] = {
|
| 777 |
+
"cooling": {
|
| 778 |
+
"total_load": 25.5,
|
| 779 |
+
"sensible_load": 20.0,
|
| 780 |
+
"latent_load": 5.5,
|
| 781 |
+
"load_per_area": 85.0,
|
| 782 |
+
"component_loads": {
|
| 783 |
+
"walls": 5.0,
|
| 784 |
+
"roof": 3.0,
|
| 785 |
+
"windows": 8.0,
|
| 786 |
+
"doors": 1.0,
|
| 787 |
+
"people": 2.5,
|
| 788 |
+
"lighting": 2.0,
|
| 789 |
+
"equipment": 1.5,
|
| 790 |
+
"infiltration": 1.0,
|
| 791 |
+
"ventilation": 1.5
|
| 792 |
+
},
|
| 793 |
+
"detailed_loads": {
|
| 794 |
+
"walls": [
|
| 795 |
+
{"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "cltd": 10.0, "load": 1.0}
|
| 796 |
+
],
|
| 797 |
+
"roofs": [
|
| 798 |
+
{"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "cltd": 15.0, "load": 3.0}
|
| 799 |
+
],
|
| 800 |
+
"windows": [
|
| 801 |
+
{"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "shgc": 0.7, "scl": 800.0, "load": 8.0}
|
| 802 |
+
],
|
| 803 |
+
"doors": [
|
| 804 |
+
{"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "cltd": 10.0, "load": 1.0}
|
| 805 |
+
],
|
| 806 |
+
"internal": [
|
| 807 |
+
{"type": "People", "name": "Occupants", "quantity": 10, "heat_gain": 250, "clf": 1.0, "load": 2.5},
|
| 808 |
+
{"type": "Lighting", "name": "General Lighting", "quantity": 1000, "heat_gain": 2000, "clf": 1.0, "load": 2.0},
|
| 809 |
+
{"type": "Equipment", "name": "Office Equipment", "quantity": 5, "heat_gain": 300, "clf": 1.0, "load": 1.5}
|
| 810 |
+
],
|
| 811 |
+
"infiltration": {
|
| 812 |
+
"air_flow": 0.05,
|
| 813 |
+
"sensible_load": 0.8,
|
| 814 |
+
"latent_load": 0.2,
|
| 815 |
+
"total_load": 1.0
|
| 816 |
+
},
|
| 817 |
+
"ventilation": {
|
| 818 |
+
"air_flow": 0.1,
|
| 819 |
+
"sensible_load": 1.0,
|
| 820 |
+
"latent_load": 0.5,
|
| 821 |
+
"total_load": 1.5
|
| 822 |
+
}
|
| 823 |
+
}
|
| 824 |
+
},
|
| 825 |
+
"heating": {
|
| 826 |
+
"total_load": 30.0,
|
| 827 |
+
"load_per_area": 100.0,
|
| 828 |
+
"design_heat_loss": 27.0,
|
| 829 |
+
"safety_factor": 10.0,
|
| 830 |
+
"component_loads": {
|
| 831 |
+
"walls": 8.0,
|
| 832 |
+
"roof": 5.0,
|
| 833 |
+
"floor": 4.0,
|
| 834 |
+
"windows": 7.0,
|
| 835 |
+
"doors": 1.0,
|
| 836 |
+
"infiltration": 2.0,
|
| 837 |
+
"ventilation": 3.0
|
| 838 |
+
},
|
| 839 |
+
"detailed_loads": {
|
| 840 |
+
"walls": [
|
| 841 |
+
{"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "delta_t": 25.0, "load": 8.0}
|
| 842 |
+
],
|
| 843 |
+
"roofs": [
|
| 844 |
+
{"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "delta_t": 25.0, "load": 5.0}
|
| 845 |
+
],
|
| 846 |
+
"floors": [
|
| 847 |
+
{"name": "Ground Floor", "area": 100.0, "u_value": 0.4, "delta_t": 10.0, "load": 4.0}
|
| 848 |
+
],
|
| 849 |
+
"windows": [
|
| 850 |
+
{"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "delta_t": 25.0, "load": 7.0}
|
| 851 |
+
],
|
| 852 |
+
"doors": [
|
| 853 |
+
{"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "delta_t": 25.0, "load": 1.0}
|
| 854 |
+
],
|
| 855 |
+
"infiltration": {
|
| 856 |
+
"air_flow": 0.05,
|
| 857 |
+
"delta_t": 25.0,
|
| 858 |
+
"load": 2.0
|
| 859 |
+
},
|
| 860 |
+
"ventilation": {
|
| 861 |
+
"air_flow": 0.1,
|
| 862 |
+
"delta_t": 25.0,
|
| 863 |
+
"load": 3.0
|
| 864 |
+
}
|
| 865 |
+
}
|
| 866 |
+
}
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
# Display export interface
|
| 870 |
+
data_export.display_export_interface(st.session_state)
|
app/data_persistence.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data persistence module for HVAC Load Calculator.
|
| 3 |
+
This module provides functionality for saving and loading project data.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import base64
|
| 13 |
+
import io
|
| 14 |
+
import pickle
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
|
| 17 |
+
# Import data models
|
| 18 |
+
from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class DataPersistence:
|
| 22 |
+
"""Class for data persistence functionality."""
|
| 23 |
+
|
| 24 |
+
@staticmethod
|
| 25 |
+
def save_project_to_json(session_state: Dict[str, Any], file_path: str = None) -> Optional[str]:
|
| 26 |
+
"""
|
| 27 |
+
Save project data to a JSON file.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
session_state: Streamlit session state containing project data
|
| 31 |
+
file_path: Optional path to save the JSON file
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
JSON string if file_path is None, otherwise None
|
| 35 |
+
"""
|
| 36 |
+
try:
|
| 37 |
+
# Create project data dictionary
|
| 38 |
+
project_data = {
|
| 39 |
+
"building_info": session_state.get("building_info", {}),
|
| 40 |
+
"components": DataPersistence._serialize_components(session_state.get("components", {})),
|
| 41 |
+
"internal_loads": session_state.get("internal_loads", {}),
|
| 42 |
+
"calculation_settings": session_state.get("calculation_settings", {}),
|
| 43 |
+
"saved_scenarios": DataPersistence._serialize_scenarios(session_state.get("saved_scenarios", {})),
|
| 44 |
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
# Convert to JSON
|
| 48 |
+
json_data = json.dumps(project_data, indent=4)
|
| 49 |
+
|
| 50 |
+
# Save to file if path provided
|
| 51 |
+
if file_path:
|
| 52 |
+
with open(file_path, "w") as f:
|
| 53 |
+
f.write(json_data)
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
# Return JSON string if no path provided
|
| 57 |
+
return json_data
|
| 58 |
+
|
| 59 |
+
except Exception as e:
|
| 60 |
+
st.error(f"Error saving project data: {e}")
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
@staticmethod
|
| 64 |
+
def load_project_from_json(json_data: str = None, file_path: str = None) -> Optional[Dict[str, Any]]:
|
| 65 |
+
"""
|
| 66 |
+
Load project data from a JSON file or string.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
json_data: Optional JSON string containing project data
|
| 70 |
+
file_path: Optional path to the JSON file
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
Dictionary with project data if successful, None otherwise
|
| 74 |
+
"""
|
| 75 |
+
try:
|
| 76 |
+
# Load from file if path provided
|
| 77 |
+
if file_path and not json_data:
|
| 78 |
+
with open(file_path, "r") as f:
|
| 79 |
+
json_data = f.read()
|
| 80 |
+
|
| 81 |
+
# Parse JSON data
|
| 82 |
+
if json_data:
|
| 83 |
+
project_data = json.loads(json_data)
|
| 84 |
+
|
| 85 |
+
# Deserialize components
|
| 86 |
+
if "components" in project_data:
|
| 87 |
+
project_data["components"] = DataPersistence._deserialize_components(project_data["components"])
|
| 88 |
+
|
| 89 |
+
# Deserialize scenarios
|
| 90 |
+
if "saved_scenarios" in project_data:
|
| 91 |
+
project_data["saved_scenarios"] = DataPersistence._deserialize_scenarios(project_data["saved_scenarios"])
|
| 92 |
+
|
| 93 |
+
return project_data
|
| 94 |
+
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
st.error(f"Error loading project data: {e}")
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
@staticmethod
|
| 102 |
+
def _serialize_components(components: Dict[str, List[Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
| 103 |
+
"""
|
| 104 |
+
Serialize components for JSON storage.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
components: Dictionary with building components
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
Dictionary with serialized components
|
| 111 |
+
"""
|
| 112 |
+
serialized_components = {
|
| 113 |
+
"walls": [],
|
| 114 |
+
"roofs": [],
|
| 115 |
+
"floors": [],
|
| 116 |
+
"windows": [],
|
| 117 |
+
"doors": []
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
# Serialize walls
|
| 121 |
+
for wall in components.get("walls", []):
|
| 122 |
+
serialized_wall = wall.__dict__.copy()
|
| 123 |
+
|
| 124 |
+
# Convert enums to strings
|
| 125 |
+
if hasattr(serialized_wall["orientation"], "name"):
|
| 126 |
+
serialized_wall["orientation"] = serialized_wall["orientation"].name
|
| 127 |
+
|
| 128 |
+
if hasattr(serialized_wall["component_type"], "name"):
|
| 129 |
+
serialized_wall["component_type"] = serialized_wall["component_type"].name
|
| 130 |
+
|
| 131 |
+
serialized_components["walls"].append(serialized_wall)
|
| 132 |
+
|
| 133 |
+
# Serialize roofs
|
| 134 |
+
for roof in components.get("roofs", []):
|
| 135 |
+
serialized_roof = roof.__dict__.copy()
|
| 136 |
+
|
| 137 |
+
# Convert enums to strings
|
| 138 |
+
if hasattr(serialized_roof["orientation"], "name"):
|
| 139 |
+
serialized_roof["orientation"] = serialized_roof["orientation"].name
|
| 140 |
+
|
| 141 |
+
if hasattr(serialized_roof["component_type"], "name"):
|
| 142 |
+
serialized_roof["component_type"] = serialized_roof["component_type"].name
|
| 143 |
+
|
| 144 |
+
serialized_components["roofs"].append(serialized_roof)
|
| 145 |
+
|
| 146 |
+
# Serialize floors
|
| 147 |
+
for floor in components.get("floors", []):
|
| 148 |
+
serialized_floor = floor.__dict__.copy()
|
| 149 |
+
|
| 150 |
+
# Convert enums to strings
|
| 151 |
+
if hasattr(serialized_floor["component_type"], "name"):
|
| 152 |
+
serialized_floor["component_type"] = serialized_floor["component_type"].name
|
| 153 |
+
|
| 154 |
+
serialized_components["floors"].append(serialized_floor)
|
| 155 |
+
|
| 156 |
+
# Serialize windows
|
| 157 |
+
for window in components.get("windows", []):
|
| 158 |
+
serialized_window = window.__dict__.copy()
|
| 159 |
+
|
| 160 |
+
# Convert enums to strings
|
| 161 |
+
if hasattr(serialized_window["orientation"], "name"):
|
| 162 |
+
serialized_window["orientation"] = serialized_window["orientation"].name
|
| 163 |
+
|
| 164 |
+
if hasattr(serialized_window["component_type"], "name"):
|
| 165 |
+
serialized_window["component_type"] = serialized_window["component_type"].name
|
| 166 |
+
|
| 167 |
+
serialized_components["windows"].append(serialized_window)
|
| 168 |
+
|
| 169 |
+
# Serialize doors
|
| 170 |
+
for door in components.get("doors", []):
|
| 171 |
+
serialized_door = door.__dict__.copy()
|
| 172 |
+
|
| 173 |
+
# Convert enums to strings
|
| 174 |
+
if hasattr(serialized_door["orientation"], "name"):
|
| 175 |
+
serialized_door["orientation"] = serialized_door["orientation"].name
|
| 176 |
+
|
| 177 |
+
if hasattr(serialized_door["component_type"], "name"):
|
| 178 |
+
serialized_door["component_type"] = serialized_door["component_type"].name
|
| 179 |
+
|
| 180 |
+
serialized_components["doors"].append(serialized_door)
|
| 181 |
+
|
| 182 |
+
return serialized_components
|
| 183 |
+
|
| 184 |
+
@staticmethod
|
| 185 |
+
def _deserialize_components(serialized_components: Dict[str, List[Dict[str, Any]]]) -> Dict[str, List[Any]]:
|
| 186 |
+
"""
|
| 187 |
+
Deserialize components from JSON storage.
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
serialized_components: Dictionary with serialized components
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
Dictionary with deserialized components
|
| 194 |
+
"""
|
| 195 |
+
components = {
|
| 196 |
+
"walls": [],
|
| 197 |
+
"roofs": [],
|
| 198 |
+
"floors": [],
|
| 199 |
+
"windows": [],
|
| 200 |
+
"doors": []
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
# Deserialize walls
|
| 204 |
+
for wall_dict in serialized_components.get("walls", []):
|
| 205 |
+
wall = Wall(
|
| 206 |
+
id=wall_dict.get("id", ""),
|
| 207 |
+
name=wall_dict.get("name", ""),
|
| 208 |
+
component_type=ComponentType[wall_dict.get("component_type", "WALL")],
|
| 209 |
+
u_value=wall_dict.get("u_value", 0.0),
|
| 210 |
+
area=wall_dict.get("area", 0.0),
|
| 211 |
+
orientation=Orientation[wall_dict.get("orientation", "NORTH")],
|
| 212 |
+
wall_type=wall_dict.get("wall_type", ""),
|
| 213 |
+
wall_group=wall_dict.get("wall_group", "")
|
| 214 |
+
)
|
| 215 |
+
components["walls"].append(wall)
|
| 216 |
+
|
| 217 |
+
# Deserialize roofs
|
| 218 |
+
for roof_dict in serialized_components.get("roofs", []):
|
| 219 |
+
roof = Roof(
|
| 220 |
+
id=roof_dict.get("id", ""),
|
| 221 |
+
name=roof_dict.get("name", ""),
|
| 222 |
+
component_type=ComponentType[roof_dict.get("component_type", "ROOF")],
|
| 223 |
+
u_value=roof_dict.get("u_value", 0.0),
|
| 224 |
+
area=roof_dict.get("area", 0.0),
|
| 225 |
+
orientation=Orientation[roof_dict.get("orientation", "HORIZONTAL")],
|
| 226 |
+
roof_type=roof_dict.get("roof_type", ""),
|
| 227 |
+
roof_group=roof_dict.get("roof_group", "")
|
| 228 |
+
)
|
| 229 |
+
components["roofs"].append(roof)
|
| 230 |
+
|
| 231 |
+
# Deserialize floors
|
| 232 |
+
for floor_dict in serialized_components.get("floors", []):
|
| 233 |
+
floor = Floor(
|
| 234 |
+
id=floor_dict.get("id", ""),
|
| 235 |
+
name=floor_dict.get("name", ""),
|
| 236 |
+
component_type=ComponentType[floor_dict.get("component_type", "FLOOR")],
|
| 237 |
+
u_value=floor_dict.get("u_value", 0.0),
|
| 238 |
+
area=floor_dict.get("area", 0.0),
|
| 239 |
+
floor_type=floor_dict.get("floor_type", "")
|
| 240 |
+
)
|
| 241 |
+
components["floors"].append(floor)
|
| 242 |
+
|
| 243 |
+
# Deserialize windows
|
| 244 |
+
for window_dict in serialized_components.get("windows", []):
|
| 245 |
+
window = Window(
|
| 246 |
+
id=window_dict.get("id", ""),
|
| 247 |
+
name=window_dict.get("name", ""),
|
| 248 |
+
component_type=ComponentType[window_dict.get("component_type", "WINDOW")],
|
| 249 |
+
u_value=window_dict.get("u_value", 0.0),
|
| 250 |
+
area=window_dict.get("area", 0.0),
|
| 251 |
+
orientation=Orientation[window_dict.get("orientation", "NORTH")],
|
| 252 |
+
shgc=window_dict.get("shgc", 0.0),
|
| 253 |
+
vt=window_dict.get("vt", 0.0),
|
| 254 |
+
window_type=window_dict.get("window_type", ""),
|
| 255 |
+
glazing_layers=window_dict.get("glazing_layers", 1),
|
| 256 |
+
gas_fill=window_dict.get("gas_fill", ""),
|
| 257 |
+
low_e_coating=window_dict.get("low_e_coating", False)
|
| 258 |
+
)
|
| 259 |
+
components["windows"].append(window)
|
| 260 |
+
|
| 261 |
+
# Deserialize doors
|
| 262 |
+
for door_dict in serialized_components.get("doors", []):
|
| 263 |
+
door = Door(
|
| 264 |
+
id=door_dict.get("id", ""),
|
| 265 |
+
name=door_dict.get("name", ""),
|
| 266 |
+
component_type=ComponentType[door_dict.get("component_type", "DOOR")],
|
| 267 |
+
u_value=door_dict.get("u_value", 0.0),
|
| 268 |
+
area=door_dict.get("area", 0.0),
|
| 269 |
+
orientation=Orientation[door_dict.get("orientation", "NORTH")],
|
| 270 |
+
door_type=door_dict.get("door_type", "")
|
| 271 |
+
)
|
| 272 |
+
components["doors"].append(door)
|
| 273 |
+
|
| 274 |
+
return components
|
| 275 |
+
|
| 276 |
+
@staticmethod
|
| 277 |
+
def _serialize_scenarios(scenarios: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
| 278 |
+
"""
|
| 279 |
+
Serialize scenarios for JSON storage.
|
| 280 |
+
|
| 281 |
+
Args:
|
| 282 |
+
scenarios: Dictionary with saved scenarios
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
Dictionary with serialized scenarios
|
| 286 |
+
"""
|
| 287 |
+
serialized_scenarios = {}
|
| 288 |
+
|
| 289 |
+
for scenario_name, scenario_data in scenarios.items():
|
| 290 |
+
serialized_scenario = {
|
| 291 |
+
"results": scenario_data.get("results", {}),
|
| 292 |
+
"building_info": scenario_data.get("building_info", {}),
|
| 293 |
+
"components": DataPersistence._serialize_components(scenario_data.get("components", {})),
|
| 294 |
+
"timestamp": scenario_data.get("timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
serialized_scenarios[scenario_name] = serialized_scenario
|
| 298 |
+
|
| 299 |
+
return serialized_scenarios
|
| 300 |
+
|
| 301 |
+
@staticmethod
|
| 302 |
+
def _deserialize_scenarios(serialized_scenarios: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
| 303 |
+
"""
|
| 304 |
+
Deserialize scenarios from JSON storage.
|
| 305 |
+
|
| 306 |
+
Args:
|
| 307 |
+
serialized_scenarios: Dictionary with serialized scenarios
|
| 308 |
+
|
| 309 |
+
Returns:
|
| 310 |
+
Dictionary with deserialized scenarios
|
| 311 |
+
"""
|
| 312 |
+
scenarios = {}
|
| 313 |
+
|
| 314 |
+
for scenario_name, serialized_scenario in serialized_scenarios.items():
|
| 315 |
+
scenario = {
|
| 316 |
+
"results": serialized_scenario.get("results", {}),
|
| 317 |
+
"building_info": serialized_scenario.get("building_info", {}),
|
| 318 |
+
"components": DataPersistence._deserialize_components(serialized_scenario.get("components", {})),
|
| 319 |
+
"timestamp": serialized_scenario.get("timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
scenarios[scenario_name] = scenario
|
| 323 |
+
|
| 324 |
+
return scenarios
|
| 325 |
+
|
| 326 |
+
@staticmethod
|
| 327 |
+
def get_download_link(data: str, filename: str, text: str) -> str:
|
| 328 |
+
"""
|
| 329 |
+
Generate a download link for data.
|
| 330 |
+
|
| 331 |
+
Args:
|
| 332 |
+
data: Data to download
|
| 333 |
+
filename: Name of the file to download
|
| 334 |
+
text: Text to display for the download link
|
| 335 |
+
|
| 336 |
+
Returns:
|
| 337 |
+
HTML string with download link
|
| 338 |
+
"""
|
| 339 |
+
b64 = base64.b64encode(data.encode()).decode()
|
| 340 |
+
href = f'<a href="data:file/txt;base64,{b64}" download="{filename}">{text}</a>'
|
| 341 |
+
return href
|
| 342 |
+
|
| 343 |
+
@staticmethod
|
| 344 |
+
def display_project_management(session_state: Dict[str, Any]) -> None:
|
| 345 |
+
"""
|
| 346 |
+
Display project management interface in Streamlit.
|
| 347 |
+
|
| 348 |
+
Args:
|
| 349 |
+
session_state: Streamlit session state containing project data
|
| 350 |
+
"""
|
| 351 |
+
st.header("Project Management")
|
| 352 |
+
|
| 353 |
+
# Create tabs for different project management functions
|
| 354 |
+
tab1, tab2, tab3 = st.tabs(["Save Project", "Load Project", "Project History"])
|
| 355 |
+
|
| 356 |
+
with tab1:
|
| 357 |
+
DataPersistence._display_save_project(session_state)
|
| 358 |
+
|
| 359 |
+
with tab2:
|
| 360 |
+
DataPersistence._display_load_project(session_state)
|
| 361 |
+
|
| 362 |
+
with tab3:
|
| 363 |
+
DataPersistence._display_project_history(session_state)
|
| 364 |
+
|
| 365 |
+
@staticmethod
|
| 366 |
+
def _display_save_project(session_state: Dict[str, Any]) -> None:
|
| 367 |
+
"""
|
| 368 |
+
Display save project interface.
|
| 369 |
+
|
| 370 |
+
Args:
|
| 371 |
+
session_state: Streamlit session state containing project data
|
| 372 |
+
"""
|
| 373 |
+
st.subheader("Save Project")
|
| 374 |
+
|
| 375 |
+
# Get project name
|
| 376 |
+
project_name = st.text_input(
|
| 377 |
+
"Project Name",
|
| 378 |
+
value=session_state.get("building_info", {}).get("project_name", "HVAC_Project"),
|
| 379 |
+
key="save_project_name"
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# Add description
|
| 383 |
+
project_description = st.text_area(
|
| 384 |
+
"Project Description",
|
| 385 |
+
value=session_state.get("project_description", ""),
|
| 386 |
+
key="save_project_description"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
# Save project description
|
| 390 |
+
session_state["project_description"] = project_description
|
| 391 |
+
|
| 392 |
+
# Add save button
|
| 393 |
+
if st.button("Save Project"):
|
| 394 |
+
# Validate project data
|
| 395 |
+
if "building_info" not in session_state or not session_state["building_info"]:
|
| 396 |
+
st.error("No building information found. Please enter building information before saving.")
|
| 397 |
+
return
|
| 398 |
+
|
| 399 |
+
if "components" not in session_state or not any(session_state["components"].values()):
|
| 400 |
+
st.warning("No building components found. It's recommended to add components before saving.")
|
| 401 |
+
|
| 402 |
+
# Save project data to JSON
|
| 403 |
+
json_data = DataPersistence.save_project_to_json(session_state)
|
| 404 |
+
|
| 405 |
+
if json_data:
|
| 406 |
+
# Generate download link
|
| 407 |
+
filename = f"{project_name.replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.hvac"
|
| 408 |
+
download_link = DataPersistence.get_download_link(json_data, filename, "Download Project File")
|
| 409 |
+
|
| 410 |
+
# Display download link
|
| 411 |
+
st.success("Project saved successfully!")
|
| 412 |
+
st.markdown(download_link, unsafe_allow_html=True)
|
| 413 |
+
|
| 414 |
+
# Save to project history
|
| 415 |
+
if "project_history" not in session_state:
|
| 416 |
+
session_state["project_history"] = []
|
| 417 |
+
|
| 418 |
+
session_state["project_history"].append({
|
| 419 |
+
"name": project_name,
|
| 420 |
+
"description": project_description,
|
| 421 |
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 422 |
+
"data": json_data
|
| 423 |
+
})
|
| 424 |
+
else:
|
| 425 |
+
st.error("Error saving project data.")
|
| 426 |
+
|
| 427 |
+
@staticmethod
|
| 428 |
+
def _display_load_project(session_state: Dict[str, Any]) -> None:
|
| 429 |
+
"""
|
| 430 |
+
Display load project interface.
|
| 431 |
+
|
| 432 |
+
Args:
|
| 433 |
+
session_state: Streamlit session state containing project data
|
| 434 |
+
"""
|
| 435 |
+
st.subheader("Load Project")
|
| 436 |
+
|
| 437 |
+
# Add file uploader
|
| 438 |
+
uploaded_file = st.file_uploader("Upload Project File", type=["hvac", "json"])
|
| 439 |
+
|
| 440 |
+
if uploaded_file is not None:
|
| 441 |
+
# Read file content
|
| 442 |
+
json_data = uploaded_file.read().decode("utf-8")
|
| 443 |
+
|
| 444 |
+
# Load project data
|
| 445 |
+
project_data = DataPersistence.load_project_from_json(json_data)
|
| 446 |
+
|
| 447 |
+
if project_data:
|
| 448 |
+
# Add load button
|
| 449 |
+
if st.button("Load Project Data"):
|
| 450 |
+
# Update session state with project data
|
| 451 |
+
for key, value in project_data.items():
|
| 452 |
+
session_state[key] = value
|
| 453 |
+
|
| 454 |
+
st.success("Project loaded successfully!")
|
| 455 |
+
st.experimental_rerun()
|
| 456 |
+
else:
|
| 457 |
+
st.error("Error loading project data. Invalid file format.")
|
| 458 |
+
|
| 459 |
+
@staticmethod
|
| 460 |
+
def _display_project_history(session_state: Dict[str, Any]) -> None:
|
| 461 |
+
"""
|
| 462 |
+
Display project history interface.
|
| 463 |
+
|
| 464 |
+
Args:
|
| 465 |
+
session_state: Streamlit session state containing project data
|
| 466 |
+
"""
|
| 467 |
+
st.subheader("Project History")
|
| 468 |
+
|
| 469 |
+
# Check if project history exists
|
| 470 |
+
if "project_history" not in session_state or not session_state["project_history"]:
|
| 471 |
+
st.info("No project history found. Save a project to see it in the history.")
|
| 472 |
+
return
|
| 473 |
+
|
| 474 |
+
# Display project history
|
| 475 |
+
for i, project in enumerate(reversed(session_state["project_history"])):
|
| 476 |
+
with st.expander(f"{project['name']} - {project['timestamp']}"):
|
| 477 |
+
st.write(f"**Description:** {project['description']}")
|
| 478 |
+
|
| 479 |
+
# Add load button
|
| 480 |
+
if st.button(f"Load Project", key=f"load_history_{i}"):
|
| 481 |
+
# Load project data
|
| 482 |
+
project_data = DataPersistence.load_project_from_json(project["data"])
|
| 483 |
+
|
| 484 |
+
if project_data:
|
| 485 |
+
# Update session state with project data
|
| 486 |
+
for key, value in project_data.items():
|
| 487 |
+
session_state[key] = value
|
| 488 |
+
|
| 489 |
+
st.success("Project loaded successfully!")
|
| 490 |
+
st.experimental_rerun()
|
| 491 |
+
|
| 492 |
+
# Add download button
|
| 493 |
+
filename = f"{project['name'].replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.hvac"
|
| 494 |
+
download_link = DataPersistence.get_download_link(project["data"], filename, "Download Project File")
|
| 495 |
+
st.markdown(download_link, unsafe_allow_html=True)
|
| 496 |
+
|
| 497 |
+
# Add delete button
|
| 498 |
+
if st.button(f"Delete from History", key=f"delete_history_{i}"):
|
| 499 |
+
# Remove project from history
|
| 500 |
+
session_state["project_history"].remove(project)
|
| 501 |
+
|
| 502 |
+
st.success("Project removed from history.")
|
| 503 |
+
st.experimental_rerun()
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
# Create a singleton instance
|
| 507 |
+
data_persistence = DataPersistence()
|
| 508 |
+
|
| 509 |
+
# Example usage
|
| 510 |
+
if __name__ == "__main__":
|
| 511 |
+
import streamlit as st
|
| 512 |
+
|
| 513 |
+
# Initialize session state with dummy data for testing
|
| 514 |
+
if "building_info" not in st.session_state:
|
| 515 |
+
st.session_state["building_info"] = {
|
| 516 |
+
"project_name": "Test Project",
|
| 517 |
+
"building_name": "Test Building",
|
| 518 |
+
"location": "New York",
|
| 519 |
+
"climate_zone": "4A",
|
| 520 |
+
"building_type": "Office",
|
| 521 |
+
"floor_area": 1000.0,
|
| 522 |
+
"num_floors": 2,
|
| 523 |
+
"floor_height": 3.0,
|
| 524 |
+
"orientation": "NORTH",
|
| 525 |
+
"occupancy": 50,
|
| 526 |
+
"operating_hours": "8:00-18:00",
|
| 527 |
+
"design_conditions": {
|
| 528 |
+
"summer_outdoor_db": 35.0,
|
| 529 |
+
"summer_outdoor_wb": 25.0,
|
| 530 |
+
"summer_indoor_db": 24.0,
|
| 531 |
+
"summer_indoor_rh": 50.0,
|
| 532 |
+
"winter_outdoor_db": -5.0,
|
| 533 |
+
"winter_outdoor_rh": 80.0,
|
| 534 |
+
"winter_indoor_db": 21.0,
|
| 535 |
+
"winter_indoor_rh": 40.0
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
# Display project management interface
|
| 540 |
+
data_persistence.display_project_management(st.session_state)
|
app/data_validation.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data validation module for HVAC Load Calculator.
|
| 3 |
+
This module provides validation functions for user inputs.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
from typing import Dict, List, Any, Optional, Tuple, Callable
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class DataValidation:
|
| 15 |
+
"""Class for data validation functionality."""
|
| 16 |
+
|
| 17 |
+
@staticmethod
|
| 18 |
+
def validate_building_info(building_info: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
| 19 |
+
"""
|
| 20 |
+
Validate building information inputs.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
building_info: Dictionary with building information
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
Tuple containing validation result (True if valid) and list of validation messages
|
| 27 |
+
"""
|
| 28 |
+
is_valid = True
|
| 29 |
+
messages = []
|
| 30 |
+
|
| 31 |
+
# Check required fields
|
| 32 |
+
required_fields = [
|
| 33 |
+
("project_name", "Project Name"),
|
| 34 |
+
("building_name", "Building Name"),
|
| 35 |
+
("location", "Location"),
|
| 36 |
+
("climate_zone", "Climate Zone"),
|
| 37 |
+
("building_type", "Building Type")
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
for field, display_name in required_fields:
|
| 41 |
+
if field not in building_info or not building_info[field]:
|
| 42 |
+
is_valid = False
|
| 43 |
+
messages.append(f"{display_name} is required.")
|
| 44 |
+
|
| 45 |
+
# Check numeric fields
|
| 46 |
+
numeric_fields = [
|
| 47 |
+
("floor_area", "Floor Area", 0, None),
|
| 48 |
+
("num_floors", "Number of Floors", 1, None),
|
| 49 |
+
("floor_height", "Floor Height", 2.0, 10.0),
|
| 50 |
+
("occupancy", "Occupancy", 0, None)
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
for field, display_name, min_val, max_val in numeric_fields:
|
| 54 |
+
if field in building_info:
|
| 55 |
+
try:
|
| 56 |
+
value = float(building_info[field])
|
| 57 |
+
if min_val is not None and value < min_val:
|
| 58 |
+
is_valid = False
|
| 59 |
+
messages.append(f"{display_name} must be at least {min_val}.")
|
| 60 |
+
if max_val is not None and value > max_val:
|
| 61 |
+
is_valid = False
|
| 62 |
+
messages.append(f"{display_name} must be at most {max_val}.")
|
| 63 |
+
except (ValueError, TypeError):
|
| 64 |
+
is_valid = False
|
| 65 |
+
messages.append(f"{display_name} must be a number.")
|
| 66 |
+
|
| 67 |
+
# Check design conditions
|
| 68 |
+
if "design_conditions" in building_info:
|
| 69 |
+
design_conditions = building_info["design_conditions"]
|
| 70 |
+
|
| 71 |
+
# Check summer conditions
|
| 72 |
+
summer_fields = [
|
| 73 |
+
("summer_outdoor_db", "Summer Outdoor Dry-Bulb", -10.0, 50.0),
|
| 74 |
+
("summer_outdoor_wb", "Summer Outdoor Wet-Bulb", -10.0, 40.0),
|
| 75 |
+
("summer_indoor_db", "Summer Indoor Dry-Bulb", 18.0, 30.0),
|
| 76 |
+
("summer_indoor_rh", "Summer Indoor RH", 30.0, 70.0)
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
for field, display_name, min_val, max_val in summer_fields:
|
| 80 |
+
if field in design_conditions:
|
| 81 |
+
try:
|
| 82 |
+
value = float(design_conditions[field])
|
| 83 |
+
if min_val is not None and value < min_val:
|
| 84 |
+
is_valid = False
|
| 85 |
+
messages.append(f"{display_name} must be at least {min_val}.")
|
| 86 |
+
if max_val is not None and value > max_val:
|
| 87 |
+
is_valid = False
|
| 88 |
+
messages.append(f"{display_name} must be at most {max_val}.")
|
| 89 |
+
except (ValueError, TypeError):
|
| 90 |
+
is_valid = False
|
| 91 |
+
messages.append(f"{display_name} must be a number.")
|
| 92 |
+
|
| 93 |
+
# Check winter conditions
|
| 94 |
+
winter_fields = [
|
| 95 |
+
("winter_outdoor_db", "Winter Outdoor Dry-Bulb", -40.0, 20.0),
|
| 96 |
+
("winter_outdoor_rh", "Winter Outdoor RH", 0.0, 100.0),
|
| 97 |
+
("winter_indoor_db", "Winter Indoor Dry-Bulb", 18.0, 25.0),
|
| 98 |
+
("winter_indoor_rh", "Winter Indoor RH", 20.0, 60.0)
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
for field, display_name, min_val, max_val in winter_fields:
|
| 102 |
+
if field in design_conditions:
|
| 103 |
+
try:
|
| 104 |
+
value = float(design_conditions[field])
|
| 105 |
+
if min_val is not None and value < min_val:
|
| 106 |
+
is_valid = False
|
| 107 |
+
messages.append(f"{display_name} must be at least {min_val}.")
|
| 108 |
+
if max_val is not None and value > max_val:
|
| 109 |
+
is_valid = False
|
| 110 |
+
messages.append(f"{display_name} must be at most {max_val}.")
|
| 111 |
+
except (ValueError, TypeError):
|
| 112 |
+
is_valid = False
|
| 113 |
+
messages.append(f"{display_name} must be a number.")
|
| 114 |
+
|
| 115 |
+
# Check that wet-bulb is less than dry-bulb
|
| 116 |
+
if "summer_outdoor_db" in design_conditions and "summer_outdoor_wb" in design_conditions:
|
| 117 |
+
try:
|
| 118 |
+
db = float(design_conditions["summer_outdoor_db"])
|
| 119 |
+
wb = float(design_conditions["summer_outdoor_wb"])
|
| 120 |
+
if wb > db:
|
| 121 |
+
is_valid = False
|
| 122 |
+
messages.append("Summer Outdoor Wet-Bulb temperature must be less than or equal to Dry-Bulb temperature.")
|
| 123 |
+
except (ValueError, TypeError):
|
| 124 |
+
pass # Already handled above
|
| 125 |
+
|
| 126 |
+
return is_valid, messages
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def validate_components(components: Dict[str, List[Any]]) -> Tuple[bool, List[str]]:
|
| 130 |
+
"""
|
| 131 |
+
Validate building components.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
components: Dictionary with building components
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
Tuple containing validation result (True if valid) and list of validation messages
|
| 138 |
+
"""
|
| 139 |
+
is_valid = True
|
| 140 |
+
messages = []
|
| 141 |
+
|
| 142 |
+
# Check if any components exist
|
| 143 |
+
if not any(components.values()):
|
| 144 |
+
is_valid = False
|
| 145 |
+
messages.append("At least one building component (wall, roof, floor, window, or door) is required.")
|
| 146 |
+
|
| 147 |
+
# Check wall components
|
| 148 |
+
for i, wall in enumerate(components.get("walls", [])):
|
| 149 |
+
# Check required fields
|
| 150 |
+
if not wall.name:
|
| 151 |
+
is_valid = False
|
| 152 |
+
messages.append(f"Wall #{i+1}: Name is required.")
|
| 153 |
+
|
| 154 |
+
# Check numeric fields
|
| 155 |
+
if wall.area <= 0:
|
| 156 |
+
is_valid = False
|
| 157 |
+
messages.append(f"Wall #{i+1}: Area must be greater than zero.")
|
| 158 |
+
|
| 159 |
+
if wall.u_value <= 0:
|
| 160 |
+
is_valid = False
|
| 161 |
+
messages.append(f"Wall #{i+1}: U-value must be greater than zero.")
|
| 162 |
+
|
| 163 |
+
# Check roof components
|
| 164 |
+
for i, roof in enumerate(components.get("roofs", [])):
|
| 165 |
+
# Check required fields
|
| 166 |
+
if not roof.name:
|
| 167 |
+
is_valid = False
|
| 168 |
+
messages.append(f"Roof #{i+1}: Name is required.")
|
| 169 |
+
|
| 170 |
+
# Check numeric fields
|
| 171 |
+
if roof.area <= 0:
|
| 172 |
+
is_valid = False
|
| 173 |
+
messages.append(f"Roof #{i+1}: Area must be greater than zero.")
|
| 174 |
+
|
| 175 |
+
if roof.u_value <= 0:
|
| 176 |
+
is_valid = False
|
| 177 |
+
messages.append(f"Roof #{i+1}: U-value must be greater than zero.")
|
| 178 |
+
|
| 179 |
+
# Check floor components
|
| 180 |
+
for i, floor in enumerate(components.get("floors", [])):
|
| 181 |
+
# Check required fields
|
| 182 |
+
if not floor.name:
|
| 183 |
+
is_valid = False
|
| 184 |
+
messages.append(f"Floor #{i+1}: Name is required.")
|
| 185 |
+
|
| 186 |
+
# Check numeric fields
|
| 187 |
+
if floor.area <= 0:
|
| 188 |
+
is_valid = False
|
| 189 |
+
messages.append(f"Floor #{i+1}: Area must be greater than zero.")
|
| 190 |
+
|
| 191 |
+
if floor.u_value <= 0:
|
| 192 |
+
is_valid = False
|
| 193 |
+
messages.append(f"Floor #{i+1}: U-value must be greater than zero.")
|
| 194 |
+
|
| 195 |
+
# Check window components
|
| 196 |
+
for i, window in enumerate(components.get("windows", [])):
|
| 197 |
+
# Check required fields
|
| 198 |
+
if not window.name:
|
| 199 |
+
is_valid = False
|
| 200 |
+
messages.append(f"Window #{i+1}: Name is required.")
|
| 201 |
+
|
| 202 |
+
# Check numeric fields
|
| 203 |
+
if window.area <= 0:
|
| 204 |
+
is_valid = False
|
| 205 |
+
messages.append(f"Window #{i+1}: Area must be greater than zero.")
|
| 206 |
+
|
| 207 |
+
if window.u_value <= 0:
|
| 208 |
+
is_valid = False
|
| 209 |
+
messages.append(f"Window #{i+1}: U-value must be greater than zero.")
|
| 210 |
+
|
| 211 |
+
if window.shgc <= 0 or window.shgc > 1:
|
| 212 |
+
is_valid = False
|
| 213 |
+
messages.append(f"Window #{i+1}: SHGC must be between 0 and 1.")
|
| 214 |
+
|
| 215 |
+
# Check door components
|
| 216 |
+
for i, door in enumerate(components.get("doors", [])):
|
| 217 |
+
# Check required fields
|
| 218 |
+
if not door.name:
|
| 219 |
+
is_valid = False
|
| 220 |
+
messages.append(f"Door #{i+1}: Name is required.")
|
| 221 |
+
|
| 222 |
+
# Check numeric fields
|
| 223 |
+
if door.area <= 0:
|
| 224 |
+
is_valid = False
|
| 225 |
+
messages.append(f"Door #{i+1}: Area must be greater than zero.")
|
| 226 |
+
|
| 227 |
+
if door.u_value <= 0:
|
| 228 |
+
is_valid = False
|
| 229 |
+
messages.append(f"Door #{i+1}: U-value must be greater than zero.")
|
| 230 |
+
|
| 231 |
+
# Check for minimum requirements
|
| 232 |
+
if not components.get("walls", []):
|
| 233 |
+
messages.append("Warning: No walls defined. At least one wall is recommended.")
|
| 234 |
+
|
| 235 |
+
if not components.get("roofs", []):
|
| 236 |
+
messages.append("Warning: No roofs defined. At least one roof is recommended.")
|
| 237 |
+
|
| 238 |
+
if not components.get("floors", []):
|
| 239 |
+
messages.append("Warning: No floors defined. At least one floor is recommended.")
|
| 240 |
+
|
| 241 |
+
return is_valid, messages
|
| 242 |
+
|
| 243 |
+
@staticmethod
|
| 244 |
+
def validate_internal_loads(internal_loads: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
| 245 |
+
"""
|
| 246 |
+
Validate internal loads inputs.
|
| 247 |
+
|
| 248 |
+
Args:
|
| 249 |
+
internal_loads: Dictionary with internal loads information
|
| 250 |
+
|
| 251 |
+
Returns:
|
| 252 |
+
Tuple containing validation result (True if valid) and list of validation messages
|
| 253 |
+
"""
|
| 254 |
+
is_valid = True
|
| 255 |
+
messages = []
|
| 256 |
+
|
| 257 |
+
# Check people loads
|
| 258 |
+
people = internal_loads.get("people", [])
|
| 259 |
+
for i, person in enumerate(people):
|
| 260 |
+
# Check required fields
|
| 261 |
+
if not person.get("name"):
|
| 262 |
+
is_valid = False
|
| 263 |
+
messages.append(f"People Load #{i+1}: Name is required.")
|
| 264 |
+
|
| 265 |
+
# Check numeric fields
|
| 266 |
+
if person.get("quantity", 0) < 0:
|
| 267 |
+
is_valid = False
|
| 268 |
+
messages.append(f"People Load #{i+1}: Quantity must be non-negative.")
|
| 269 |
+
|
| 270 |
+
if person.get("sensible_heat", 0) < 0:
|
| 271 |
+
is_valid = False
|
| 272 |
+
messages.append(f"People Load #{i+1}: Sensible heat must be non-negative.")
|
| 273 |
+
|
| 274 |
+
if person.get("latent_heat", 0) < 0:
|
| 275 |
+
is_valid = False
|
| 276 |
+
messages.append(f"People Load #{i+1}: Latent heat must be non-negative.")
|
| 277 |
+
|
| 278 |
+
# Check lighting loads
|
| 279 |
+
lighting = internal_loads.get("lighting", [])
|
| 280 |
+
for i, light in enumerate(lighting):
|
| 281 |
+
# Check required fields
|
| 282 |
+
if not light.get("name"):
|
| 283 |
+
is_valid = False
|
| 284 |
+
messages.append(f"Lighting Load #{i+1}: Name is required.")
|
| 285 |
+
|
| 286 |
+
# Check numeric fields
|
| 287 |
+
if light.get("power", 0) < 0:
|
| 288 |
+
is_valid = False
|
| 289 |
+
messages.append(f"Lighting Load #{i+1}: Power must be non-negative.")
|
| 290 |
+
|
| 291 |
+
if light.get("usage_factor", 0) < 0 or light.get("usage_factor", 0) > 1:
|
| 292 |
+
is_valid = False
|
| 293 |
+
messages.append(f"Lighting Load #{i+1}: Usage factor must be between 0 and 1.")
|
| 294 |
+
|
| 295 |
+
# Check equipment loads
|
| 296 |
+
equipment = internal_loads.get("equipment", [])
|
| 297 |
+
for i, equip in enumerate(equipment):
|
| 298 |
+
# Check required fields
|
| 299 |
+
if not equip.get("name"):
|
| 300 |
+
is_valid = False
|
| 301 |
+
messages.append(f"Equipment Load #{i+1}: Name is required.")
|
| 302 |
+
|
| 303 |
+
# Check numeric fields
|
| 304 |
+
if equip.get("power", 0) < 0:
|
| 305 |
+
is_valid = False
|
| 306 |
+
messages.append(f"Equipment Load #{i+1}: Power must be non-negative.")
|
| 307 |
+
|
| 308 |
+
if equip.get("usage_factor", 0) < 0 or equip.get("usage_factor", 0) > 1:
|
| 309 |
+
is_valid = False
|
| 310 |
+
messages.append(f"Equipment Load #{i+1}: Usage factor must be between 0 and 1.")
|
| 311 |
+
|
| 312 |
+
if equip.get("radiation_fraction", 0) < 0 or equip.get("radiation_fraction", 0) > 1:
|
| 313 |
+
is_valid = False
|
| 314 |
+
messages.append(f"Equipment Load #{i+1}: Radiation fraction must be between 0 and 1.")
|
| 315 |
+
|
| 316 |
+
return is_valid, messages
|
| 317 |
+
|
| 318 |
+
@staticmethod
|
| 319 |
+
def validate_calculation_settings(settings: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
| 320 |
+
"""
|
| 321 |
+
Validate calculation settings.
|
| 322 |
+
|
| 323 |
+
Args:
|
| 324 |
+
settings: Dictionary with calculation settings
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
Tuple containing validation result (True if valid) and list of validation messages
|
| 328 |
+
"""
|
| 329 |
+
is_valid = True
|
| 330 |
+
messages = []
|
| 331 |
+
|
| 332 |
+
# Check infiltration rate
|
| 333 |
+
if "infiltration_rate" in settings:
|
| 334 |
+
try:
|
| 335 |
+
infiltration_rate = float(settings["infiltration_rate"])
|
| 336 |
+
if infiltration_rate < 0:
|
| 337 |
+
is_valid = False
|
| 338 |
+
messages.append("Infiltration rate must be non-negative.")
|
| 339 |
+
except (ValueError, TypeError):
|
| 340 |
+
is_valid = False
|
| 341 |
+
messages.append("Infiltration rate must be a number.")
|
| 342 |
+
|
| 343 |
+
# Check ventilation rate
|
| 344 |
+
if "ventilation_rate" in settings:
|
| 345 |
+
try:
|
| 346 |
+
ventilation_rate = float(settings["ventilation_rate"])
|
| 347 |
+
if ventilation_rate < 0:
|
| 348 |
+
is_valid = False
|
| 349 |
+
messages.append("Ventilation rate must be non-negative.")
|
| 350 |
+
except (ValueError, TypeError):
|
| 351 |
+
is_valid = False
|
| 352 |
+
messages.append("Ventilation rate must be a number.")
|
| 353 |
+
|
| 354 |
+
# Check safety factors
|
| 355 |
+
safety_factors = ["cooling_safety_factor", "heating_safety_factor"]
|
| 356 |
+
for factor in safety_factors:
|
| 357 |
+
if factor in settings:
|
| 358 |
+
try:
|
| 359 |
+
value = float(settings[factor])
|
| 360 |
+
if value < 0:
|
| 361 |
+
is_valid = False
|
| 362 |
+
messages.append(f"{factor.replace('_', ' ').title()} must be non-negative.")
|
| 363 |
+
except (ValueError, TypeError):
|
| 364 |
+
is_valid = False
|
| 365 |
+
messages.append(f"{factor.replace('_', ' ').title()} must be a number.")
|
| 366 |
+
|
| 367 |
+
return is_valid, messages
|
| 368 |
+
|
| 369 |
+
@staticmethod
|
| 370 |
+
def display_validation_messages(messages: List[str], container=None) -> None:
|
| 371 |
+
"""
|
| 372 |
+
Display validation messages in Streamlit.
|
| 373 |
+
|
| 374 |
+
Args:
|
| 375 |
+
messages: List of validation messages
|
| 376 |
+
container: Optional Streamlit container to display messages in
|
| 377 |
+
"""
|
| 378 |
+
if not messages:
|
| 379 |
+
return
|
| 380 |
+
|
| 381 |
+
# Separate errors and warnings
|
| 382 |
+
errors = [msg for msg in messages if not msg.startswith("Warning:")]
|
| 383 |
+
warnings = [msg for msg in messages if msg.startswith("Warning:")]
|
| 384 |
+
|
| 385 |
+
# Use provided container or st directly
|
| 386 |
+
display = container if container is not None else st
|
| 387 |
+
|
| 388 |
+
# Display errors
|
| 389 |
+
if errors:
|
| 390 |
+
error_msg = "Please fix the following errors:\n" + "\n".join([f"- {msg}" for msg in errors])
|
| 391 |
+
display.error(error_msg)
|
| 392 |
+
|
| 393 |
+
# Display warnings
|
| 394 |
+
if warnings:
|
| 395 |
+
warning_msg = "Warnings:\n" + "\n".join([f"- {msg[8:]}" for msg in warnings])
|
| 396 |
+
display.warning(warning_msg)
|
| 397 |
+
|
| 398 |
+
@staticmethod
|
| 399 |
+
def validate_and_proceed(
|
| 400 |
+
session_state: Dict[str, Any],
|
| 401 |
+
validation_function: Callable[[Dict[str, Any]], Tuple[bool, List[str]]],
|
| 402 |
+
data_key: str,
|
| 403 |
+
success_message: str = "Validation successful!",
|
| 404 |
+
proceed_callback: Optional[Callable] = None
|
| 405 |
+
) -> bool:
|
| 406 |
+
"""
|
| 407 |
+
Validate data and proceed if valid.
|
| 408 |
+
|
| 409 |
+
Args:
|
| 410 |
+
session_state: Streamlit session state
|
| 411 |
+
validation_function: Function to validate data
|
| 412 |
+
data_key: Key for data in session state
|
| 413 |
+
success_message: Message to display on success
|
| 414 |
+
proceed_callback: Optional callback function to execute if validation succeeds
|
| 415 |
+
|
| 416 |
+
Returns:
|
| 417 |
+
Boolean indicating whether validation succeeded
|
| 418 |
+
"""
|
| 419 |
+
if data_key not in session_state:
|
| 420 |
+
st.error(f"No {data_key.replace('_', ' ')} data found.")
|
| 421 |
+
return False
|
| 422 |
+
|
| 423 |
+
# Validate data
|
| 424 |
+
is_valid, messages = validation_function(session_state[data_key])
|
| 425 |
+
|
| 426 |
+
# Display validation messages
|
| 427 |
+
DataValidation.display_validation_messages(messages)
|
| 428 |
+
|
| 429 |
+
# Proceed if valid
|
| 430 |
+
if is_valid:
|
| 431 |
+
st.success(success_message)
|
| 432 |
+
|
| 433 |
+
# Execute callback if provided
|
| 434 |
+
if proceed_callback is not None:
|
| 435 |
+
proceed_callback()
|
| 436 |
+
|
| 437 |
+
return True
|
| 438 |
+
|
| 439 |
+
return False
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
# Create a singleton instance
|
| 443 |
+
data_validation = DataValidation()
|
| 444 |
+
|
| 445 |
+
# Example usage
|
| 446 |
+
if __name__ == "__main__":
|
| 447 |
+
import streamlit as st
|
| 448 |
+
|
| 449 |
+
# Initialize session state with dummy data for testing
|
| 450 |
+
if "building_info" not in st.session_state:
|
| 451 |
+
st.session_state["building_info"] = {
|
| 452 |
+
"project_name": "Test Project",
|
| 453 |
+
"building_name": "Test Building",
|
| 454 |
+
"location": "New York",
|
| 455 |
+
"climate_zone": "4A",
|
| 456 |
+
"building_type": "Office",
|
| 457 |
+
"floor_area": 1000.0,
|
| 458 |
+
"num_floors": 2,
|
| 459 |
+
"floor_height": 3.0,
|
| 460 |
+
"orientation": "NORTH",
|
| 461 |
+
"occupancy": 50,
|
| 462 |
+
"operating_hours": "8:00-18:00",
|
| 463 |
+
"design_conditions": {
|
| 464 |
+
"summer_outdoor_db": 35.0,
|
| 465 |
+
"summer_outdoor_wb": 25.0,
|
| 466 |
+
"summer_indoor_db": 24.0,
|
| 467 |
+
"summer_indoor_rh": 50.0,
|
| 468 |
+
"winter_outdoor_db": -5.0,
|
| 469 |
+
"winter_outdoor_rh": 80.0,
|
| 470 |
+
"winter_indoor_db": 21.0,
|
| 471 |
+
"winter_indoor_rh": 40.0
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
# Test validation
|
| 476 |
+
st.header("Test Building Information Validation")
|
| 477 |
+
|
| 478 |
+
# Add some invalid data for testing
|
| 479 |
+
if st.button("Make Data Invalid"):
|
| 480 |
+
st.session_state["building_info"]["floor_area"] = -100.0
|
| 481 |
+
st.session_state["building_info"]["design_conditions"]["summer_outdoor_wb"] = 40.0
|
| 482 |
+
|
| 483 |
+
# Validate building info
|
| 484 |
+
if st.button("Validate Building Info"):
|
| 485 |
+
data_validation.validate_and_proceed(
|
| 486 |
+
st.session_state,
|
| 487 |
+
data_validation.validate_building_info,
|
| 488 |
+
"building_info",
|
| 489 |
+
"Building information is valid!"
|
| 490 |
+
)
|
app/main.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HVAC Calculator Code Documentation
|
| 3 |
+
|
| 4 |
+
This module contains the main Streamlit application for the HVAC Calculator.
|
| 5 |
+
It provides a comprehensive interface for calculating heating and cooling loads
|
| 6 |
+
using ASHRAE methods.
|
| 7 |
+
|
| 8 |
+
Author: Dr Majed Abuseif
|
| 9 |
+
Date: March 2025
|
| 10 |
+
Version: 1.0.0
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import streamlit as st
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import numpy as np
|
| 16 |
+
import plotly.graph_objects as go
|
| 17 |
+
import plotly.express as px
|
| 18 |
+
import matplotlib.pyplot as plt
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 23 |
+
|
| 24 |
+
# Import application modules
|
| 25 |
+
from app.building_info_form import BuildingInfoForm
|
| 26 |
+
from app.component_selection import ComponentSelection
|
| 27 |
+
from app.results_display import ResultsDisplay
|
| 28 |
+
from app.data_validation import DataValidation
|
| 29 |
+
from app.data_persistence import DataPersistence
|
| 30 |
+
from app.data_export import DataExport
|
| 31 |
+
|
| 32 |
+
# Import data modules
|
| 33 |
+
from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
|
| 34 |
+
from data.reference_data import ReferenceData
|
| 35 |
+
from data.climate_data import ClimateData
|
| 36 |
+
from data.ashrae_tables import ASHRAETables
|
| 37 |
+
|
| 38 |
+
# Import utility modules
|
| 39 |
+
from utils.component_library import ComponentLibrary
|
| 40 |
+
from utils.u_value_calculator import UValueCalculator
|
| 41 |
+
from utils.shading_system import ShadingSystem
|
| 42 |
+
from utils.area_calculation_system import AreaCalculationSystem
|
| 43 |
+
from utils.psychrometrics import Psychrometrics
|
| 44 |
+
from utils.heat_transfer import HeatTransfer
|
| 45 |
+
from utils.cooling_load import CoolingLoad
|
| 46 |
+
from utils.heating_load import HeatingLoad
|
| 47 |
+
from utils.component_visualization import ComponentVisualization
|
| 48 |
+
from utils.scenario_comparison import ScenarioComparison
|
| 49 |
+
from utils.psychrometric_visualization import PsychrometricVisualization
|
| 50 |
+
from utils.time_based_visualization import TimeBasedVisualization
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class HVACCalculator:
|
| 54 |
+
"""
|
| 55 |
+
Main HVAC Calculator application class.
|
| 56 |
+
|
| 57 |
+
This class initializes the Streamlit application and manages the navigation
|
| 58 |
+
between different sections of the calculator.
|
| 59 |
+
|
| 60 |
+
Attributes:
|
| 61 |
+
building_info_form (BuildingInfoForm): Building information input form
|
| 62 |
+
component_selection (ComponentSelection): Component selection interface
|
| 63 |
+
results_display (ResultsDisplay): Results display module
|
| 64 |
+
data_validation (DataValidation): Data validation module
|
| 65 |
+
data_persistence (DataPersistence): Data persistence module
|
| 66 |
+
data_export (DataExport): Data export module
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
def __init__(self):
|
| 70 |
+
"""Initialize the HVAC Calculator application."""
|
| 71 |
+
# Set page configuration
|
| 72 |
+
st.set_page_config(
|
| 73 |
+
page_title="HVAC Load Calculator",
|
| 74 |
+
page_icon="🌡️",
|
| 75 |
+
layout="wide",
|
| 76 |
+
initial_sidebar_state="expanded"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Initialize session state if not exists
|
| 80 |
+
if 'page' not in st.session_state:
|
| 81 |
+
st.session_state.page = 'Building Information'
|
| 82 |
+
|
| 83 |
+
if 'building_info' not in st.session_state:
|
| 84 |
+
st.session_state.building_info = {}
|
| 85 |
+
|
| 86 |
+
if 'components' not in st.session_state:
|
| 87 |
+
st.session_state.components = {
|
| 88 |
+
'walls': [],
|
| 89 |
+
'roofs': [],
|
| 90 |
+
'floors': [],
|
| 91 |
+
'windows': [],
|
| 92 |
+
'doors': []
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
if 'internal_loads' not in st.session_state:
|
| 96 |
+
st.session_state.internal_loads = {
|
| 97 |
+
'people': [],
|
| 98 |
+
'lighting': [],
|
| 99 |
+
'equipment': []
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if 'calculation_results' not in st.session_state:
|
| 103 |
+
st.session_state.calculation_results = {
|
| 104 |
+
'cooling': {},
|
| 105 |
+
'heating': {}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
if 'scenarios' not in st.session_state:
|
| 109 |
+
st.session_state.scenarios = []
|
| 110 |
+
|
| 111 |
+
# Initialize application modules
|
| 112 |
+
self.building_info_form = BuildingInfoForm()
|
| 113 |
+
self.component_selection = ComponentSelection()
|
| 114 |
+
self.results_display = ResultsDisplay()
|
| 115 |
+
self.data_validation = DataValidation()
|
| 116 |
+
self.data_persistence = DataPersistence()
|
| 117 |
+
self.data_export = DataExport()
|
| 118 |
+
|
| 119 |
+
# Set up the application layout
|
| 120 |
+
self.setup_layout()
|
| 121 |
+
|
| 122 |
+
def setup_layout(self):
|
| 123 |
+
"""Set up the application layout with sidebar navigation."""
|
| 124 |
+
# Application title
|
| 125 |
+
st.sidebar.title("HVAC Load Calculator")
|
| 126 |
+
st.sidebar.markdown("---")
|
| 127 |
+
|
| 128 |
+
# Navigation
|
| 129 |
+
st.sidebar.subheader("Navigation")
|
| 130 |
+
pages = [
|
| 131 |
+
"Building Information",
|
| 132 |
+
"Climate Data",
|
| 133 |
+
"Building Components",
|
| 134 |
+
"Internal Loads",
|
| 135 |
+
"Calculation Results",
|
| 136 |
+
"Export Data"
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
selected_page = st.sidebar.radio("Go to", pages, index=pages.index(st.session_state.page))
|
| 140 |
+
|
| 141 |
+
# Update session state if page changed
|
| 142 |
+
if selected_page != st.session_state.page:
|
| 143 |
+
st.session_state.page = selected_page
|
| 144 |
+
|
| 145 |
+
# Display the selected page
|
| 146 |
+
self.display_page(st.session_state.page)
|
| 147 |
+
|
| 148 |
+
# Footer
|
| 149 |
+
st.sidebar.markdown("---")
|
| 150 |
+
st.sidebar.info(
|
| 151 |
+
"HVAC Load Calculator v1.0.0\n\n"
|
| 152 |
+
"Based on ASHRAE calculation methods\n\n"
|
| 153 |
+
"© 2025"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
def display_page(self, page: str):
|
| 157 |
+
"""
|
| 158 |
+
Display the selected page.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
page (str): The page to display
|
| 162 |
+
"""
|
| 163 |
+
if page == "Building Information":
|
| 164 |
+
self.building_info_form.display()
|
| 165 |
+
elif page == "Climate Data":
|
| 166 |
+
self.display_climate_data()
|
| 167 |
+
elif page == "Building Components":
|
| 168 |
+
self.component_selection.display()
|
| 169 |
+
elif page == "Internal Loads":
|
| 170 |
+
self.display_internal_loads()
|
| 171 |
+
elif page == "Calculation Results":
|
| 172 |
+
self.results_display.display()
|
| 173 |
+
elif page == "Export Data":
|
| 174 |
+
self.data_export.display()
|
| 175 |
+
|
| 176 |
+
def display_climate_data(self):
|
| 177 |
+
"""Display the climate data page."""
|
| 178 |
+
st.title("Climate Data")
|
| 179 |
+
|
| 180 |
+
# Check if building information is available
|
| 181 |
+
if not st.session_state.building_info:
|
| 182 |
+
st.warning("Please enter building information first.")
|
| 183 |
+
st.button("Go to Building Information", on_click=self.navigate_to, args=["Building Information"])
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
# Get climate zone from building information
|
| 187 |
+
climate_zone = st.session_state.building_info.get('climate_zone')
|
| 188 |
+
if not climate_zone:
|
| 189 |
+
st.error("Climate zone not found in building information.")
|
| 190 |
+
st.button("Go to Building Information", on_click=self.navigate_to, args=["Building Information"])
|
| 191 |
+
return
|
| 192 |
+
|
| 193 |
+
# Display climate zone information
|
| 194 |
+
st.subheader(f"Climate Zone: {climate_zone}")
|
| 195 |
+
|
| 196 |
+
# Get design conditions
|
| 197 |
+
design_conditions = ClimateData.get_design_conditions(climate_zone)
|
| 198 |
+
|
| 199 |
+
# Display design conditions
|
| 200 |
+
st.subheader("Design Conditions")
|
| 201 |
+
col1, col2 = st.columns(2)
|
| 202 |
+
|
| 203 |
+
with col1:
|
| 204 |
+
st.write("Summer Design Conditions")
|
| 205 |
+
summer_data = pd.DataFrame({
|
| 206 |
+
"Parameter": ["Dry-Bulb Temperature", "Wet-Bulb Temperature", "Dew Point"],
|
| 207 |
+
"Value": [
|
| 208 |
+
f"{design_conditions['summer']['db']} °C",
|
| 209 |
+
f"{design_conditions['summer']['wb']} °C",
|
| 210 |
+
f"{design_conditions['summer']['dp']} °C"
|
| 211 |
+
]
|
| 212 |
+
})
|
| 213 |
+
st.table(summer_data)
|
| 214 |
+
|
| 215 |
+
with col2:
|
| 216 |
+
st.write("Winter Design Conditions")
|
| 217 |
+
winter_data = pd.DataFrame({
|
| 218 |
+
"Parameter": ["Dry-Bulb Temperature", "Relative Humidity"],
|
| 219 |
+
"Value": [
|
| 220 |
+
f"{design_conditions['winter']['db']} °C",
|
| 221 |
+
f"{design_conditions['winter']['rh']} %"
|
| 222 |
+
]
|
| 223 |
+
})
|
| 224 |
+
st.table(winter_data)
|
| 225 |
+
|
| 226 |
+
# Get monthly temperature data
|
| 227 |
+
monthly_temps = ClimateData.get_monthly_temperatures(climate_zone)
|
| 228 |
+
|
| 229 |
+
# Prepare data for plotting
|
| 230 |
+
months = list(range(1, 13))
|
| 231 |
+
avg_temps = [monthly_temps[m]['avg_db'] for m in months]
|
| 232 |
+
max_temps = [monthly_temps[m]['max_db'] for m in months]
|
| 233 |
+
min_temps = [monthly_temps[m]['min_db'] for m in months]
|
| 234 |
+
|
| 235 |
+
# Plot monthly temperature data
|
| 236 |
+
st.subheader("Monthly Temperature Data")
|
| 237 |
+
fig = go.Figure()
|
| 238 |
+
|
| 239 |
+
fig.add_trace(go.Scatter(
|
| 240 |
+
x=months,
|
| 241 |
+
y=max_temps,
|
| 242 |
+
mode='lines+markers',
|
| 243 |
+
name='Maximum Temperature',
|
| 244 |
+
line=dict(color='red')
|
| 245 |
+
))
|
| 246 |
+
|
| 247 |
+
fig.add_trace(go.Scatter(
|
| 248 |
+
x=months,
|
| 249 |
+
y=avg_temps,
|
| 250 |
+
mode='lines+markers',
|
| 251 |
+
name='Average Temperature',
|
| 252 |
+
line=dict(color='green')
|
| 253 |
+
))
|
| 254 |
+
|
| 255 |
+
fig.add_trace(go.Scatter(
|
| 256 |
+
x=months,
|
| 257 |
+
y=min_temps,
|
| 258 |
+
mode='lines+markers',
|
| 259 |
+
name='Minimum Temperature',
|
| 260 |
+
line=dict(color='blue')
|
| 261 |
+
))
|
| 262 |
+
|
| 263 |
+
fig.update_layout(
|
| 264 |
+
title='Monthly Temperature Data',
|
| 265 |
+
xaxis_title='Month',
|
| 266 |
+
yaxis_title='Temperature (°C)',
|
| 267 |
+
xaxis=dict(
|
| 268 |
+
tickmode='array',
|
| 269 |
+
tickvals=months,
|
| 270 |
+
ticktext=['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
| 271 |
+
)
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 275 |
+
|
| 276 |
+
# Navigation buttons
|
| 277 |
+
col1, col2 = st.columns(2)
|
| 278 |
+
with col1:
|
| 279 |
+
st.button("Back to Building Information", on_click=self.navigate_to, args=["Building Information"])
|
| 280 |
+
with col2:
|
| 281 |
+
st.button("Continue to Building Components", on_click=self.navigate_to, args=["Building Components"])
|
| 282 |
+
|
| 283 |
+
def display_internal_loads(self):
|
| 284 |
+
"""Display the internal loads page."""
|
| 285 |
+
st.title("Internal Loads")
|
| 286 |
+
|
| 287 |
+
# Check if building components are available
|
| 288 |
+
if not any(st.session_state.components.values()):
|
| 289 |
+
st.warning("Please define building components first.")
|
| 290 |
+
st.button("Go to Building Components", on_click=self.navigate_to, args=["Building Components"])
|
| 291 |
+
return
|
| 292 |
+
|
| 293 |
+
# Tabs for different internal load types
|
| 294 |
+
tabs = st.tabs(["People", "Lighting", "Equipment"])
|
| 295 |
+
|
| 296 |
+
# People tab
|
| 297 |
+
with tabs[0]:
|
| 298 |
+
self.display_people_loads()
|
| 299 |
+
|
| 300 |
+
# Lighting tab
|
| 301 |
+
with tabs[1]:
|
| 302 |
+
self.display_lighting_loads()
|
| 303 |
+
|
| 304 |
+
# Equipment tab
|
| 305 |
+
with tabs[2]:
|
| 306 |
+
self.display_equipment_loads()
|
| 307 |
+
|
| 308 |
+
# Display summary of internal loads
|
| 309 |
+
self.display_internal_loads_summary()
|
| 310 |
+
|
| 311 |
+
# Navigation buttons
|
| 312 |
+
col1, col2 = st.columns(2)
|
| 313 |
+
with col1:
|
| 314 |
+
st.button("Back to Building Components", on_click=self.navigate_to, args=["Building Components"])
|
| 315 |
+
with col2:
|
| 316 |
+
# Validate internal loads before proceeding
|
| 317 |
+
if self.data_validation.validate_internal_loads():
|
| 318 |
+
st.button("Continue to Calculation Results", on_click=self.navigate_to, args=["Calculation Results"])
|
| 319 |
+
else:
|
| 320 |
+
st.button("Continue to Calculation Results", disabled=True)
|
| 321 |
+
|
| 322 |
+
def display_people_loads(self):
|
| 323 |
+
"""Display the people loads section."""
|
| 324 |
+
st.subheader("People")
|
| 325 |
+
|
| 326 |
+
# Form for adding people loads
|
| 327 |
+
with st.form("people_load_form"):
|
| 328 |
+
col1, col2 = st.columns(2)
|
| 329 |
+
|
| 330 |
+
with col1:
|
| 331 |
+
name = st.text_input("Name", "Occupants")
|
| 332 |
+
num_people = st.number_input("Number of People", min_value=1, value=10)
|
| 333 |
+
|
| 334 |
+
with col2:
|
| 335 |
+
activity_level = st.selectbox(
|
| 336 |
+
"Activity Level",
|
| 337 |
+
options=["Seated, resting", "Seated, light work", "Office work", "Standing, light work", "Walking", "Heavy work"]
|
| 338 |
+
)
|
| 339 |
+
zone_type = st.selectbox(
|
| 340 |
+
"Zone Type",
|
| 341 |
+
options=["Office", "Residential", "Retail", "Educational", "Healthcare"]
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
hours_in_operation = st.slider("Hours in Operation", min_value=1, max_value=24, value=8)
|
| 345 |
+
|
| 346 |
+
submitted = st.form_submit_button("Add People Load")
|
| 347 |
+
|
| 348 |
+
if submitted:
|
| 349 |
+
# Create people load
|
| 350 |
+
people_load = {
|
| 351 |
+
"id": f"people_{len(st.session_state.internal_loads['people'])}",
|
| 352 |
+
"name": name,
|
| 353 |
+
"num_people": num_people,
|
| 354 |
+
"activity_level": activity_level,
|
| 355 |
+
"zone_type": zone_type,
|
| 356 |
+
"hours_in_operation": hours_in_operation
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
# Add to session state
|
| 360 |
+
st.session_state.internal_loads['people'].append(people_load)
|
| 361 |
+
st.success(f"Added {name} with {num_people} people")
|
| 362 |
+
|
| 363 |
+
# Display existing people loads
|
| 364 |
+
if st.session_state.internal_loads['people']:
|
| 365 |
+
st.subheader("Existing People Loads")
|
| 366 |
+
|
| 367 |
+
people_data = []
|
| 368 |
+
for load in st.session_state.internal_loads['people']:
|
| 369 |
+
people_data.append({
|
| 370 |
+
"Name": load['name'],
|
| 371 |
+
"Number of People": load['num_people'],
|
| 372 |
+
"Activity Level": load['activity_level'],
|
| 373 |
+
"Zone Type": load['zone_type'],
|
| 374 |
+
"Hours in Operation": load['hours_in_operation'],
|
| 375 |
+
"Actions": load['id']
|
| 376 |
+
})
|
| 377 |
+
|
| 378 |
+
df = pd.DataFrame(people_data)
|
| 379 |
+
|
| 380 |
+
# Display table with edit and delete buttons
|
| 381 |
+
for i, row in df.iterrows():
|
| 382 |
+
col1, col2, col3, col4, col5, col6, col7 = st.columns([2, 1, 2, 2, 1, 1, 1])
|
| 383 |
+
|
| 384 |
+
with col1:
|
| 385 |
+
st.write(row["Name"])
|
| 386 |
+
with col2:
|
| 387 |
+
st.write(row["Number of People"])
|
| 388 |
+
with col3:
|
| 389 |
+
st.write(row["Activity Level"])
|
| 390 |
+
with col4:
|
| 391 |
+
st.write(row["Zone Type"])
|
| 392 |
+
with col5:
|
| 393 |
+
st.write(row["Hours in Operation"])
|
| 394 |
+
with col6:
|
| 395 |
+
if st.button("Edit", key=f"edit_{row['Actions']}"):
|
| 396 |
+
# Set session state for editing
|
| 397 |
+
st.session_state.editing_people = row['Actions']
|
| 398 |
+
with col7:
|
| 399 |
+
if st.button("Delete", key=f"delete_{row['Actions']}"):
|
| 400 |
+
# Remove from session state
|
| 401 |
+
st.session_state.internal_loads['people'] = [
|
| 402 |
+
load for load in st.session_state.internal_loads['people']
|
| 403 |
+
if load['id'] != row['Actions']
|
| 404 |
+
]
|
| 405 |
+
st.experimental_rerun()
|
| 406 |
+
|
| 407 |
+
def display_lighting_loads(self):
|
| 408 |
+
"""Display the lighting loads section."""
|
| 409 |
+
st.subheader("Lighting")
|
| 410 |
+
|
| 411 |
+
# Form for adding lighting loads
|
| 412 |
+
with st.form("lighting_load_form"):
|
| 413 |
+
col1, col2 = st.columns(2)
|
| 414 |
+
|
| 415 |
+
with col1:
|
| 416 |
+
name = st.text_input("Name", "General Lighting")
|
| 417 |
+
power = st.number_input("Power (W)", min_value=0.0, value=1000.0)
|
| 418 |
+
|
| 419 |
+
with col2:
|
| 420 |
+
usage_factor = st.slider("Usage Factor", min_value=0.0, max_value=1.0, value=0.8, step=0.1)
|
| 421 |
+
zone_type = st.selectbox(
|
| 422 |
+
"Zone Type",
|
| 423 |
+
options=["Office", "Residential", "Retail", "Educational", "Healthcare"]
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
hours_in_operation = st.slider("Hours in Operation", min_value=1, max_value=24, value=8)
|
| 427 |
+
|
| 428 |
+
submitted = st.form_submit_button("Add Lighting Load")
|
| 429 |
+
|
| 430 |
+
if submitted:
|
| 431 |
+
# Create lighting load
|
| 432 |
+
lighting_load = {
|
| 433 |
+
"id": f"lighting_{len(st.session_state.internal_loads['lighting'])}",
|
| 434 |
+
"name": name,
|
| 435 |
+
"power": power,
|
| 436 |
+
"usage_factor": usage_factor,
|
| 437 |
+
"zone_type": zone_type,
|
| 438 |
+
"hours_in_operation": hours_in_operation
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
# Add to session state
|
| 442 |
+
st.session_state.internal_loads['lighting'].append(lighting_load)
|
| 443 |
+
st.success(f"Added {name} with {power} W")
|
| 444 |
+
|
| 445 |
+
# Display existing lighting loads
|
| 446 |
+
if st.session_state.internal_loads['lighting']:
|
| 447 |
+
st.subheader("Existing Lighting Loads")
|
| 448 |
+
|
| 449 |
+
lighting_data = []
|
| 450 |
+
for load in st.session_state.internal_loads['lighting']:
|
| 451 |
+
lighting_data.append({
|
| 452 |
+
"Name": load['name'],
|
| 453 |
+
"Power (W)": load['power'],
|
| 454 |
+
"Usage Factor": load['usage_factor'],
|
| 455 |
+
"Zone Type": load['zone_type'],
|
| 456 |
+
"Hours in Operation": load['hours_in_operation'],
|
| 457 |
+
"Actions": load['id']
|
| 458 |
+
})
|
| 459 |
+
|
| 460 |
+
df = pd.DataFrame(lighting_data)
|
| 461 |
+
|
| 462 |
+
# Display table with edit and delete buttons
|
| 463 |
+
for i, row in df.iterrows():
|
| 464 |
+
col1, col2, col3, col4, col5, col6, col7 = st.columns([2, 1, 1, 2, 1, 1, 1])
|
| 465 |
+
|
| 466 |
+
with col1:
|
| 467 |
+
st.write(row["Name"])
|
| 468 |
+
with col2:
|
| 469 |
+
st.write(f"{row['Power (W)']:.1f}")
|
| 470 |
+
with col3:
|
| 471 |
+
st.write(f"{row['Usage Factor']:.1f}")
|
| 472 |
+
with col4:
|
| 473 |
+
st.write(row["Zone Type"])
|
| 474 |
+
with col5:
|
| 475 |
+
st.write(row["Hours in Operation"])
|
| 476 |
+
with col6:
|
| 477 |
+
if st.button("Edit", key=f"edit_{row['Actions']}"):
|
| 478 |
+
# Set session state for editing
|
| 479 |
+
st.session_state.editing_lighting = row['Actions']
|
| 480 |
+
with col7:
|
| 481 |
+
if st.button("Delete", key=f"delete_{row['Actions']}"):
|
| 482 |
+
# Remove from session state
|
| 483 |
+
st.session_state.internal_loads['lighting'] = [
|
| 484 |
+
load for load in st.session_state.internal_loads['lighting']
|
| 485 |
+
if load['id'] != row['Actions']
|
| 486 |
+
]
|
| 487 |
+
st.experimental_rerun()
|
| 488 |
+
|
| 489 |
+
def display_equipment_loads(self):
|
| 490 |
+
"""Display the equipment loads section."""
|
| 491 |
+
st.subheader("Equipment")
|
| 492 |
+
|
| 493 |
+
# Form for adding equipment loads
|
| 494 |
+
with st.form("equipment_load_form"):
|
| 495 |
+
col1, col2 = st.columns(2)
|
| 496 |
+
|
| 497 |
+
with col1:
|
| 498 |
+
name = st.text_input("Name", "Office Equipment")
|
| 499 |
+
power = st.number_input("Power (W)", min_value=0.0, value=500.0)
|
| 500 |
+
|
| 501 |
+
with col2:
|
| 502 |
+
usage_factor = st.slider("Usage Factor", min_value=0.0, max_value=1.0, value=0.7, step=0.1)
|
| 503 |
+
radiation_fraction = st.slider("Radiation Fraction", min_value=0.0, max_value=1.0, value=0.3, step=0.1)
|
| 504 |
+
|
| 505 |
+
zone_type = st.selectbox(
|
| 506 |
+
"Zone Type",
|
| 507 |
+
options=["Office", "Residential", "Retail", "Educational", "Healthcare"]
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
hours_in_operation = st.slider("Hours in Operation", min_value=1, max_value=24, value=8)
|
| 511 |
+
|
| 512 |
+
submitted = st.form_submit_button("Add Equipment Load")
|
| 513 |
+
|
| 514 |
+
if submitted:
|
| 515 |
+
# Create equipment load
|
| 516 |
+
equipment_load = {
|
| 517 |
+
"id": f"equipment_{len(st.session_state.internal_loads['equipment'])}",
|
| 518 |
+
"name": name,
|
| 519 |
+
"power": power,
|
| 520 |
+
"usage_factor": usage_factor,
|
| 521 |
+
"radiation_fraction": radiation_fraction,
|
| 522 |
+
"zone_type": zone_type,
|
| 523 |
+
"hours_in_operation": hours_in_operation
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
# Add to session state
|
| 527 |
+
st.session_state.internal_loads['equipment'].append(equipment_load)
|
| 528 |
+
st.success(f"Added {name} with {power} W")
|
| 529 |
+
|
| 530 |
+
# Display existing equipment loads
|
| 531 |
+
if st.session_state.internal_loads['equipment']:
|
| 532 |
+
st.subheader("Existing Equipment Loads")
|
| 533 |
+
|
| 534 |
+
equipment_data = []
|
| 535 |
+
for load in st.session_state.internal_loads['equipment']:
|
| 536 |
+
equipment_data.append({
|
| 537 |
+
"Name": load['name'],
|
| 538 |
+
"Power (W)": load['power'],
|
| 539 |
+
"Usage Factor": load['usage_factor'],
|
| 540 |
+
"Radiation Fraction": load['radiation_fraction'],
|
| 541 |
+
"Zone Type": load['zone_type'],
|
| 542 |
+
"Hours in Operation": load['hours_in_operation'],
|
| 543 |
+
"Actions": load['id']
|
| 544 |
+
})
|
| 545 |
+
|
| 546 |
+
df = pd.DataFrame(equipment_data)
|
| 547 |
+
|
| 548 |
+
# Display table with edit and delete buttons
|
| 549 |
+
for i, row in df.iterrows():
|
| 550 |
+
col1, col2, col3, col4, col5, col6, col7, col8 = st.columns([2, 1, 1, 1, 2, 1, 1, 1])
|
| 551 |
+
|
| 552 |
+
with col1:
|
| 553 |
+
st.write(row["Name"])
|
| 554 |
+
with col2:
|
| 555 |
+
st.write(f"{row['Power (W)']:.1f}")
|
| 556 |
+
with col3:
|
| 557 |
+
st.write(f"{row['Usage Factor']:.1f}")
|
| 558 |
+
with col4:
|
| 559 |
+
st.write(f"{row['Radiation Fraction']:.1f}")
|
| 560 |
+
with col5:
|
| 561 |
+
st.write(row["Zone Type"])
|
| 562 |
+
with col6:
|
| 563 |
+
st.write(row["Hours in Operation"])
|
| 564 |
+
with col7:
|
| 565 |
+
if st.button("Edit", key=f"edit_{row['Actions']}"):
|
| 566 |
+
# Set session state for editing
|
| 567 |
+
st.session_state.editing_equipment = row['Actions']
|
| 568 |
+
with col8:
|
| 569 |
+
if st.button("Delete", key=f"delete_{row['Actions']}"):
|
| 570 |
+
# Remove from session state
|
| 571 |
+
st.session_state.internal_loads['equipment'] = [
|
| 572 |
+
load for load in st.session_state.internal_loads['equipment']
|
| 573 |
+
if load['id'] != row['Actions']
|
| 574 |
+
]
|
| 575 |
+
st.experimental_rerun()
|
| 576 |
+
|
| 577 |
+
def display_internal_loads_summary(self):
|
| 578 |
+
"""Display a summary of all internal loads."""
|
| 579 |
+
st.subheader("Internal Loads Summary")
|
| 580 |
+
|
| 581 |
+
# Check if any internal loads exist
|
| 582 |
+
if not any(st.session_state.internal_loads.values()):
|
| 583 |
+
st.info("No internal loads defined yet.")
|
| 584 |
+
return
|
| 585 |
+
|
| 586 |
+
# Calculate total loads
|
| 587 |
+
total_people = sum(load['num_people'] for load in st.session_state.internal_loads['people'])
|
| 588 |
+
total_lighting_power = sum(load['power'] * load['usage_factor'] for load in st.session_state.internal_loads['lighting'])
|
| 589 |
+
total_equipment_power = sum(load['power'] * load['usage_factor'] for load in st.session_state.internal_loads['equipment'])
|
| 590 |
+
|
| 591 |
+
# Display summary
|
| 592 |
+
col1, col2, col3 = st.columns(3)
|
| 593 |
+
|
| 594 |
+
with col1:
|
| 595 |
+
st.metric("Total People", f"{total_people}")
|
| 596 |
+
|
| 597 |
+
with col2:
|
| 598 |
+
st.metric("Total Lighting Power", f"{total_lighting_power:.1f} W")
|
| 599 |
+
|
| 600 |
+
with col3:
|
| 601 |
+
st.metric("Total Equipment Power", f"{total_equipment_power:.1f} W")
|
| 602 |
+
|
| 603 |
+
# Display pie chart of internal loads
|
| 604 |
+
if total_lighting_power > 0 or total_equipment_power > 0:
|
| 605 |
+
# Estimate people load (assuming 100W per person)
|
| 606 |
+
people_power = total_people * 100
|
| 607 |
+
|
| 608 |
+
# Create pie chart
|
| 609 |
+
fig = px.pie(
|
| 610 |
+
values=[people_power, total_lighting_power, total_equipment_power],
|
| 611 |
+
names=['People', 'Lighting', 'Equipment'],
|
| 612 |
+
title='Internal Loads Distribution'
|
| 613 |
+
)
|
| 614 |
+
|
| 615 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 616 |
+
|
| 617 |
+
def navigate_to(self, page: str):
|
| 618 |
+
"""
|
| 619 |
+
Navigate to the specified page.
|
| 620 |
+
|
| 621 |
+
Args:
|
| 622 |
+
page (str): The page to navigate to
|
| 623 |
+
"""
|
| 624 |
+
st.session_state.page = page
|
| 625 |
+
st.experimental_rerun()
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
if __name__ == "__main__":
|
| 629 |
+
# Create and run the HVAC Calculator application
|
| 630 |
+
app = HVACCalculator()
|
app/results_display.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Results display module for HVAC Load Calculator.
|
| 3 |
+
This module provides the UI components for displaying calculation results.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import plotly.graph_objects as go
|
| 13 |
+
import plotly.express as px
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
|
| 16 |
+
# Import visualization modules
|
| 17 |
+
from utils.component_visualization import ComponentVisualization
|
| 18 |
+
from utils.scenario_comparison import ScenarioComparison
|
| 19 |
+
from utils.psychrometric_visualization import PsychrometricVisualization
|
| 20 |
+
from utils.time_based_visualization import TimeBasedVisualization
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ResultsDisplay:
|
| 24 |
+
"""Class for results display interface."""
|
| 25 |
+
|
| 26 |
+
def __init__(self):
|
| 27 |
+
"""Initialize results display interface."""
|
| 28 |
+
self.component_visualization = ComponentVisualization()
|
| 29 |
+
self.scenario_comparison = ScenarioComparison()
|
| 30 |
+
self.psychrometric_visualization = PsychrometricVisualization()
|
| 31 |
+
self.time_based_visualization = TimeBasedVisualization()
|
| 32 |
+
|
| 33 |
+
def display_results(self, session_state: Dict[str, Any]) -> None:
|
| 34 |
+
"""
|
| 35 |
+
Display calculation results in Streamlit.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
session_state: Streamlit session state containing calculation results
|
| 39 |
+
"""
|
| 40 |
+
st.header("Calculation Results")
|
| 41 |
+
|
| 42 |
+
# Check if calculations have been performed
|
| 43 |
+
if "calculation_results" not in session_state or not session_state["calculation_results"]:
|
| 44 |
+
st.warning("No calculation results available. Please run calculations first.")
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
# Create tabs for different result views
|
| 48 |
+
tab1, tab2, tab3, tab4, tab5 = st.tabs([
|
| 49 |
+
"Summary",
|
| 50 |
+
"Component Breakdown",
|
| 51 |
+
"Psychrometric Analysis",
|
| 52 |
+
"Time Analysis",
|
| 53 |
+
"Scenario Comparison"
|
| 54 |
+
])
|
| 55 |
+
|
| 56 |
+
with tab1:
|
| 57 |
+
self._display_summary_results(session_state)
|
| 58 |
+
|
| 59 |
+
with tab2:
|
| 60 |
+
self._display_component_breakdown(session_state)
|
| 61 |
+
|
| 62 |
+
with tab3:
|
| 63 |
+
self._display_psychrometric_analysis(session_state)
|
| 64 |
+
|
| 65 |
+
with tab4:
|
| 66 |
+
self._display_time_analysis(session_state)
|
| 67 |
+
|
| 68 |
+
with tab5:
|
| 69 |
+
self._display_scenario_comparison(session_state)
|
| 70 |
+
|
| 71 |
+
def _display_summary_results(self, session_state: Dict[str, Any]) -> None:
|
| 72 |
+
"""
|
| 73 |
+
Display summary of calculation results.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
session_state: Streamlit session state containing calculation results
|
| 77 |
+
"""
|
| 78 |
+
st.subheader("Summary Results")
|
| 79 |
+
|
| 80 |
+
results = session_state["calculation_results"]
|
| 81 |
+
|
| 82 |
+
# Display project information
|
| 83 |
+
if "building_info" in session_state:
|
| 84 |
+
st.write(f"**Project:** {session_state['building_info']['project_name']}")
|
| 85 |
+
st.write(f"**Building:** {session_state['building_info']['building_name']}")
|
| 86 |
+
st.write(f"**Location:** {session_state['building_info']['location']}")
|
| 87 |
+
st.write(f"**Climate Zone:** {session_state['building_info']['climate_zone']}")
|
| 88 |
+
st.write(f"**Floor Area:** {session_state['building_info']['floor_area']} m²")
|
| 89 |
+
|
| 90 |
+
# Create columns for cooling and heating loads
|
| 91 |
+
col1, col2 = st.columns(2)
|
| 92 |
+
|
| 93 |
+
with col1:
|
| 94 |
+
st.write("### Cooling Load Results")
|
| 95 |
+
|
| 96 |
+
# Display cooling load metrics
|
| 97 |
+
cooling_metrics = [
|
| 98 |
+
{"name": "Total Cooling Load", "value": results["cooling"]["total_load"], "unit": "kW"},
|
| 99 |
+
{"name": "Sensible Cooling Load", "value": results["cooling"]["sensible_load"], "unit": "kW"},
|
| 100 |
+
{"name": "Latent Cooling Load", "value": results["cooling"]["latent_load"], "unit": "kW"},
|
| 101 |
+
{"name": "Cooling Load per Area", "value": results["cooling"]["load_per_area"], "unit": "W/m²"}
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
for metric in cooling_metrics:
|
| 105 |
+
st.metric(
|
| 106 |
+
label=metric["name"],
|
| 107 |
+
value=f"{metric['value']:.2f} {metric['unit']}"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Display cooling load pie chart
|
| 111 |
+
cooling_breakdown = {
|
| 112 |
+
"Walls": results["cooling"]["component_loads"]["walls"],
|
| 113 |
+
"Roof": results["cooling"]["component_loads"]["roof"],
|
| 114 |
+
"Windows": results["cooling"]["component_loads"]["windows"],
|
| 115 |
+
"Doors": results["cooling"]["component_loads"]["doors"],
|
| 116 |
+
"People": results["cooling"]["component_loads"]["people"],
|
| 117 |
+
"Lighting": results["cooling"]["component_loads"]["lighting"],
|
| 118 |
+
"Equipment": results["cooling"]["component_loads"]["equipment"],
|
| 119 |
+
"Infiltration": results["cooling"]["component_loads"]["infiltration"],
|
| 120 |
+
"Ventilation": results["cooling"]["component_loads"]["ventilation"]
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
fig = px.pie(
|
| 124 |
+
values=list(cooling_breakdown.values()),
|
| 125 |
+
names=list(cooling_breakdown.keys()),
|
| 126 |
+
title="Cooling Load Breakdown",
|
| 127 |
+
color_discrete_sequence=px.colors.qualitative.Pastel
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
fig.update_traces(textposition='inside', textinfo='percent+label')
|
| 131 |
+
fig.update_layout(uniformtext_minsize=12, uniformtext_mode='hide')
|
| 132 |
+
|
| 133 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 134 |
+
|
| 135 |
+
with col2:
|
| 136 |
+
st.write("### Heating Load Results")
|
| 137 |
+
|
| 138 |
+
# Display heating load metrics
|
| 139 |
+
heating_metrics = [
|
| 140 |
+
{"name": "Total Heating Load", "value": results["heating"]["total_load"], "unit": "kW"},
|
| 141 |
+
{"name": "Heating Load per Area", "value": results["heating"]["load_per_area"], "unit": "W/m²"},
|
| 142 |
+
{"name": "Design Heat Loss", "value": results["heating"]["design_heat_loss"], "unit": "kW"},
|
| 143 |
+
{"name": "Safety Factor", "value": results["heating"]["safety_factor"], "unit": "%"}
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
for metric in heating_metrics:
|
| 147 |
+
st.metric(
|
| 148 |
+
label=metric["name"],
|
| 149 |
+
value=f"{metric['value']:.2f} {metric['unit']}"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# Display heating load pie chart
|
| 153 |
+
heating_breakdown = {
|
| 154 |
+
"Walls": results["heating"]["component_loads"]["walls"],
|
| 155 |
+
"Roof": results["heating"]["component_loads"]["roof"],
|
| 156 |
+
"Floor": results["heating"]["component_loads"]["floor"],
|
| 157 |
+
"Windows": results["heating"]["component_loads"]["windows"],
|
| 158 |
+
"Doors": results["heating"]["component_loads"]["doors"],
|
| 159 |
+
"Infiltration": results["heating"]["component_loads"]["infiltration"],
|
| 160 |
+
"Ventilation": results["heating"]["component_loads"]["ventilation"]
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
fig = px.pie(
|
| 164 |
+
values=list(heating_breakdown.values()),
|
| 165 |
+
names=list(heating_breakdown.keys()),
|
| 166 |
+
title="Heating Load Breakdown",
|
| 167 |
+
color_discrete_sequence=px.colors.qualitative.Pastel
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
fig.update_traces(textposition='inside', textinfo='percent+label')
|
| 171 |
+
fig.update_layout(uniformtext_minsize=12, uniformtext_mode='hide')
|
| 172 |
+
|
| 173 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 174 |
+
|
| 175 |
+
# Display tabular results
|
| 176 |
+
st.subheader("Detailed Results")
|
| 177 |
+
|
| 178 |
+
# Create tabs for cooling and heating tables
|
| 179 |
+
tab1, tab2 = st.tabs(["Cooling Load Details", "Heating Load Details"])
|
| 180 |
+
|
| 181 |
+
with tab1:
|
| 182 |
+
# Create cooling load details table
|
| 183 |
+
cooling_details = []
|
| 184 |
+
|
| 185 |
+
# Add envelope components
|
| 186 |
+
for wall in results["cooling"]["detailed_loads"]["walls"]:
|
| 187 |
+
cooling_details.append({
|
| 188 |
+
"Component Type": "Wall",
|
| 189 |
+
"Name": wall["name"],
|
| 190 |
+
"Orientation": wall["orientation"],
|
| 191 |
+
"Area (m²)": wall["area"],
|
| 192 |
+
"U-Value (W/m²·K)": wall["u_value"],
|
| 193 |
+
"CLTD (°C)": wall["cltd"],
|
| 194 |
+
"Load (kW)": wall["load"]
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
for roof in results["cooling"]["detailed_loads"]["roofs"]:
|
| 198 |
+
cooling_details.append({
|
| 199 |
+
"Component Type": "Roof",
|
| 200 |
+
"Name": roof["name"],
|
| 201 |
+
"Orientation": roof["orientation"],
|
| 202 |
+
"Area (m²)": roof["area"],
|
| 203 |
+
"U-Value (W/m²·K)": roof["u_value"],
|
| 204 |
+
"CLTD (°C)": roof["cltd"],
|
| 205 |
+
"Load (kW)": roof["load"]
|
| 206 |
+
})
|
| 207 |
+
|
| 208 |
+
for window in results["cooling"]["detailed_loads"]["windows"]:
|
| 209 |
+
cooling_details.append({
|
| 210 |
+
"Component Type": "Window",
|
| 211 |
+
"Name": window["name"],
|
| 212 |
+
"Orientation": window["orientation"],
|
| 213 |
+
"Area (m²)": window["area"],
|
| 214 |
+
"U-Value (W/m²·K)": window["u_value"],
|
| 215 |
+
"SHGC": window["shgc"],
|
| 216 |
+
"SCL (W/m²)": window["scl"],
|
| 217 |
+
"Load (kW)": window["load"]
|
| 218 |
+
})
|
| 219 |
+
|
| 220 |
+
for door in results["cooling"]["detailed_loads"]["doors"]:
|
| 221 |
+
cooling_details.append({
|
| 222 |
+
"Component Type": "Door",
|
| 223 |
+
"Name": door["name"],
|
| 224 |
+
"Orientation": door["orientation"],
|
| 225 |
+
"Area (m²)": door["area"],
|
| 226 |
+
"U-Value (W/m²·K)": door["u_value"],
|
| 227 |
+
"CLTD (°C)": door["cltd"],
|
| 228 |
+
"Load (kW)": door["load"]
|
| 229 |
+
})
|
| 230 |
+
|
| 231 |
+
# Add internal loads
|
| 232 |
+
for internal_load in results["cooling"]["detailed_loads"]["internal"]:
|
| 233 |
+
cooling_details.append({
|
| 234 |
+
"Component Type": internal_load["type"],
|
| 235 |
+
"Name": internal_load["name"],
|
| 236 |
+
"Quantity": internal_load["quantity"],
|
| 237 |
+
"Heat Gain (W)": internal_load["heat_gain"],
|
| 238 |
+
"CLF": internal_load["clf"],
|
| 239 |
+
"Load (kW)": internal_load["load"]
|
| 240 |
+
})
|
| 241 |
+
|
| 242 |
+
# Add infiltration and ventilation
|
| 243 |
+
cooling_details.append({
|
| 244 |
+
"Component Type": "Infiltration",
|
| 245 |
+
"Name": "Air Infiltration",
|
| 246 |
+
"Air Flow (m³/s)": results["cooling"]["detailed_loads"]["infiltration"]["air_flow"],
|
| 247 |
+
"Sensible Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["sensible_load"],
|
| 248 |
+
"Latent Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["latent_load"],
|
| 249 |
+
"Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["total_load"]
|
| 250 |
+
})
|
| 251 |
+
|
| 252 |
+
cooling_details.append({
|
| 253 |
+
"Component Type": "Ventilation",
|
| 254 |
+
"Name": "Fresh Air",
|
| 255 |
+
"Air Flow (m³/s)": results["cooling"]["detailed_loads"]["ventilation"]["air_flow"],
|
| 256 |
+
"Sensible Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["sensible_load"],
|
| 257 |
+
"Latent Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["latent_load"],
|
| 258 |
+
"Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["total_load"]
|
| 259 |
+
})
|
| 260 |
+
|
| 261 |
+
# Display cooling details table
|
| 262 |
+
cooling_df = pd.DataFrame(cooling_details)
|
| 263 |
+
st.dataframe(cooling_df, use_container_width=True)
|
| 264 |
+
|
| 265 |
+
with tab2:
|
| 266 |
+
# Create heating load details table
|
| 267 |
+
heating_details = []
|
| 268 |
+
|
| 269 |
+
# Add envelope components
|
| 270 |
+
for wall in results["heating"]["detailed_loads"]["walls"]:
|
| 271 |
+
heating_details.append({
|
| 272 |
+
"Component Type": "Wall",
|
| 273 |
+
"Name": wall["name"],
|
| 274 |
+
"Orientation": wall["orientation"],
|
| 275 |
+
"Area (m²)": wall["area"],
|
| 276 |
+
"U-Value (W/m²·K)": wall["u_value"],
|
| 277 |
+
"Temperature Difference (°C)": wall["delta_t"],
|
| 278 |
+
"Load (kW)": wall["load"]
|
| 279 |
+
})
|
| 280 |
+
|
| 281 |
+
for roof in results["heating"]["detailed_loads"]["roofs"]:
|
| 282 |
+
heating_details.append({
|
| 283 |
+
"Component Type": "Roof",
|
| 284 |
+
"Name": roof["name"],
|
| 285 |
+
"Orientation": roof["orientation"],
|
| 286 |
+
"Area (m²)": roof["area"],
|
| 287 |
+
"U-Value (W/m²·K)": roof["u_value"],
|
| 288 |
+
"Temperature Difference (°C)": roof["delta_t"],
|
| 289 |
+
"Load (kW)": roof["load"]
|
| 290 |
+
})
|
| 291 |
+
|
| 292 |
+
for floor in results["heating"]["detailed_loads"]["floors"]:
|
| 293 |
+
heating_details.append({
|
| 294 |
+
"Component Type": "Floor",
|
| 295 |
+
"Name": floor["name"],
|
| 296 |
+
"Area (m²)": floor["area"],
|
| 297 |
+
"U-Value (W/m²·K)": floor["u_value"],
|
| 298 |
+
"Temperature Difference (°C)": floor["delta_t"],
|
| 299 |
+
"Load (kW)": floor["load"]
|
| 300 |
+
})
|
| 301 |
+
|
| 302 |
+
for window in results["heating"]["detailed_loads"]["windows"]:
|
| 303 |
+
heating_details.append({
|
| 304 |
+
"Component Type": "Window",
|
| 305 |
+
"Name": window["name"],
|
| 306 |
+
"Orientation": window["orientation"],
|
| 307 |
+
"Area (m²)": window["area"],
|
| 308 |
+
"U-Value (W/m²·K)": window["u_value"],
|
| 309 |
+
"Temperature Difference (°C)": window["delta_t"],
|
| 310 |
+
"Load (kW)": window["load"]
|
| 311 |
+
})
|
| 312 |
+
|
| 313 |
+
for door in results["heating"]["detailed_loads"]["doors"]:
|
| 314 |
+
heating_details.append({
|
| 315 |
+
"Component Type": "Door",
|
| 316 |
+
"Name": door["name"],
|
| 317 |
+
"Orientation": door["orientation"],
|
| 318 |
+
"Area (m²)": door["area"],
|
| 319 |
+
"U-Value (W/m²·K)": door["u_value"],
|
| 320 |
+
"Temperature Difference (°C)": door["delta_t"],
|
| 321 |
+
"Load (kW)": door["load"]
|
| 322 |
+
})
|
| 323 |
+
|
| 324 |
+
# Add infiltration and ventilation
|
| 325 |
+
heating_details.append({
|
| 326 |
+
"Component Type": "Infiltration",
|
| 327 |
+
"Name": "Air Infiltration",
|
| 328 |
+
"Air Flow (m³/s)": results["heating"]["detailed_loads"]["infiltration"]["air_flow"],
|
| 329 |
+
"Temperature Difference (°C)": results["heating"]["detailed_loads"]["infiltration"]["delta_t"],
|
| 330 |
+
"Load (kW)": results["heating"]["detailed_loads"]["infiltration"]["load"]
|
| 331 |
+
})
|
| 332 |
+
|
| 333 |
+
heating_details.append({
|
| 334 |
+
"Component Type": "Ventilation",
|
| 335 |
+
"Name": "Fresh Air",
|
| 336 |
+
"Air Flow (m³/s)": results["heating"]["detailed_loads"]["ventilation"]["air_flow"],
|
| 337 |
+
"Temperature Difference (°C)": results["heating"]["detailed_loads"]["ventilation"]["delta_t"],
|
| 338 |
+
"Load (kW)": results["heating"]["detailed_loads"]["ventilation"]["load"]
|
| 339 |
+
})
|
| 340 |
+
|
| 341 |
+
# Display heating details table
|
| 342 |
+
heating_df = pd.DataFrame(heating_details)
|
| 343 |
+
st.dataframe(heating_df, use_container_width=True)
|
| 344 |
+
|
| 345 |
+
# Add download buttons for results
|
| 346 |
+
st.subheader("Download Results")
|
| 347 |
+
|
| 348 |
+
col1, col2 = st.columns(2)
|
| 349 |
+
|
| 350 |
+
with col1:
|
| 351 |
+
if st.button("Download Cooling Load Results (CSV)"):
|
| 352 |
+
cooling_csv = cooling_df.to_csv(index=False)
|
| 353 |
+
st.download_button(
|
| 354 |
+
label="Download CSV",
|
| 355 |
+
data=cooling_csv,
|
| 356 |
+
file_name=f"cooling_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 357 |
+
mime="text/csv"
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
with col2:
|
| 361 |
+
if st.button("Download Heating Load Results (CSV)"):
|
| 362 |
+
heating_csv = heating_df.to_csv(index=False)
|
| 363 |
+
st.download_button(
|
| 364 |
+
label="Download CSV",
|
| 365 |
+
data=heating_csv,
|
| 366 |
+
file_name=f"heating_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 367 |
+
mime="text/csv"
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
# Add button to download full report
|
| 371 |
+
if st.button("Generate Full Report (Excel)"):
|
| 372 |
+
# This would be implemented with the export functionality
|
| 373 |
+
st.info("Excel report generation will be implemented in the Export module.")
|
| 374 |
+
|
| 375 |
+
def _display_component_breakdown(self, session_state: Dict[str, Any]) -> None:
|
| 376 |
+
"""
|
| 377 |
+
Display component breakdown visualization.
|
| 378 |
+
|
| 379 |
+
Args:
|
| 380 |
+
session_state: Streamlit session state containing calculation results
|
| 381 |
+
"""
|
| 382 |
+
st.subheader("Component Breakdown")
|
| 383 |
+
|
| 384 |
+
# Use component visualization module
|
| 385 |
+
self.component_visualization.display_component_breakdown(
|
| 386 |
+
session_state["calculation_results"],
|
| 387 |
+
session_state["components"]
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
def _display_psychrometric_analysis(self, session_state: Dict[str, Any]) -> None:
|
| 391 |
+
"""
|
| 392 |
+
Display psychrometric analysis visualization.
|
| 393 |
+
|
| 394 |
+
Args:
|
| 395 |
+
session_state: Streamlit session state containing calculation results
|
| 396 |
+
"""
|
| 397 |
+
st.subheader("Psychrometric Analysis")
|
| 398 |
+
|
| 399 |
+
# Use psychrometric visualization module
|
| 400 |
+
self.psychrometric_visualization.display_psychrometric_chart(
|
| 401 |
+
session_state["calculation_results"],
|
| 402 |
+
session_state["building_info"]
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
def _display_time_analysis(self, session_state: Dict[str, Any]) -> None:
|
| 406 |
+
"""
|
| 407 |
+
Display time-based analysis visualization.
|
| 408 |
+
|
| 409 |
+
Args:
|
| 410 |
+
session_state: Streamlit session state containing calculation results
|
| 411 |
+
"""
|
| 412 |
+
st.subheader("Time Analysis")
|
| 413 |
+
|
| 414 |
+
# Use time-based visualization module
|
| 415 |
+
self.time_based_visualization.display_time_analysis(
|
| 416 |
+
session_state["calculation_results"]
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
def _display_scenario_comparison(self, session_state: Dict[str, Any]) -> None:
|
| 420 |
+
"""
|
| 421 |
+
Display scenario comparison visualization.
|
| 422 |
+
|
| 423 |
+
Args:
|
| 424 |
+
session_state: Streamlit session state containing calculation results
|
| 425 |
+
"""
|
| 426 |
+
st.subheader("Scenario Comparison")
|
| 427 |
+
|
| 428 |
+
# Check if there are saved scenarios
|
| 429 |
+
if "saved_scenarios" not in session_state or not session_state["saved_scenarios"]:
|
| 430 |
+
st.info("No saved scenarios available for comparison. Save the current results as a scenario to enable comparison.")
|
| 431 |
+
|
| 432 |
+
# Add button to save current results as a scenario
|
| 433 |
+
scenario_name = st.text_input("Scenario Name", value="Baseline")
|
| 434 |
+
|
| 435 |
+
if st.button("Save Current Results as Scenario"):
|
| 436 |
+
if "saved_scenarios" not in session_state:
|
| 437 |
+
session_state["saved_scenarios"] = {}
|
| 438 |
+
|
| 439 |
+
# Save current results as a scenario
|
| 440 |
+
session_state["saved_scenarios"][scenario_name] = {
|
| 441 |
+
"results": session_state["calculation_results"],
|
| 442 |
+
"building_info": session_state["building_info"],
|
| 443 |
+
"components": session_state["components"],
|
| 444 |
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
st.success(f"Scenario '{scenario_name}' saved successfully!")
|
| 448 |
+
st.experimental_rerun()
|
| 449 |
+
else:
|
| 450 |
+
# Use scenario comparison module
|
| 451 |
+
self.scenario_comparison.display_scenario_comparison(
|
| 452 |
+
session_state["calculation_results"],
|
| 453 |
+
session_state["saved_scenarios"]
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
# Add button to save current results as a new scenario
|
| 457 |
+
st.write("### Save Current Results as New Scenario")
|
| 458 |
+
|
| 459 |
+
scenario_name = st.text_input("Scenario Name", value="Scenario " + str(len(session_state["saved_scenarios"]) + 1))
|
| 460 |
+
|
| 461 |
+
if st.button("Save Current Results as Scenario"):
|
| 462 |
+
# Save current results as a scenario
|
| 463 |
+
session_state["saved_scenarios"][scenario_name] = {
|
| 464 |
+
"results": session_state["calculation_results"],
|
| 465 |
+
"building_info": session_state["building_info"],
|
| 466 |
+
"components": session_state["components"],
|
| 467 |
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
st.success(f"Scenario '{scenario_name}' saved successfully!")
|
| 471 |
+
st.experimental_rerun()
|
| 472 |
+
|
| 473 |
+
# Add button to delete a scenario
|
| 474 |
+
st.write("### Delete Scenario")
|
| 475 |
+
|
| 476 |
+
scenario_to_delete = st.selectbox(
|
| 477 |
+
"Select Scenario to Delete",
|
| 478 |
+
options=list(session_state["saved_scenarios"].keys())
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
if st.button("Delete Selected Scenario"):
|
| 482 |
+
# Delete selected scenario
|
| 483 |
+
del session_state["saved_scenarios"][scenario_to_delete]
|
| 484 |
+
|
| 485 |
+
st.success(f"Scenario '{scenario_to_delete}' deleted successfully!")
|
| 486 |
+
st.experimental_rerun()
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
# Create a singleton instance
|
| 490 |
+
results_display = ResultsDisplay()
|
| 491 |
+
|
| 492 |
+
# Example usage
|
| 493 |
+
if __name__ == "__main__":
|
| 494 |
+
import streamlit as st
|
| 495 |
+
|
| 496 |
+
# Initialize session state with dummy data for testing
|
| 497 |
+
if "calculation_results" not in st.session_state:
|
| 498 |
+
st.session_state["calculation_results"] = {
|
| 499 |
+
"cooling": {
|
| 500 |
+
"total_load": 25.5,
|
| 501 |
+
"sensible_load": 20.0,
|
| 502 |
+
"latent_load": 5.5,
|
| 503 |
+
"load_per_area": 85.0,
|
| 504 |
+
"component_loads": {
|
| 505 |
+
"walls": 5.0,
|
| 506 |
+
"roof": 3.0,
|
| 507 |
+
"windows": 8.0,
|
| 508 |
+
"doors": 1.0,
|
| 509 |
+
"people": 2.5,
|
| 510 |
+
"lighting": 2.0,
|
| 511 |
+
"equipment": 1.5,
|
| 512 |
+
"infiltration": 1.0,
|
| 513 |
+
"ventilation": 1.5
|
| 514 |
+
},
|
| 515 |
+
"detailed_loads": {
|
| 516 |
+
"walls": [
|
| 517 |
+
{"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "cltd": 10.0, "load": 1.0}
|
| 518 |
+
],
|
| 519 |
+
"roofs": [
|
| 520 |
+
{"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "cltd": 15.0, "load": 3.0}
|
| 521 |
+
],
|
| 522 |
+
"windows": [
|
| 523 |
+
{"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "shgc": 0.7, "scl": 800.0, "load": 8.0}
|
| 524 |
+
],
|
| 525 |
+
"doors": [
|
| 526 |
+
{"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "cltd": 10.0, "load": 1.0}
|
| 527 |
+
],
|
| 528 |
+
"internal": [
|
| 529 |
+
{"type": "People", "name": "Occupants", "quantity": 10, "heat_gain": 250, "clf": 1.0, "load": 2.5},
|
| 530 |
+
{"type": "Lighting", "name": "General Lighting", "quantity": 1000, "heat_gain": 2000, "clf": 1.0, "load": 2.0},
|
| 531 |
+
{"type": "Equipment", "name": "Office Equipment", "quantity": 5, "heat_gain": 300, "clf": 1.0, "load": 1.5}
|
| 532 |
+
],
|
| 533 |
+
"infiltration": {
|
| 534 |
+
"air_flow": 0.05,
|
| 535 |
+
"sensible_load": 0.8,
|
| 536 |
+
"latent_load": 0.2,
|
| 537 |
+
"total_load": 1.0
|
| 538 |
+
},
|
| 539 |
+
"ventilation": {
|
| 540 |
+
"air_flow": 0.1,
|
| 541 |
+
"sensible_load": 1.0,
|
| 542 |
+
"latent_load": 0.5,
|
| 543 |
+
"total_load": 1.5
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
},
|
| 547 |
+
"heating": {
|
| 548 |
+
"total_load": 30.0,
|
| 549 |
+
"load_per_area": 100.0,
|
| 550 |
+
"design_heat_loss": 27.0,
|
| 551 |
+
"safety_factor": 10.0,
|
| 552 |
+
"component_loads": {
|
| 553 |
+
"walls": 8.0,
|
| 554 |
+
"roof": 5.0,
|
| 555 |
+
"floor": 4.0,
|
| 556 |
+
"windows": 7.0,
|
| 557 |
+
"doors": 1.0,
|
| 558 |
+
"infiltration": 2.0,
|
| 559 |
+
"ventilation": 3.0
|
| 560 |
+
},
|
| 561 |
+
"detailed_loads": {
|
| 562 |
+
"walls": [
|
| 563 |
+
{"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "delta_t": 25.0, "load": 8.0}
|
| 564 |
+
],
|
| 565 |
+
"roofs": [
|
| 566 |
+
{"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "delta_t": 25.0, "load": 5.0}
|
| 567 |
+
],
|
| 568 |
+
"floors": [
|
| 569 |
+
{"name": "Ground Floor", "area": 100.0, "u_value": 0.4, "delta_t": 10.0, "load": 4.0}
|
| 570 |
+
],
|
| 571 |
+
"windows": [
|
| 572 |
+
{"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "delta_t": 25.0, "load": 7.0}
|
| 573 |
+
],
|
| 574 |
+
"doors": [
|
| 575 |
+
{"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "delta_t": 25.0, "load": 1.0}
|
| 576 |
+
],
|
| 577 |
+
"infiltration": {
|
| 578 |
+
"air_flow": 0.05,
|
| 579 |
+
"delta_t": 25.0,
|
| 580 |
+
"load": 2.0
|
| 581 |
+
},
|
| 582 |
+
"ventilation": {
|
| 583 |
+
"air_flow": 0.1,
|
| 584 |
+
"delta_t": 25.0,
|
| 585 |
+
"load": 3.0
|
| 586 |
+
}
|
| 587 |
+
}
|
| 588 |
+
}
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
# Display results
|
| 592 |
+
results_display.display_results(st.session_state)
|
data/ashrae_tables.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ASHRAE tables module for HVAC Load Calculator.
|
| 3 |
+
This module implements CLTD, SCL, CLF tables and interpolation functions for load calculations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
from enum import Enum
|
| 12 |
+
|
| 13 |
+
# Define paths
|
| 14 |
+
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class WallGroup(Enum):
|
| 18 |
+
"""Enumeration for ASHRAE wall groups."""
|
| 19 |
+
A = "A" # Light construction
|
| 20 |
+
B = "B"
|
| 21 |
+
C = "C"
|
| 22 |
+
D = "D"
|
| 23 |
+
E = "E"
|
| 24 |
+
F = "F"
|
| 25 |
+
G = "G"
|
| 26 |
+
H = "H" # Heavy construction
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RoofGroup(Enum):
|
| 30 |
+
"""Enumeration for ASHRAE roof groups."""
|
| 31 |
+
A = "A" # Light construction
|
| 32 |
+
B = "B"
|
| 33 |
+
C = "C"
|
| 34 |
+
D = "D"
|
| 35 |
+
E = "E"
|
| 36 |
+
F = "F"
|
| 37 |
+
G = "G" # Heavy construction
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Orientation(Enum):
|
| 41 |
+
"""Enumeration for building component orientations."""
|
| 42 |
+
N = "North"
|
| 43 |
+
NE = "Northeast"
|
| 44 |
+
E = "East"
|
| 45 |
+
SE = "Southeast"
|
| 46 |
+
S = "South"
|
| 47 |
+
SW = "Southwest"
|
| 48 |
+
W = "West"
|
| 49 |
+
NW = "Northwest"
|
| 50 |
+
HOR = "Horizontal" # For roofs and floors
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ASHRAETables:
|
| 54 |
+
"""Class for managing ASHRAE tables for load calculations."""
|
| 55 |
+
|
| 56 |
+
def __init__(self):
|
| 57 |
+
"""Initialize ASHRAE tables."""
|
| 58 |
+
# Load tables
|
| 59 |
+
self.cltd_wall = self._load_cltd_wall_table()
|
| 60 |
+
self.cltd_roof = self._load_cltd_roof_table()
|
| 61 |
+
self.scl = self._load_scl_table()
|
| 62 |
+
self.clf_lights = self._load_clf_lights_table()
|
| 63 |
+
self.clf_people = self._load_clf_people_table()
|
| 64 |
+
self.clf_equipment = self._load_clf_equipment_table()
|
| 65 |
+
|
| 66 |
+
# Load correction factors
|
| 67 |
+
self.latitude_correction = self._load_latitude_correction()
|
| 68 |
+
self.color_correction = self._load_color_correction()
|
| 69 |
+
self.month_correction = self._load_month_correction()
|
| 70 |
+
|
| 71 |
+
def _load_cltd_wall_table(self) -> Dict[str, pd.DataFrame]:
|
| 72 |
+
"""
|
| 73 |
+
Load CLTD tables for walls.
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
Dictionary of DataFrames with CLTD values for each wall group
|
| 77 |
+
"""
|
| 78 |
+
# This would typically load from CSV files with ASHRAE data
|
| 79 |
+
# For now, we'll define sample tables inline
|
| 80 |
+
|
| 81 |
+
# Hours of the day (0-23)
|
| 82 |
+
hours = list(range(24))
|
| 83 |
+
|
| 84 |
+
# Sample CLTD values for wall group A (light construction)
|
| 85 |
+
# Values for different orientations (N, NE, E, SE, S, SW, W, NW)
|
| 86 |
+
wall_a_data = {
|
| 87 |
+
"N": [2, 1, 0, -1, -2, -2, -2, -1, 0, 1, 3, 4, 6, 7, 8, 9, 9, 8, 7, 6, 5, 4, 3, 3],
|
| 88 |
+
"NE": [1, 0, -1, -1, -2, -1, 1, 4, 7, 9, 10, 11, 11, 10, 9, 8, 7, 6, 5, 4, 3, 3, 2, 1],
|
| 89 |
+
"E": [1, 0, -1, -1, -2, -1, 2, 6, 10, 13, 15, 16, 15, 14, 12, 10, 8, 6, 5, 4, 3, 2, 2, 1],
|
| 90 |
+
"SE": [1, 0, -1, -1, -2, -1, 1, 4, 8, 11, 14, 15, 16, 15, 14, 12, 10, 8, 6, 5, 4, 3, 2, 1],
|
| 91 |
+
"S": [1, 0, -1, -1, -2, -2, -1, 0, 2, 4, 7, 10, 12, 14, 15, 15, 14, 12, 9, 7, 5, 3, 2, 2],
|
| 92 |
+
"SW": [2, 1, 0, -1, -1, -2, -2, -1, 0, 2, 4, 6, 8, 11, 13, 15, 16, 15, 13, 10, 8, 6, 4, 3],
|
| 93 |
+
"W": [2, 1, 0, -1, -1, -2, -2, -1, 0, 1, 3, 4, 6, 8, 10, 13, 15, 16, 15, 13, 10, 7, 5, 3],
|
| 94 |
+
"NW": [2, 1, 0, -1, -1, -2, -2, -1, 0, 1, 2, 3, 5, 6, 7, 9, 11, 12, 12, 11, 9, 7, 5, 3]
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
# Sample CLTD values for wall group B
|
| 98 |
+
wall_b_data = {
|
| 99 |
+
"N": [3, 2, 1, 0, -1, -1, -1, 0, 1, 2, 4, 5, 7, 8, 9, 10, 10, 9, 8, 7, 6, 5, 4, 4],
|
| 100 |
+
"NE": [2, 1, 0, 0, -1, 0, 2, 5, 8, 10, 11, 12, 12, 11, 10, 9, 8, 7, 6, 5, 4, 4, 3, 2],
|
| 101 |
+
"E": [2, 1, 0, 0, -1, 0, 3, 7, 11, 14, 16, 17, 16, 15, 13, 11, 9, 7, 6, 5, 4, 3, 3, 2],
|
| 102 |
+
"SE": [2, 1, 0, 0, -1, 0, 2, 5, 9, 12, 15, 16, 17, 16, 15, 13, 11, 9, 7, 6, 5, 4, 3, 2],
|
| 103 |
+
"S": [2, 1, 0, 0, -1, -1, 0, 1, 3, 5, 8, 11, 13, 15, 16, 16, 15, 13, 10, 8, 6, 4, 3, 3],
|
| 104 |
+
"SW": [3, 2, 1, 0, 0, -1, -1, 0, 1, 3, 5, 7, 9, 12, 14, 16, 17, 16, 14, 11, 9, 7, 5, 4],
|
| 105 |
+
"W": [3, 2, 1, 0, 0, -1, -1, 0, 1, 2, 4, 5, 7, 9, 11, 14, 16, 17, 16, 14, 11, 8, 6, 4],
|
| 106 |
+
"NW": [3, 2, 1, 0, 0, -1, -1, 0, 1, 2, 3, 4, 6, 7, 8, 10, 12, 13, 13, 12, 10, 8, 6, 4]
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
# Sample CLTD values for wall group C
|
| 110 |
+
wall_c_data = {
|
| 111 |
+
"N": [4, 3, 2, 1, 0, 0, 0, 1, 2, 3, 5, 6, 8, 9, 10, 11, 11, 10, 9, 8, 7, 6, 5, 5],
|
| 112 |
+
"NE": [3, 2, 1, 1, 0, 1, 3, 6, 9, 11, 12, 13, 13, 12, 11, 10, 9, 8, 7, 6, 5, 5, 4, 3],
|
| 113 |
+
"E": [3, 2, 1, 1, 0, 1, 4, 8, 12, 15, 17, 18, 17, 16, 14, 12, 10, 8, 7, 6, 5, 4, 4, 3],
|
| 114 |
+
"SE": [3, 2, 1, 1, 0, 1, 3, 6, 10, 13, 16, 17, 18, 17, 16, 14, 12, 10, 8, 7, 6, 5, 4, 3],
|
| 115 |
+
"S": [3, 2, 1, 1, 0, 0, 1, 2, 4, 6, 9, 12, 14, 16, 17, 17, 16, 14, 11, 9, 7, 5, 4, 4],
|
| 116 |
+
"SW": [4, 3, 2, 1, 1, 0, 0, 1, 2, 4, 6, 8, 10, 13, 15, 17, 18, 17, 15, 12, 10, 8, 6, 5],
|
| 117 |
+
"W": [4, 3, 2, 1, 1, 0, 0, 1, 2, 3, 5, 6, 8, 10, 12, 15, 17, 18, 17, 15, 12, 9, 7, 5],
|
| 118 |
+
"NW": [4, 3, 2, 1, 1, 0, 0, 1, 2, 3, 4, 5, 7, 8, 9, 11, 13, 14, 14, 13, 11, 9, 7, 5]
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
# Sample CLTD values for wall group D
|
| 122 |
+
wall_d_data = {
|
| 123 |
+
"N": [5, 4, 3, 2, 1, 1, 1, 2, 3, 4, 6, 7, 9, 10, 11, 12, 12, 11, 10, 9, 8, 7, 6, 6],
|
| 124 |
+
"NE": [4, 3, 2, 2, 1, 2, 4, 7, 10, 12, 13, 14, 14, 13, 12, 11, 10, 9, 8, 7, 6, 6, 5, 4],
|
| 125 |
+
"E": [4, 3, 2, 2, 1, 2, 5, 9, 13, 16, 18, 19, 18, 17, 15, 13, 11, 9, 8, 7, 6, 5, 5, 4],
|
| 126 |
+
"SE": [4, 3, 2, 2, 1, 2, 4, 7, 11, 14, 17, 18, 19, 18, 17, 15, 13, 11, 9, 8, 7, 6, 5, 4],
|
| 127 |
+
"S": [4, 3, 2, 2, 1, 1, 2, 3, 5, 7, 10, 13, 15, 17, 18, 18, 17, 15, 12, 10, 8, 6, 5, 5],
|
| 128 |
+
"SW": [5, 4, 3, 2, 2, 1, 1, 2, 3, 5, 7, 9, 11, 14, 16, 18, 19, 18, 16, 13, 11, 9, 7, 6],
|
| 129 |
+
"W": [5, 4, 3, 2, 2, 1, 1, 2, 3, 4, 6, 7, 9, 11, 13, 16, 18, 19, 18, 16, 13, 10, 8, 6],
|
| 130 |
+
"NW": [5, 4, 3, 2, 2, 1, 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 14, 15, 15, 14, 12, 10, 8, 6]
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
# Create DataFrames for each wall group
|
| 134 |
+
wall_groups = {
|
| 135 |
+
"A": pd.DataFrame(wall_a_data, index=hours),
|
| 136 |
+
"B": pd.DataFrame(wall_b_data, index=hours),
|
| 137 |
+
"C": pd.DataFrame(wall_c_data, index=hours),
|
| 138 |
+
"D": pd.DataFrame(wall_d_data, index=hours),
|
| 139 |
+
# Groups E-H would be defined similarly
|
| 140 |
+
# Using group D values for now as placeholders
|
| 141 |
+
"E": pd.DataFrame(wall_d_data, index=hours),
|
| 142 |
+
"F": pd.DataFrame(wall_d_data, index=hours),
|
| 143 |
+
"G": pd.DataFrame(wall_d_data, index=hours),
|
| 144 |
+
"H": pd.DataFrame(wall_d_data, index=hours)
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
return wall_groups
|
| 148 |
+
|
| 149 |
+
def _load_cltd_roof_table(self) -> Dict[str, pd.DataFrame]:
|
| 150 |
+
"""
|
| 151 |
+
Load CLTD tables for roofs.
|
| 152 |
+
|
| 153 |
+
Returns:
|
| 154 |
+
Dictionary of DataFrames with CLTD values for each roof group
|
| 155 |
+
"""
|
| 156 |
+
# Hours of the day (0-23)
|
| 157 |
+
hours = list(range(24))
|
| 158 |
+
|
| 159 |
+
# Sample CLTD values for roof group A (light construction)
|
| 160 |
+
roof_a_data = [6, 3, 1, -1, -2, -3, 0, 5, 11, 17, 23, 28, 32, 34, 35, 34, 31, 27, 22, 18, 14, 12, 9, 7]
|
| 161 |
+
|
| 162 |
+
# Sample CLTD values for roof group B
|
| 163 |
+
roof_b_data = [9, 7, 5, 3, 2, 1, 1, 3, 7, 12, 17, 22, 26, 30, 33, 34, 34, 32, 29, 25, 21, 17, 14, 11]
|
| 164 |
+
|
| 165 |
+
# Sample CLTD values for roof group C
|
| 166 |
+
roof_c_data = [13, 11, 9, 7, 6, 5, 4, 4, 6, 9, 13, 17, 21, 25, 28, 31, 32, 32, 30, 27, 24, 21, 18, 15]
|
| 167 |
+
|
| 168 |
+
# Sample CLTD values for roof group D
|
| 169 |
+
roof_d_data = [14, 13, 12, 10, 9, 8, 7, 7, 7, 9, 11, 14, 17, 20, 23, 26, 28, 29, 29, 27, 25, 22, 19, 17]
|
| 170 |
+
|
| 171 |
+
# Sample CLTD values for roof group E
|
| 172 |
+
roof_e_data = [17, 16, 15, 14, 13, 12, 11, 11, 11, 11, 12, 14, 16, 18, 21, 23, 25, 26, 27, 26, 25, 23, 21, 19]
|
| 173 |
+
|
| 174 |
+
# Sample CLTD values for roof group F
|
| 175 |
+
roof_f_data = [19, 18, 18, 17, 16, 16, 15, 15, 14, 14, 15, 16, 17, 19, 20, 22, 23, 24, 25, 25, 24, 23, 22, 20]
|
| 176 |
+
|
| 177 |
+
# Sample CLTD values for roof group G (heavy construction)
|
| 178 |
+
roof_g_data = [20, 20, 19, 19, 19, 18, 18, 18, 18, 18, 18, 18, 19, 19, 20, 21, 22, 22, 23, 23, 23, 22, 22, 21]
|
| 179 |
+
|
| 180 |
+
# Create DataFrames for each roof group
|
| 181 |
+
roof_groups = {
|
| 182 |
+
"A": pd.DataFrame({"HOR": roof_a_data}, index=hours),
|
| 183 |
+
"B": pd.DataFrame({"HOR": roof_b_data}, index=hours),
|
| 184 |
+
"C": pd.DataFrame({"HOR": roof_c_data}, index=hours),
|
| 185 |
+
"D": pd.DataFrame({"HOR": roof_d_data}, index=hours),
|
| 186 |
+
"E": pd.DataFrame({"HOR": roof_e_data}, index=hours),
|
| 187 |
+
"F": pd.DataFrame({"HOR": roof_f_data}, index=hours),
|
| 188 |
+
"G": pd.DataFrame({"HOR": roof_g_data}, index=hours)
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
return roof_groups
|
| 192 |
+
|
| 193 |
+
def _load_scl_table(self) -> Dict[str, pd.DataFrame]:
|
| 194 |
+
"""
|
| 195 |
+
Load SCL (Solar Cooling Load) tables for windows.
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
Dictionary of DataFrames with SCL values for each orientation
|
| 199 |
+
"""
|
| 200 |
+
# Hours of the day (0-23)
|
| 201 |
+
hours = list(range(24))
|
| 202 |
+
|
| 203 |
+
# Sample SCL values for different orientations
|
| 204 |
+
# Values are for 40° North latitude in July
|
| 205 |
+
scl_data = {
|
| 206 |
+
"N": [11, 8, 6, 6, 6, 9, 13, 16, 19, 21, 22, 23, 23, 22, 20, 17, 14, 11, 11, 11, 11, 11, 11, 11],
|
| 207 |
+
"NE": [11, 8, 6, 6, 6, 19, 75, 113, 121, 103, 75, 40, 31, 27, 23, 19, 14, 11, 11, 11, 11, 11, 11, 11],
|
| 208 |
+
"E": [11, 8, 6, 6, 6, 13, 55, 159, 232, 251, 222, 157, 82, 43, 32, 24, 17, 11, 11, 11, 11, 11, 11, 11],
|
| 209 |
+
"SE": [11, 8, 6, 6, 6, 10, 33, 98, 187, 251, 276, 264, 214, 139, 74, 37, 21, 11, 11, 11, 11, 11, 11, 11],
|
| 210 |
+
"S": [11, 8, 6, 6, 6, 8, 14, 27, 66, 139, 209, 254, 268, 251, 203, 139, 66, 27, 14, 11, 11, 11, 11, 11],
|
| 211 |
+
"SW": [11, 8, 6, 6, 6, 8, 14, 19, 24, 37, 74, 139, 214, 264, 276, 251, 187, 98, 33, 14, 11, 11, 11, 11],
|
| 212 |
+
"W": [11, 8, 6, 6, 6, 8, 14, 19, 24, 32, 43, 82, 157, 222, 251, 232, 159, 55, 13, 11, 11, 11, 11, 11],
|
| 213 |
+
"NW": [11, 8, 6, 6, 6, 8, 14, 19, 24, 27, 31, 40, 75, 103, 121, 113, 75, 19, 11, 11, 11, 11, 11, 11],
|
| 214 |
+
"HOR": [11, 8, 6, 6, 6, 19, 69, 135, 201, 254, 290, 308, 308, 290, 254, 201, 135, 69, 19, 11, 11, 11, 11, 11]
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
# Create DataFrame for SCL values
|
| 218 |
+
scl_table = pd.DataFrame(scl_data, index=hours)
|
| 219 |
+
|
| 220 |
+
# Return as a dictionary with a single entry for now
|
| 221 |
+
# In a real implementation, there would be tables for different latitudes and months
|
| 222 |
+
return {"40N_JUL": scl_table}
|
| 223 |
+
|
| 224 |
+
def _load_clf_lights_table(self) -> pd.DataFrame:
|
| 225 |
+
"""
|
| 226 |
+
Load CLF (Cooling Load Factor) table for lights.
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
DataFrame with CLF values for lights
|
| 230 |
+
"""
|
| 231 |
+
# Hours of the day (0-23)
|
| 232 |
+
hours = list(range(24))
|
| 233 |
+
|
| 234 |
+
# Sample CLF values for lights
|
| 235 |
+
# Values for different hours of operation
|
| 236 |
+
clf_lights_data = {
|
| 237 |
+
"8h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.24, 0.20, 0.17, 0.14, 0.12, 0.10, 0.08],
|
| 238 |
+
"10h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.17, 0.14, 0.12, 0.10, 0.08],
|
| 239 |
+
"12h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.71, 0.14, 0.12, 0.10, 0.08],
|
| 240 |
+
"14h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.71, 0.61, 0.12, 0.10, 0.08],
|
| 241 |
+
"16h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.71, 0.61, 0.53, 0.10, 0.08],
|
| 242 |
+
"18h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.08],
|
| 243 |
+
"24h": [0.18, 0.16, 0.14, 0.12, 0.11, 0.10, 0.09, 0.08, 0.07, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06]
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
# Create DataFrame for CLF values for lights
|
| 247 |
+
return pd.DataFrame(clf_lights_data, index=hours)
|
| 248 |
+
|
| 249 |
+
def _load_clf_people_table(self) -> pd.DataFrame:
|
| 250 |
+
"""
|
| 251 |
+
Load CLF (Cooling Load Factor) table for people.
|
| 252 |
+
|
| 253 |
+
Returns:
|
| 254 |
+
DataFrame with CLF values for people
|
| 255 |
+
"""
|
| 256 |
+
# Hours of the day (0-23)
|
| 257 |
+
hours = list(range(24))
|
| 258 |
+
|
| 259 |
+
# Sample CLF values for people
|
| 260 |
+
# Values for different hours of occupancy
|
| 261 |
+
clf_people_data = {
|
| 262 |
+
"8h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.50, 0.47, 0.44, 0.41, 0.38, 0.36, 0.33],
|
| 263 |
+
"10h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.44, 0.41, 0.38, 0.36, 0.33],
|
| 264 |
+
"12h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.80, 0.41, 0.38, 0.36, 0.33],
|
| 265 |
+
"14h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.80, 0.75, 0.38, 0.36, 0.33],
|
| 266 |
+
"16h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.80, 0.75, 0.70, 0.36, 0.33],
|
| 267 |
+
"18h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.33],
|
| 268 |
+
"24h": [0.27, 0.25, 0.23, 0.21, 0.19, 0.17, 0.16, 0.14, 0.13, 0.12, 0.11, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10]
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
# Create DataFrame for CLF values for people
|
| 272 |
+
return pd.DataFrame(clf_people_data, index=hours)
|
| 273 |
+
|
| 274 |
+
def _load_clf_equipment_table(self) -> pd.DataFrame:
|
| 275 |
+
"""
|
| 276 |
+
Load CLF (Cooling Load Factor) table for equipment.
|
| 277 |
+
|
| 278 |
+
Returns:
|
| 279 |
+
DataFrame with CLF values for equipment
|
| 280 |
+
"""
|
| 281 |
+
# Hours of the day (0-23)
|
| 282 |
+
hours = list(range(24))
|
| 283 |
+
|
| 284 |
+
# Sample CLF values for equipment
|
| 285 |
+
# Values for different hours of operation
|
| 286 |
+
clf_equipment_data = {
|
| 287 |
+
"8h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.30, 0.26, 0.22, 0.18, 0.16, 0.14, 0.12],
|
| 288 |
+
"10h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.22, 0.18, 0.16, 0.14, 0.12],
|
| 289 |
+
"12h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.76, 0.18, 0.16, 0.14, 0.12],
|
| 290 |
+
"14h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.76, 0.69, 0.16, 0.14, 0.12],
|
| 291 |
+
"16h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.76, 0.69, 0.62, 0.14, 0.12],
|
| 292 |
+
"18h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.12],
|
| 293 |
+
"24h": [0.23, 0.21, 0.19, 0.17, 0.15, 0.13, 0.12, 0.10, 0.09, 0.08, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07]
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
# Create DataFrame for CLF values for equipment
|
| 297 |
+
return pd.DataFrame(clf_equipment_data, index=hours)
|
| 298 |
+
|
| 299 |
+
def _load_latitude_correction(self) -> Dict[str, Dict[str, float]]:
|
| 300 |
+
"""
|
| 301 |
+
Load latitude correction factors for CLTD/SCL values.
|
| 302 |
+
|
| 303 |
+
Returns:
|
| 304 |
+
Dictionary of correction factors for different latitudes and months
|
| 305 |
+
"""
|
| 306 |
+
# Sample latitude correction factors
|
| 307 |
+
# Values for different latitudes and months
|
| 308 |
+
return {
|
| 309 |
+
"24N": {
|
| 310 |
+
"Jan": -5.0, "Feb": -3.5, "Mar": -1.0, "Apr": 2.0, "May": 4.0,
|
| 311 |
+
"Jun": 5.0, "Jul": 4.5, "Aug": 3.0, "Sep": 1.0, "Oct": -1.5,
|
| 312 |
+
"Nov": -4.0, "Dec": -5.5
|
| 313 |
+
},
|
| 314 |
+
"32N": {
|
| 315 |
+
"Jan": -4.0, "Feb": -2.5, "Mar": 0.0, "Apr": 2.5, "May": 4.5,
|
| 316 |
+
"Jun": 5.5, "Jul": 5.0, "Aug": 3.5, "Sep": 1.5, "Oct": -0.5,
|
| 317 |
+
"Nov": -3.0, "Dec": -4.5
|
| 318 |
+
},
|
| 319 |
+
"40N": {
|
| 320 |
+
"Jan": -3.0, "Feb": -1.5, "Mar": 1.0, "Apr": 3.0, "May": 5.0,
|
| 321 |
+
"Jun": 6.0, "Jul": 5.5, "Aug": 4.0, "Sep": 2.0, "Oct": 0.0,
|
| 322 |
+
"Nov": -2.0, "Dec": -3.5
|
| 323 |
+
},
|
| 324 |
+
"48N": {
|
| 325 |
+
"Jan": -2.0, "Feb": -0.5, "Mar": 2.0, "Apr": 4.0, "May": 6.0,
|
| 326 |
+
"Jun": 7.0, "Jul": 6.5, "Aug": 5.0, "Sep": 3.0, "Oct": 1.0,
|
| 327 |
+
"Nov": -1.0, "Dec": -2.5
|
| 328 |
+
},
|
| 329 |
+
"56N": {
|
| 330 |
+
"Jan": -1.0, "Feb": 0.5, "Mar": 3.0, "Apr": 5.0, "May": 7.0,
|
| 331 |
+
"Jun": 8.0, "Jul": 7.5, "Aug": 6.0, "Sep": 4.0, "Oct": 2.0,
|
| 332 |
+
"Nov": 0.0, "Dec": -1.5
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
def _load_color_correction(self) -> Dict[str, float]:
|
| 337 |
+
"""
|
| 338 |
+
Load color correction factors for CLTD values.
|
| 339 |
+
|
| 340 |
+
Returns:
|
| 341 |
+
Dictionary of correction factors for different colors
|
| 342 |
+
"""
|
| 343 |
+
# Color correction factors
|
| 344 |
+
return {
|
| 345 |
+
"Dark": 0.0, # No correction for dark colors (default)
|
| 346 |
+
"Medium": -1.0, # Correction for medium colors
|
| 347 |
+
"Light": -2.0 # Correction for light colors
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
def _load_month_correction(self) -> Dict[str, float]:
|
| 351 |
+
"""
|
| 352 |
+
Load month correction factors for CLTD values.
|
| 353 |
+
|
| 354 |
+
Returns:
|
| 355 |
+
Dictionary of correction factors for different months
|
| 356 |
+
"""
|
| 357 |
+
# Month correction factors (relative to July/August)
|
| 358 |
+
return {
|
| 359 |
+
"Jan": -6.0, "Feb": -5.0, "Mar": -3.0, "Apr": -1.0, "May": 1.0,
|
| 360 |
+
"Jun": 2.0, "Jul": 2.0, "Aug": 2.0, "Sep": 1.0, "Oct": -1.0,
|
| 361 |
+
"Nov": -3.0, "Dec": -5.0
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
def get_cltd_wall(self, wall_group: str, orientation: str, hour: int) -> float:
|
| 365 |
+
"""
|
| 366 |
+
Get CLTD value for a wall.
|
| 367 |
+
|
| 368 |
+
Args:
|
| 369 |
+
wall_group: ASHRAE wall group (A-H)
|
| 370 |
+
orientation: Wall orientation (N, NE, E, SE, S, SW, W, NW)
|
| 371 |
+
hour: Hour of the day (0-23)
|
| 372 |
+
|
| 373 |
+
Returns:
|
| 374 |
+
CLTD value for the specified wall and hour
|
| 375 |
+
"""
|
| 376 |
+
# Validate inputs
|
| 377 |
+
if wall_group not in self.cltd_wall:
|
| 378 |
+
raise ValueError(f"Invalid wall group: {wall_group}")
|
| 379 |
+
|
| 380 |
+
# Convert orientation to abbreviation if needed
|
| 381 |
+
orientation_map = {
|
| 382 |
+
"North": "N", "Northeast": "NE", "East": "E", "Southeast": "SE",
|
| 383 |
+
"South": "S", "Southwest": "SW", "West": "W", "Northwest": "NW"
|
| 384 |
+
}
|
| 385 |
+
orientation_abbr = orientation_map.get(orientation, orientation)
|
| 386 |
+
|
| 387 |
+
if orientation_abbr not in self.cltd_wall[wall_group].columns:
|
| 388 |
+
raise ValueError(f"Invalid orientation: {orientation}")
|
| 389 |
+
|
| 390 |
+
if hour < 0 or hour > 23:
|
| 391 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 392 |
+
|
| 393 |
+
# Get CLTD value
|
| 394 |
+
return self.cltd_wall[wall_group].loc[hour, orientation_abbr]
|
| 395 |
+
|
| 396 |
+
def get_cltd_roof(self, roof_group: str, hour: int) -> float:
|
| 397 |
+
"""
|
| 398 |
+
Get CLTD value for a roof.
|
| 399 |
+
|
| 400 |
+
Args:
|
| 401 |
+
roof_group: ASHRAE roof group (A-G)
|
| 402 |
+
hour: Hour of the day (0-23)
|
| 403 |
+
|
| 404 |
+
Returns:
|
| 405 |
+
CLTD value for the specified roof and hour
|
| 406 |
+
"""
|
| 407 |
+
# Validate inputs
|
| 408 |
+
if roof_group not in self.cltd_roof:
|
| 409 |
+
raise ValueError(f"Invalid roof group: {roof_group}")
|
| 410 |
+
|
| 411 |
+
if hour < 0 or hour > 23:
|
| 412 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 413 |
+
|
| 414 |
+
# Get CLTD value
|
| 415 |
+
return self.cltd_roof[roof_group].loc[hour, "HOR"]
|
| 416 |
+
|
| 417 |
+
def get_scl(self, orientation: str, hour: int, latitude: str = "40N_JUL") -> float:
|
| 418 |
+
"""
|
| 419 |
+
Get SCL value for a window.
|
| 420 |
+
|
| 421 |
+
Args:
|
| 422 |
+
orientation: Window orientation (N, NE, E, SE, S, SW, W, NW, HOR)
|
| 423 |
+
hour: Hour of the day (0-23)
|
| 424 |
+
latitude: Latitude and month key (default: "40N_JUL")
|
| 425 |
+
|
| 426 |
+
Returns:
|
| 427 |
+
SCL value for the specified window and hour
|
| 428 |
+
"""
|
| 429 |
+
# Validate inputs
|
| 430 |
+
if latitude not in self.scl:
|
| 431 |
+
raise ValueError(f"Invalid latitude: {latitude}")
|
| 432 |
+
|
| 433 |
+
# Convert orientation to abbreviation if needed
|
| 434 |
+
orientation_map = {
|
| 435 |
+
"North": "N", "Northeast": "NE", "East": "E", "Southeast": "SE",
|
| 436 |
+
"South": "S", "Southwest": "SW", "West": "W", "Northwest": "NW",
|
| 437 |
+
"Horizontal": "HOR"
|
| 438 |
+
}
|
| 439 |
+
orientation_abbr = orientation_map.get(orientation, orientation)
|
| 440 |
+
|
| 441 |
+
if orientation_abbr not in self.scl[latitude].columns:
|
| 442 |
+
raise ValueError(f"Invalid orientation: {orientation}")
|
| 443 |
+
|
| 444 |
+
if hour < 0 or hour > 23:
|
| 445 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 446 |
+
|
| 447 |
+
# Get SCL value
|
| 448 |
+
return self.scl[latitude].loc[hour, orientation_abbr]
|
| 449 |
+
|
| 450 |
+
def get_clf_lights(self, hour: int, hours_operation: str) -> float:
|
| 451 |
+
"""
|
| 452 |
+
Get CLF value for lights.
|
| 453 |
+
|
| 454 |
+
Args:
|
| 455 |
+
hour: Hour of the day (0-23)
|
| 456 |
+
hours_operation: Hours of operation (8h, 10h, 12h, 14h, 16h, 18h, 24h)
|
| 457 |
+
|
| 458 |
+
Returns:
|
| 459 |
+
CLF value for lights at the specified hour
|
| 460 |
+
"""
|
| 461 |
+
# Validate inputs
|
| 462 |
+
if hours_operation not in self.clf_lights.columns:
|
| 463 |
+
raise ValueError(f"Invalid hours of operation: {hours_operation}")
|
| 464 |
+
|
| 465 |
+
if hour < 0 or hour > 23:
|
| 466 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 467 |
+
|
| 468 |
+
# Get CLF value
|
| 469 |
+
return self.clf_lights.loc[hour, hours_operation]
|
| 470 |
+
|
| 471 |
+
def get_clf_people(self, hour: int, hours_occupancy: str) -> float:
|
| 472 |
+
"""
|
| 473 |
+
Get CLF value for people.
|
| 474 |
+
|
| 475 |
+
Args:
|
| 476 |
+
hour: Hour of the day (0-23)
|
| 477 |
+
hours_occupancy: Hours of occupancy (8h, 10h, 12h, 14h, 16h, 18h, 24h)
|
| 478 |
+
|
| 479 |
+
Returns:
|
| 480 |
+
CLF value for people at the specified hour
|
| 481 |
+
"""
|
| 482 |
+
# Validate inputs
|
| 483 |
+
if hours_occupancy not in self.clf_people.columns:
|
| 484 |
+
raise ValueError(f"Invalid hours of occupancy: {hours_occupancy}")
|
| 485 |
+
|
| 486 |
+
if hour < 0 or hour > 23:
|
| 487 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 488 |
+
|
| 489 |
+
# Get CLF value
|
| 490 |
+
return self.clf_people.loc[hour, hours_occupancy]
|
| 491 |
+
|
| 492 |
+
def get_clf_equipment(self, hour: int, hours_operation: str) -> float:
|
| 493 |
+
"""
|
| 494 |
+
Get CLF value for equipment.
|
| 495 |
+
|
| 496 |
+
Args:
|
| 497 |
+
hour: Hour of the day (0-23)
|
| 498 |
+
hours_operation: Hours of operation (8h, 10h, 12h, 14h, 16h, 18h, 24h)
|
| 499 |
+
|
| 500 |
+
Returns:
|
| 501 |
+
CLF value for equipment at the specified hour
|
| 502 |
+
"""
|
| 503 |
+
# Validate inputs
|
| 504 |
+
if hours_operation not in self.clf_equipment.columns:
|
| 505 |
+
raise ValueError(f"Invalid hours of operation: {hours_operation}")
|
| 506 |
+
|
| 507 |
+
if hour < 0 or hour > 23:
|
| 508 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 509 |
+
|
| 510 |
+
# Get CLF value
|
| 511 |
+
return self.clf_equipment.loc[hour, hours_operation]
|
| 512 |
+
|
| 513 |
+
def get_latitude_correction(self, latitude: str, month: str) -> float:
|
| 514 |
+
"""
|
| 515 |
+
Get latitude correction factor for CLTD/SCL values.
|
| 516 |
+
|
| 517 |
+
Args:
|
| 518 |
+
latitude: Latitude (24N, 32N, 40N, 48N, 56N)
|
| 519 |
+
month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
|
| 520 |
+
|
| 521 |
+
Returns:
|
| 522 |
+
Latitude correction factor
|
| 523 |
+
"""
|
| 524 |
+
# Validate inputs
|
| 525 |
+
if latitude not in self.latitude_correction:
|
| 526 |
+
raise ValueError(f"Invalid latitude: {latitude}")
|
| 527 |
+
|
| 528 |
+
if month not in self.latitude_correction[latitude]:
|
| 529 |
+
raise ValueError(f"Invalid month: {month}")
|
| 530 |
+
|
| 531 |
+
# Get correction factor
|
| 532 |
+
return self.latitude_correction[latitude][month]
|
| 533 |
+
|
| 534 |
+
def get_color_correction(self, color: str) -> float:
|
| 535 |
+
"""
|
| 536 |
+
Get color correction factor for CLTD values.
|
| 537 |
+
|
| 538 |
+
Args:
|
| 539 |
+
color: Surface color (Dark, Medium, Light)
|
| 540 |
+
|
| 541 |
+
Returns:
|
| 542 |
+
Color correction factor
|
| 543 |
+
"""
|
| 544 |
+
# Validate inputs
|
| 545 |
+
if color not in self.color_correction:
|
| 546 |
+
raise ValueError(f"Invalid color: {color}")
|
| 547 |
+
|
| 548 |
+
# Get correction factor
|
| 549 |
+
return self.color_correction[color]
|
| 550 |
+
|
| 551 |
+
def get_month_correction(self, month: str) -> float:
|
| 552 |
+
"""
|
| 553 |
+
Get month correction factor for CLTD values.
|
| 554 |
+
|
| 555 |
+
Args:
|
| 556 |
+
month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
|
| 557 |
+
|
| 558 |
+
Returns:
|
| 559 |
+
Month correction factor
|
| 560 |
+
"""
|
| 561 |
+
# Validate inputs
|
| 562 |
+
if month not in self.month_correction:
|
| 563 |
+
raise ValueError(f"Invalid month: {month}")
|
| 564 |
+
|
| 565 |
+
# Get correction factor
|
| 566 |
+
return self.month_correction[month]
|
| 567 |
+
|
| 568 |
+
def calculate_corrected_cltd_wall(self, wall_group: str, orientation: str, hour: int,
|
| 569 |
+
color: str = "Dark", month: str = "Jul",
|
| 570 |
+
latitude: str = "40N", indoor_temp: float = 25.5,
|
| 571 |
+
outdoor_temp: float = 35.0) -> float:
|
| 572 |
+
"""
|
| 573 |
+
Calculate corrected CLTD value for a wall.
|
| 574 |
+
|
| 575 |
+
Args:
|
| 576 |
+
wall_group: ASHRAE wall group (A-H)
|
| 577 |
+
orientation: Wall orientation (N, NE, E, SE, S, SW, W, NW)
|
| 578 |
+
hour: Hour of the day (0-23)
|
| 579 |
+
color: Surface color (Dark, Medium, Light)
|
| 580 |
+
month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
|
| 581 |
+
latitude: Latitude (24N, 32N, 40N, 48N, 56N)
|
| 582 |
+
indoor_temp: Indoor design temperature (°C)
|
| 583 |
+
outdoor_temp: Outdoor design temperature (°C)
|
| 584 |
+
|
| 585 |
+
Returns:
|
| 586 |
+
Corrected CLTD value for the specified wall and hour
|
| 587 |
+
"""
|
| 588 |
+
# Get base CLTD value
|
| 589 |
+
cltd = self.get_cltd_wall(wall_group, orientation, hour)
|
| 590 |
+
|
| 591 |
+
# Apply corrections
|
| 592 |
+
color_correction = self.get_color_correction(color)
|
| 593 |
+
month_correction = self.get_month_correction(month)
|
| 594 |
+
latitude_correction = self.get_latitude_correction(latitude, month)
|
| 595 |
+
|
| 596 |
+
# Temperature correction
|
| 597 |
+
temp_correction = (indoor_temp - 25.5) + (outdoor_temp - 35.0)
|
| 598 |
+
|
| 599 |
+
# Calculate corrected CLTD
|
| 600 |
+
corrected_cltd = cltd + color_correction + month_correction + latitude_correction + temp_correction
|
| 601 |
+
|
| 602 |
+
return max(0, corrected_cltd) # Ensure non-negative value
|
| 603 |
+
|
| 604 |
+
def calculate_corrected_cltd_roof(self, roof_group: str, hour: int,
|
| 605 |
+
color: str = "Dark", month: str = "Jul",
|
| 606 |
+
latitude: str = "40N", indoor_temp: float = 25.5,
|
| 607 |
+
outdoor_temp: float = 35.0) -> float:
|
| 608 |
+
"""
|
| 609 |
+
Calculate corrected CLTD value for a roof.
|
| 610 |
+
|
| 611 |
+
Args:
|
| 612 |
+
roof_group: ASHRAE roof group (A-G)
|
| 613 |
+
hour: Hour of the day (0-23)
|
| 614 |
+
color: Surface color (Dark, Medium, Light)
|
| 615 |
+
month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
|
| 616 |
+
latitude: Latitude (24N, 32N, 40N, 48N, 56N)
|
| 617 |
+
indoor_temp: Indoor design temperature (°C)
|
| 618 |
+
outdoor_temp: Outdoor design temperature (°C)
|
| 619 |
+
|
| 620 |
+
Returns:
|
| 621 |
+
Corrected CLTD value for the specified roof and hour
|
| 622 |
+
"""
|
| 623 |
+
# Get base CLTD value
|
| 624 |
+
cltd = self.get_cltd_roof(roof_group, hour)
|
| 625 |
+
|
| 626 |
+
# Apply corrections
|
| 627 |
+
color_correction = self.get_color_correction(color)
|
| 628 |
+
month_correction = self.get_month_correction(month)
|
| 629 |
+
latitude_correction = self.get_latitude_correction(latitude, month)
|
| 630 |
+
|
| 631 |
+
# Temperature correction
|
| 632 |
+
temp_correction = (indoor_temp - 25.5) + (outdoor_temp - 35.0)
|
| 633 |
+
|
| 634 |
+
# Calculate corrected CLTD
|
| 635 |
+
corrected_cltd = cltd + color_correction + month_correction + latitude_correction + temp_correction
|
| 636 |
+
|
| 637 |
+
return max(0, corrected_cltd) # Ensure non-negative value
|
| 638 |
+
|
| 639 |
+
def interpolate_cltd(self, cltd1: float, cltd2: float, factor: float) -> float:
|
| 640 |
+
"""
|
| 641 |
+
Interpolate between two CLTD values.
|
| 642 |
+
|
| 643 |
+
Args:
|
| 644 |
+
cltd1: First CLTD value
|
| 645 |
+
cltd2: Second CLTD value
|
| 646 |
+
factor: Interpolation factor (0-1)
|
| 647 |
+
|
| 648 |
+
Returns:
|
| 649 |
+
Interpolated CLTD value
|
| 650 |
+
"""
|
| 651 |
+
return cltd1 + factor * (cltd2 - cltd1)
|
| 652 |
+
|
| 653 |
+
def interpolate_scl(self, scl1: float, scl2: float, factor: float) -> float:
|
| 654 |
+
"""
|
| 655 |
+
Interpolate between two SCL values.
|
| 656 |
+
|
| 657 |
+
Args:
|
| 658 |
+
scl1: First SCL value
|
| 659 |
+
scl2: Second SCL value
|
| 660 |
+
factor: Interpolation factor (0-1)
|
| 661 |
+
|
| 662 |
+
Returns:
|
| 663 |
+
Interpolated SCL value
|
| 664 |
+
"""
|
| 665 |
+
return scl1 + factor * (scl2 - scl1)
|
| 666 |
+
|
| 667 |
+
def interpolate_clf(self, clf1: float, clf2: float, factor: float) -> float:
|
| 668 |
+
"""
|
| 669 |
+
Interpolate between two CLF values.
|
| 670 |
+
|
| 671 |
+
Args:
|
| 672 |
+
clf1: First CLF value
|
| 673 |
+
clf2: Second CLF value
|
| 674 |
+
factor: Interpolation factor (0-1)
|
| 675 |
+
|
| 676 |
+
Returns:
|
| 677 |
+
Interpolated CLF value
|
| 678 |
+
"""
|
| 679 |
+
return clf1 + factor * (clf2 - clf1)
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
# Create a singleton instance
|
| 683 |
+
ashrae_tables = ASHRAETables()
|
| 684 |
+
|
| 685 |
+
# Export tables to CSV if needed
|
| 686 |
+
if __name__ == "__main__":
|
| 687 |
+
# Export CLTD tables for walls
|
| 688 |
+
for group, df in ashrae_tables.cltd_wall.items():
|
| 689 |
+
df.to_csv(os.path.join(DATA_DIR, f"cltd_wall_group_{group}.csv"))
|
| 690 |
+
|
| 691 |
+
# Export CLTD tables for roofs
|
| 692 |
+
for group, df in ashrae_tables.cltd_roof.items():
|
| 693 |
+
df.to_csv(os.path.join(DATA_DIR, f"cltd_roof_group_{group}.csv"))
|
| 694 |
+
|
| 695 |
+
# Export SCL table
|
| 696 |
+
for key, df in ashrae_tables.scl.items():
|
| 697 |
+
df.to_csv(os.path.join(DATA_DIR, f"scl_{key}.csv"))
|
| 698 |
+
|
| 699 |
+
# Export CLF tables
|
| 700 |
+
ashrae_tables.clf_lights.to_csv(os.path.join(DATA_DIR, "clf_lights.csv"))
|
| 701 |
+
ashrae_tables.clf_people.to_csv(os.path.join(DATA_DIR, "clf_people.csv"))
|
| 702 |
+
ashrae_tables.clf_equipment.to_csv(os.path.join(DATA_DIR, "clf_equipment.csv"))
|
data/building_components.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Building component data models for HVAC Load Calculator.
|
| 3 |
+
This module defines the data structures for walls, roofs, floors, windows, doors, and other building components.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from enum import Enum
|
| 8 |
+
from typing import List, Dict, Optional, Union
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Orientation(Enum):
|
| 13 |
+
"""Enumeration for building component orientations."""
|
| 14 |
+
NORTH = "North"
|
| 15 |
+
NORTHEAST = "Northeast"
|
| 16 |
+
EAST = "East"
|
| 17 |
+
SOUTHEAST = "Southeast"
|
| 18 |
+
SOUTH = "South"
|
| 19 |
+
SOUTHWEST = "Southwest"
|
| 20 |
+
WEST = "West"
|
| 21 |
+
NORTHWEST = "Northwest"
|
| 22 |
+
HORIZONTAL = "Horizontal" # For roofs and floors
|
| 23 |
+
NOT_APPLICABLE = "N/A" # For components without orientation
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ComponentType(Enum):
|
| 27 |
+
"""Enumeration for building component types."""
|
| 28 |
+
WALL = "Wall"
|
| 29 |
+
ROOF = "Roof"
|
| 30 |
+
FLOOR = "Floor"
|
| 31 |
+
WINDOW = "Window"
|
| 32 |
+
DOOR = "Door"
|
| 33 |
+
SKYLIGHT = "Skylight"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class MaterialLayer:
|
| 37 |
+
"""Class representing a single material layer in a building component."""
|
| 38 |
+
|
| 39 |
+
def __init__(self, name: str, thickness: float, conductivity: float,
|
| 40 |
+
density: float = None, specific_heat: float = None):
|
| 41 |
+
"""
|
| 42 |
+
Initialize a material layer.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
name: Name of the material
|
| 46 |
+
thickness: Thickness of the layer in meters
|
| 47 |
+
conductivity: Thermal conductivity in W/(m·K)
|
| 48 |
+
density: Density in kg/m³ (optional)
|
| 49 |
+
specific_heat: Specific heat capacity in J/(kg·K) (optional)
|
| 50 |
+
"""
|
| 51 |
+
self.name = name
|
| 52 |
+
self.thickness = thickness # m
|
| 53 |
+
self.conductivity = conductivity # W/(m·K)
|
| 54 |
+
self.density = density # kg/m³
|
| 55 |
+
self.specific_heat = specific_heat # J/(kg·K)
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def r_value(self) -> float:
|
| 59 |
+
"""Calculate the thermal resistance (R-value) of the layer in m²·K/W."""
|
| 60 |
+
if self.conductivity == 0:
|
| 61 |
+
return float('inf') # Avoid division by zero
|
| 62 |
+
return self.thickness / self.conductivity
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def thermal_mass(self) -> Optional[float]:
|
| 66 |
+
"""Calculate the thermal mass of the layer in J/(m²·K)."""
|
| 67 |
+
if self.density is None or self.specific_heat is None:
|
| 68 |
+
return None
|
| 69 |
+
return self.thickness * self.density * self.specific_heat
|
| 70 |
+
|
| 71 |
+
def to_dict(self) -> Dict:
|
| 72 |
+
"""Convert the material layer to a dictionary."""
|
| 73 |
+
return {
|
| 74 |
+
"name": self.name,
|
| 75 |
+
"thickness": self.thickness,
|
| 76 |
+
"conductivity": self.conductivity,
|
| 77 |
+
"density": self.density,
|
| 78 |
+
"specific_heat": self.specific_heat,
|
| 79 |
+
"r_value": self.r_value,
|
| 80 |
+
"thermal_mass": self.thermal_mass
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class BuildingComponent:
|
| 86 |
+
"""Base class for all building components."""
|
| 87 |
+
|
| 88 |
+
id: str
|
| 89 |
+
name: str
|
| 90 |
+
component_type: ComponentType
|
| 91 |
+
u_value: float # W/(m²·K)
|
| 92 |
+
area: float # m²
|
| 93 |
+
orientation: Orientation = Orientation.NOT_APPLICABLE
|
| 94 |
+
color: str = "Medium" # Light, Medium, Dark
|
| 95 |
+
material_layers: List[MaterialLayer] = field(default_factory=list)
|
| 96 |
+
|
| 97 |
+
def __post_init__(self):
|
| 98 |
+
"""Validate component data after initialization."""
|
| 99 |
+
if self.area <= 0:
|
| 100 |
+
raise ValueError("Area must be greater than zero")
|
| 101 |
+
if self.u_value < 0:
|
| 102 |
+
raise ValueError("U-value cannot be negative")
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def r_value(self) -> float:
|
| 106 |
+
"""Calculate the total thermal resistance (R-value) in m²·K/W."""
|
| 107 |
+
return 1 / self.u_value if self.u_value > 0 else float('inf')
|
| 108 |
+
|
| 109 |
+
@property
|
| 110 |
+
def total_r_value_from_layers(self) -> Optional[float]:
|
| 111 |
+
"""Calculate the total R-value from material layers if available."""
|
| 112 |
+
if not self.material_layers:
|
| 113 |
+
return None
|
| 114 |
+
|
| 115 |
+
# Add surface resistances (interior and exterior)
|
| 116 |
+
r_si = 0.13 # m²·K/W (interior surface resistance)
|
| 117 |
+
r_se = 0.04 # m²·K/W (exterior surface resistance)
|
| 118 |
+
|
| 119 |
+
# Sum the R-values of all layers
|
| 120 |
+
r_layers = sum(layer.r_value for layer in self.material_layers)
|
| 121 |
+
|
| 122 |
+
return r_si + r_layers + r_se
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
def calculated_u_value(self) -> Optional[float]:
|
| 126 |
+
"""Calculate U-value from material layers if available."""
|
| 127 |
+
total_r = self.total_r_value_from_layers
|
| 128 |
+
if total_r is None or total_r == 0:
|
| 129 |
+
return None
|
| 130 |
+
return 1 / total_r
|
| 131 |
+
|
| 132 |
+
def heat_transfer_rate(self, delta_t: float) -> float:
|
| 133 |
+
"""
|
| 134 |
+
Calculate heat transfer rate through the component.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
delta_t: Temperature difference across the component in K or °C
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
Heat transfer rate in Watts
|
| 141 |
+
"""
|
| 142 |
+
return self.u_value * self.area * delta_t
|
| 143 |
+
|
| 144 |
+
def to_dict(self) -> Dict:
|
| 145 |
+
"""Convert the building component to a dictionary."""
|
| 146 |
+
return {
|
| 147 |
+
"id": self.id,
|
| 148 |
+
"name": self.name,
|
| 149 |
+
"component_type": self.component_type.value,
|
| 150 |
+
"u_value": self.u_value,
|
| 151 |
+
"area": self.area,
|
| 152 |
+
"orientation": self.orientation.value,
|
| 153 |
+
"color": self.color,
|
| 154 |
+
"r_value": self.r_value,
|
| 155 |
+
"material_layers": [layer.to_dict() for layer in self.material_layers],
|
| 156 |
+
"calculated_u_value": self.calculated_u_value,
|
| 157 |
+
"total_r_value_from_layers": self.total_r_value_from_layers
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@dataclass
|
| 162 |
+
class Wall(BuildingComponent):
|
| 163 |
+
"""Class representing a wall component."""
|
| 164 |
+
|
| 165 |
+
has_sun_exposure: bool = True
|
| 166 |
+
wall_type: str = "Custom" # Brick, Concrete, Wood Frame, etc.
|
| 167 |
+
wall_group: str = "A" # ASHRAE wall group (A, B, C, D, E, F, G, H)
|
| 168 |
+
gross_area: float = None # m² (before subtracting windows/doors)
|
| 169 |
+
net_area: float = None # m² (after subtracting windows/doors)
|
| 170 |
+
windows: List[str] = field(default_factory=list) # List of window IDs
|
| 171 |
+
doors: List[str] = field(default_factory=list) # List of door IDs
|
| 172 |
+
|
| 173 |
+
def __post_init__(self):
|
| 174 |
+
"""Initialize wall-specific attributes."""
|
| 175 |
+
super().__post_init__()
|
| 176 |
+
self.component_type = ComponentType.WALL
|
| 177 |
+
|
| 178 |
+
# Set net area equal to area if not specified
|
| 179 |
+
if self.net_area is None:
|
| 180 |
+
self.net_area = self.area
|
| 181 |
+
|
| 182 |
+
# Set gross area equal to net area if not specified
|
| 183 |
+
if self.gross_area is None:
|
| 184 |
+
self.gross_area = self.net_area
|
| 185 |
+
|
| 186 |
+
def update_net_area(self, window_areas: Dict[str, float], door_areas: Dict[str, float]):
|
| 187 |
+
"""
|
| 188 |
+
Update the net wall area by subtracting windows and doors.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
window_areas: Dictionary mapping window IDs to areas
|
| 192 |
+
door_areas: Dictionary mapping door IDs to areas
|
| 193 |
+
"""
|
| 194 |
+
total_window_area = sum(window_areas.get(window_id, 0) for window_id in self.windows)
|
| 195 |
+
total_door_area = sum(door_areas.get(door_id, 0) for door_id in self.doors)
|
| 196 |
+
|
| 197 |
+
self.net_area = self.gross_area - total_window_area - total_door_area
|
| 198 |
+
self.area = self.net_area # Update the main area property
|
| 199 |
+
|
| 200 |
+
if self.net_area <= 0:
|
| 201 |
+
raise ValueError("Net wall area cannot be negative or zero")
|
| 202 |
+
|
| 203 |
+
def to_dict(self) -> Dict:
|
| 204 |
+
"""Convert the wall to a dictionary."""
|
| 205 |
+
wall_dict = super().to_dict()
|
| 206 |
+
wall_dict.update({
|
| 207 |
+
"has_sun_exposure": self.has_sun_exposure,
|
| 208 |
+
"wall_type": self.wall_type,
|
| 209 |
+
"wall_group": self.wall_group,
|
| 210 |
+
"gross_area": self.gross_area,
|
| 211 |
+
"net_area": self.net_area,
|
| 212 |
+
"windows": self.windows,
|
| 213 |
+
"doors": self.doors
|
| 214 |
+
})
|
| 215 |
+
return wall_dict
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@dataclass
|
| 219 |
+
class Roof(BuildingComponent):
|
| 220 |
+
"""Class representing a roof component."""
|
| 221 |
+
|
| 222 |
+
roof_type: str = "Custom" # Flat, Pitched, etc.
|
| 223 |
+
roof_group: str = "A" # ASHRAE roof group
|
| 224 |
+
pitch: float = 0.0 # Roof pitch in degrees
|
| 225 |
+
has_suspended_ceiling: bool = False
|
| 226 |
+
ceiling_plenum_height: float = 0.0 # m
|
| 227 |
+
|
| 228 |
+
def __post_init__(self):
|
| 229 |
+
"""Initialize roof-specific attributes."""
|
| 230 |
+
super().__post_init__()
|
| 231 |
+
self.component_type = ComponentType.ROOF
|
| 232 |
+
self.orientation = Orientation.HORIZONTAL
|
| 233 |
+
|
| 234 |
+
def to_dict(self) -> Dict:
|
| 235 |
+
"""Convert the roof to a dictionary."""
|
| 236 |
+
roof_dict = super().to_dict()
|
| 237 |
+
roof_dict.update({
|
| 238 |
+
"roof_type": self.roof_type,
|
| 239 |
+
"roof_group": self.roof_group,
|
| 240 |
+
"pitch": self.pitch,
|
| 241 |
+
"has_suspended_ceiling": self.has_suspended_ceiling,
|
| 242 |
+
"ceiling_plenum_height": self.ceiling_plenum_height
|
| 243 |
+
})
|
| 244 |
+
return roof_dict
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
@dataclass
|
| 248 |
+
class Floor(BuildingComponent):
|
| 249 |
+
"""Class representing a floor component."""
|
| 250 |
+
|
| 251 |
+
floor_type: str = "Custom" # Slab-on-grade, Raised, etc.
|
| 252 |
+
is_ground_contact: bool = False
|
| 253 |
+
perimeter_length: float = 0.0 # m (for slab-on-grade floors)
|
| 254 |
+
|
| 255 |
+
def __post_init__(self):
|
| 256 |
+
"""Initialize floor-specific attributes."""
|
| 257 |
+
super().__post_init__()
|
| 258 |
+
self.component_type = ComponentType.FLOOR
|
| 259 |
+
self.orientation = Orientation.HORIZONTAL
|
| 260 |
+
|
| 261 |
+
def to_dict(self) -> Dict:
|
| 262 |
+
"""Convert the floor to a dictionary."""
|
| 263 |
+
floor_dict = super().to_dict()
|
| 264 |
+
floor_dict.update({
|
| 265 |
+
"floor_type": self.floor_type,
|
| 266 |
+
"is_ground_contact": self.is_ground_contact,
|
| 267 |
+
"perimeter_length": self.perimeter_length
|
| 268 |
+
})
|
| 269 |
+
return floor_dict
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@dataclass
|
| 273 |
+
class Fenestration(BuildingComponent):
|
| 274 |
+
"""Base class for fenestration components (windows, doors, skylights)."""
|
| 275 |
+
|
| 276 |
+
shgc: float = 0.7 # Solar Heat Gain Coefficient
|
| 277 |
+
vt: float = 0.7 # Visible Transmittance
|
| 278 |
+
frame_type: str = "Aluminum" # Aluminum, Wood, Vinyl, etc.
|
| 279 |
+
frame_width: float = 0.05 # m
|
| 280 |
+
has_shading: bool = False
|
| 281 |
+
shading_type: str = None # Internal, External, Between-glass
|
| 282 |
+
shading_coefficient: float = 1.0 # 0-1 (1 = no shading)
|
| 283 |
+
|
| 284 |
+
def __post_init__(self):
|
| 285 |
+
"""Initialize fenestration-specific attributes."""
|
| 286 |
+
super().__post_init__()
|
| 287 |
+
|
| 288 |
+
if self.shgc < 0 or self.shgc > 1:
|
| 289 |
+
raise ValueError("SHGC must be between 0 and 1")
|
| 290 |
+
if self.vt < 0 or self.vt > 1:
|
| 291 |
+
raise ValueError("VT must be between 0 and 1")
|
| 292 |
+
if self.shading_coefficient < 0 or self.shading_coefficient > 1:
|
| 293 |
+
raise ValueError("Shading coefficient must be between 0 and 1")
|
| 294 |
+
|
| 295 |
+
@property
|
| 296 |
+
def effective_shgc(self) -> float:
|
| 297 |
+
"""Calculate the effective SHGC considering shading."""
|
| 298 |
+
return self.shgc * self.shading_coefficient
|
| 299 |
+
|
| 300 |
+
def to_dict(self) -> Dict:
|
| 301 |
+
"""Convert the fenestration to a dictionary."""
|
| 302 |
+
fenestration_dict = super().to_dict()
|
| 303 |
+
fenestration_dict.update({
|
| 304 |
+
"shgc": self.shgc,
|
| 305 |
+
"vt": self.vt,
|
| 306 |
+
"frame_type": self.frame_type,
|
| 307 |
+
"frame_width": self.frame_width,
|
| 308 |
+
"has_shading": self.has_shading,
|
| 309 |
+
"shading_type": self.shading_type,
|
| 310 |
+
"shading_coefficient": self.shading_coefficient,
|
| 311 |
+
"effective_shgc": self.effective_shgc
|
| 312 |
+
})
|
| 313 |
+
return fenestration_dict
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
@dataclass
|
| 317 |
+
class Window(Fenestration):
|
| 318 |
+
"""Class representing a window component."""
|
| 319 |
+
|
| 320 |
+
window_type: str = "Custom" # Single, Double, Triple glazed, etc.
|
| 321 |
+
glazing_layers: int = 2 # Number of glazing layers
|
| 322 |
+
gas_fill: str = "Air" # Air, Argon, Krypton, etc.
|
| 323 |
+
low_e_coating: bool = False
|
| 324 |
+
width: float = 1.0 # m
|
| 325 |
+
height: float = 1.0 # m
|
| 326 |
+
wall_id: str = None # ID of the wall containing this window
|
| 327 |
+
|
| 328 |
+
def __post_init__(self):
|
| 329 |
+
"""Initialize window-specific attributes."""
|
| 330 |
+
super().__post_init__()
|
| 331 |
+
self.component_type = ComponentType.WINDOW
|
| 332 |
+
|
| 333 |
+
# Calculate area from width and height if not provided
|
| 334 |
+
if self.area <= 0 and self.width > 0 and self.height > 0:
|
| 335 |
+
self.area = self.width * self.height
|
| 336 |
+
|
| 337 |
+
def to_dict(self) -> Dict:
|
| 338 |
+
"""Convert the window to a dictionary."""
|
| 339 |
+
window_dict = super().to_dict()
|
| 340 |
+
window_dict.update({
|
| 341 |
+
"window_type": self.window_type,
|
| 342 |
+
"glazing_layers": self.glazing_layers,
|
| 343 |
+
"gas_fill": self.gas_fill,
|
| 344 |
+
"low_e_coating": self.low_e_coating,
|
| 345 |
+
"width": self.width,
|
| 346 |
+
"height": self.height,
|
| 347 |
+
"wall_id": self.wall_id
|
| 348 |
+
})
|
| 349 |
+
return window_dict
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
@dataclass
|
| 353 |
+
class Door(Fenestration):
|
| 354 |
+
"""Class representing a door component."""
|
| 355 |
+
|
| 356 |
+
door_type: str = "Custom" # Solid, Partially glazed, etc.
|
| 357 |
+
glazing_percentage: float = 0.0 # Percentage of door area that is glazed (0-100)
|
| 358 |
+
width: float = 0.9 # m
|
| 359 |
+
height: float = 2.1 # m
|
| 360 |
+
wall_id: str = None # ID of the wall containing this door
|
| 361 |
+
|
| 362 |
+
def __post_init__(self):
|
| 363 |
+
"""Initialize door-specific attributes."""
|
| 364 |
+
super().__post_init__()
|
| 365 |
+
self.component_type = ComponentType.DOOR
|
| 366 |
+
|
| 367 |
+
# Calculate area from width and height if not provided
|
| 368 |
+
if self.area <= 0 and self.width > 0 and self.height > 0:
|
| 369 |
+
self.area = self.width * self.height
|
| 370 |
+
|
| 371 |
+
if self.glazing_percentage < 0 or self.glazing_percentage > 100:
|
| 372 |
+
raise ValueError("Glazing percentage must be between 0 and 100")
|
| 373 |
+
|
| 374 |
+
@property
|
| 375 |
+
def glazing_area(self) -> float:
|
| 376 |
+
"""Calculate the glazed area of the door in m²."""
|
| 377 |
+
return self.area * (self.glazing_percentage / 100)
|
| 378 |
+
|
| 379 |
+
@property
|
| 380 |
+
def opaque_area(self) -> float:
|
| 381 |
+
"""Calculate the opaque area of the door in m²."""
|
| 382 |
+
return self.area - self.glazing_area
|
| 383 |
+
|
| 384 |
+
def to_dict(self) -> Dict:
|
| 385 |
+
"""Convert the door to a dictionary."""
|
| 386 |
+
door_dict = super().to_dict()
|
| 387 |
+
door_dict.update({
|
| 388 |
+
"door_type": self.door_type,
|
| 389 |
+
"glazing_percentage": self.glazing_percentage,
|
| 390 |
+
"width": self.width,
|
| 391 |
+
"height": self.height,
|
| 392 |
+
"wall_id": self.wall_id,
|
| 393 |
+
"glazing_area": self.glazing_area,
|
| 394 |
+
"opaque_area": self.opaque_area
|
| 395 |
+
})
|
| 396 |
+
return door_dict
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
@dataclass
|
| 400 |
+
class Skylight(Fenestration):
|
| 401 |
+
"""Class representing a skylight component."""
|
| 402 |
+
|
| 403 |
+
skylight_type: str = "Custom" # Flat, Domed, etc.
|
| 404 |
+
glazing_layers: int = 2 # Number of glazing layers
|
| 405 |
+
gas_fill: str = "Air" # Air, Argon, Krypton, etc.
|
| 406 |
+
low_e_coating: bool = False
|
| 407 |
+
width: float = 1.0 # m
|
| 408 |
+
length: float = 1.0 # m
|
| 409 |
+
roof_id: str = None # ID of the roof containing this skylight
|
| 410 |
+
|
| 411 |
+
def __post_init__(self):
|
| 412 |
+
"""Initialize skylight-specific attributes."""
|
| 413 |
+
super().__post_init__()
|
| 414 |
+
self.component_type = ComponentType.SKYLIGHT
|
| 415 |
+
self.orientation = Orientation.HORIZONTAL
|
| 416 |
+
|
| 417 |
+
# Calculate area from width and length if not provided
|
| 418 |
+
if self.area <= 0 and self.width > 0 and self.length > 0:
|
| 419 |
+
self.area = self.width * self.length
|
| 420 |
+
|
| 421 |
+
def to_dict(self) -> Dict:
|
| 422 |
+
"""Convert the skylight to a dictionary."""
|
| 423 |
+
skylight_dict = super().to_dict()
|
| 424 |
+
skylight_dict.update({
|
| 425 |
+
"skylight_type": self.skylight_type,
|
| 426 |
+
"glazing_layers": self.glazing_layers,
|
| 427 |
+
"gas_fill": self.gas_fill,
|
| 428 |
+
"low_e_coating": self.low_e_coating,
|
| 429 |
+
"width": self.width,
|
| 430 |
+
"length": self.length,
|
| 431 |
+
"roof_id": self.roof_id
|
| 432 |
+
})
|
| 433 |
+
return skylight_dict
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
class BuildingComponentFactory:
|
| 437 |
+
"""Factory class for creating building components."""
|
| 438 |
+
|
| 439 |
+
@staticmethod
|
| 440 |
+
def create_component(component_data: Dict) -> BuildingComponent:
|
| 441 |
+
"""
|
| 442 |
+
Create a building component from a dictionary of data.
|
| 443 |
+
|
| 444 |
+
Args:
|
| 445 |
+
component_data: Dictionary containing component data
|
| 446 |
+
|
| 447 |
+
Returns:
|
| 448 |
+
A BuildingComponent object of the appropriate type
|
| 449 |
+
"""
|
| 450 |
+
component_type = component_data.get("component_type")
|
| 451 |
+
|
| 452 |
+
if component_type == ComponentType.WALL.value:
|
| 453 |
+
return Wall(**component_data)
|
| 454 |
+
elif component_type == ComponentType.ROOF.value:
|
| 455 |
+
return Roof(**component_data)
|
| 456 |
+
elif component_type == ComponentType.FLOOR.value:
|
| 457 |
+
return Floor(**component_data)
|
| 458 |
+
elif component_type == ComponentType.WINDOW.value:
|
| 459 |
+
return Window(**component_data)
|
| 460 |
+
elif component_type == ComponentType.DOOR.value:
|
| 461 |
+
return Door(**component_data)
|
| 462 |
+
elif component_type == ComponentType.SKYLIGHT.value:
|
| 463 |
+
return Skylight(**component_data)
|
| 464 |
+
else:
|
| 465 |
+
raise ValueError(f"Unknown component type: {component_type}")
|
data/climate_data.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ASHRAE 169 climate data module for HVAC Load Calculator.
|
| 3 |
+
This module provides access to climate data for various locations based on ASHRAE 169 standard.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
# Define paths
|
| 14 |
+
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class ClimateLocation:
|
| 19 |
+
"""Class representing a climate location with ASHRAE 169 data."""
|
| 20 |
+
|
| 21 |
+
id: str
|
| 22 |
+
country: str
|
| 23 |
+
state_province: str
|
| 24 |
+
city: str
|
| 25 |
+
latitude: float
|
| 26 |
+
longitude: float
|
| 27 |
+
elevation: float # meters
|
| 28 |
+
climate_zone: str
|
| 29 |
+
heating_degree_days: float # base 18°C
|
| 30 |
+
cooling_degree_days: float # base 18°C
|
| 31 |
+
|
| 32 |
+
# Design conditions
|
| 33 |
+
winter_design_temp: float # 99.6% heating design temperature (°C)
|
| 34 |
+
summer_design_temp_db: float # 0.4% cooling design dry-bulb temperature (°C)
|
| 35 |
+
summer_design_temp_wb: float # 0.4% cooling design wet-bulb temperature (°C)
|
| 36 |
+
summer_daily_range: float # Mean daily temperature range in summer (°C)
|
| 37 |
+
|
| 38 |
+
# Monthly data
|
| 39 |
+
monthly_temps: Dict[str, float] # Average monthly temperatures (°C)
|
| 40 |
+
monthly_humidity: Dict[str, float] # Average monthly relative humidity (%)
|
| 41 |
+
|
| 42 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 43 |
+
"""Convert the climate location to a dictionary."""
|
| 44 |
+
return {
|
| 45 |
+
"id": self.id,
|
| 46 |
+
"country": self.country,
|
| 47 |
+
"state_province": self.state_province,
|
| 48 |
+
"city": self.city,
|
| 49 |
+
"latitude": self.latitude,
|
| 50 |
+
"longitude": self.longitude,
|
| 51 |
+
"elevation": self.elevation,
|
| 52 |
+
"climate_zone": self.climate_zone,
|
| 53 |
+
"heating_degree_days": self.heating_degree_days,
|
| 54 |
+
"cooling_degree_days": self.cooling_degree_days,
|
| 55 |
+
"winter_design_temp": self.winter_design_temp,
|
| 56 |
+
"summer_design_temp_db": self.summer_design_temp_db,
|
| 57 |
+
"summer_design_temp_wb": self.summer_design_temp_wb,
|
| 58 |
+
"summer_daily_range": self.summer_daily_range,
|
| 59 |
+
"monthly_temps": self.monthly_temps,
|
| 60 |
+
"monthly_humidity": self.monthly_humidity
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class ClimateData:
|
| 65 |
+
"""Class for managing ASHRAE 169 climate data."""
|
| 66 |
+
|
| 67 |
+
def __init__(self):
|
| 68 |
+
"""Initialize climate data."""
|
| 69 |
+
self.locations = self._load_climate_locations()
|
| 70 |
+
self.countries = sorted(list(set(loc.country for loc in self.locations.values())))
|
| 71 |
+
self.country_states = self._group_locations_by_country_state()
|
| 72 |
+
|
| 73 |
+
def _load_climate_locations(self) -> Dict[str, ClimateLocation]:
|
| 74 |
+
"""
|
| 75 |
+
Load climate location data.
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
Dictionary of climate locations indexed by ID
|
| 79 |
+
"""
|
| 80 |
+
# This would typically load from a JSON or CSV file with ASHRAE 169 data
|
| 81 |
+
# For now, we'll define some sample locations inline
|
| 82 |
+
|
| 83 |
+
# Sample monthly data (for all locations in this example)
|
| 84 |
+
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
| 85 |
+
|
| 86 |
+
# New York monthly temperatures (°C)
|
| 87 |
+
ny_temps = {
|
| 88 |
+
"Jan": 0.5, "Feb": 2.1, "Mar": 6.3, "Apr": 12.5, "May": 18.2,
|
| 89 |
+
"Jun": 23.1, "Jul": 25.8, "Aug": 24.9, "Sep": 20.7, "Oct": 14.3,
|
| 90 |
+
"Nov": 8.2, "Dec": 2.4
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
# New York monthly humidity (%)
|
| 94 |
+
ny_humidity = {
|
| 95 |
+
"Jan": 65, "Feb": 62, "Mar": 58, "Apr": 55, "May": 60,
|
| 96 |
+
"Jun": 65, "Jul": 68, "Aug": 70, "Sep": 68, "Oct": 63,
|
| 97 |
+
"Nov": 67, "Dec": 68
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
# Los Angeles monthly temperatures (°C)
|
| 101 |
+
la_temps = {
|
| 102 |
+
"Jan": 14.6, "Feb": 15.1, "Mar": 15.8, "Apr": 17.1, "May": 18.3,
|
| 103 |
+
"Jun": 20.1, "Jul": 22.3, "Aug": 22.9, "Sep": 22.1, "Oct": 20.3,
|
| 104 |
+
"Nov": 17.2, "Dec": 14.9
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
# Los Angeles monthly humidity (%)
|
| 108 |
+
la_humidity = {
|
| 109 |
+
"Jan": 63, "Feb": 67, "Mar": 70, "Apr": 71, "May": 74,
|
| 110 |
+
"Jun": 75, "Jul": 76, "Aug": 76, "Sep": 74, "Oct": 70,
|
| 111 |
+
"Nov": 65, "Dec": 63
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# Chicago monthly temperatures (°C)
|
| 115 |
+
chi_temps = {
|
| 116 |
+
"Jan": -3.5, "Feb": -1.2, "Mar": 4.1, "Apr": 10.3, "May": 16.5,
|
| 117 |
+
"Jun": 22.1, "Jul": 24.8, "Aug": 23.9, "Sep": 19.7, "Oct": 12.8,
|
| 118 |
+
"Nov": 5.2, "Dec": -1.4
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
# Chicago monthly humidity (%)
|
| 122 |
+
chi_humidity = {
|
| 123 |
+
"Jan": 72, "Feb": 70, "Mar": 65, "Apr": 60, "May": 64,
|
| 124 |
+
"Jun": 67, "Jul": 70, "Aug": 73, "Sep": 71, "Oct": 68,
|
| 125 |
+
"Nov": 72, "Dec": 75
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
# London monthly temperatures (°C)
|
| 129 |
+
lon_temps = {
|
| 130 |
+
"Jan": 5.2, "Feb": 5.5, "Mar": 7.4, "Apr": 9.9, "May": 13.3,
|
| 131 |
+
"Jun": 16.7, "Jul": 18.7, "Aug": 18.3, "Sep": 15.9, "Oct": 12.2,
|
| 132 |
+
"Nov": 8.3, "Dec": 5.9
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
# London monthly humidity (%)
|
| 136 |
+
lon_humidity = {
|
| 137 |
+
"Jan": 84, "Feb": 80, "Mar": 76, "Apr": 72, "May": 70,
|
| 138 |
+
"Jun": 70, "Jul": 71, "Aug": 72, "Sep": 75, "Oct": 80,
|
| 139 |
+
"Nov": 84, "Dec": 86
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
# Sydney monthly temperatures (°C)
|
| 143 |
+
syd_temps = {
|
| 144 |
+
"Jan": 23.5, "Feb": 23.4, "Mar": 22.1, "Apr": 19.5, "May": 16.5,
|
| 145 |
+
"Jun": 14.1, "Jul": 13.4, "Aug": 14.5, "Sep": 16.6, "Oct": 18.8,
|
| 146 |
+
"Nov": 20.6, "Dec": 22.6
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
# Sydney monthly humidity (%)
|
| 150 |
+
syd_humidity = {
|
| 151 |
+
"Jan": 65, "Feb": 68, "Mar": 68, "Apr": 67, "May": 70,
|
| 152 |
+
"Jun": 70, "Jul": 68, "Aug": 63, "Sep": 60, "Oct": 60,
|
| 153 |
+
"Nov": 62, "Dec": 63
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
# Create sample locations
|
| 157 |
+
locations = {
|
| 158 |
+
"US-NY-NYC": ClimateLocation(
|
| 159 |
+
id="US-NY-NYC",
|
| 160 |
+
country="United States",
|
| 161 |
+
state_province="New York",
|
| 162 |
+
city="New York",
|
| 163 |
+
latitude=40.7128,
|
| 164 |
+
longitude=-74.0060,
|
| 165 |
+
elevation=10.0,
|
| 166 |
+
climate_zone="4A",
|
| 167 |
+
heating_degree_days=2600,
|
| 168 |
+
cooling_degree_days=1200,
|
| 169 |
+
winter_design_temp=-8.3,
|
| 170 |
+
summer_design_temp_db=32.8,
|
| 171 |
+
summer_design_temp_wb=25.6,
|
| 172 |
+
summer_daily_range=8.3,
|
| 173 |
+
monthly_temps=ny_temps,
|
| 174 |
+
monthly_humidity=ny_humidity
|
| 175 |
+
),
|
| 176 |
+
"US-CA-LAX": ClimateLocation(
|
| 177 |
+
id="US-CA-LAX",
|
| 178 |
+
country="United States",
|
| 179 |
+
state_province="California",
|
| 180 |
+
city="Los Angeles",
|
| 181 |
+
latitude=34.0522,
|
| 182 |
+
longitude=-118.2437,
|
| 183 |
+
elevation=93.0,
|
| 184 |
+
climate_zone="3B",
|
| 185 |
+
heating_degree_days=800,
|
| 186 |
+
cooling_degree_days=1200,
|
| 187 |
+
winter_design_temp=8.3,
|
| 188 |
+
summer_design_temp_db=32.2,
|
| 189 |
+
summer_design_temp_wb=23.3,
|
| 190 |
+
summer_daily_range=6.7,
|
| 191 |
+
monthly_temps=la_temps,
|
| 192 |
+
monthly_humidity=la_humidity
|
| 193 |
+
),
|
| 194 |
+
"US-IL-CHI": ClimateLocation(
|
| 195 |
+
id="US-IL-CHI",
|
| 196 |
+
country="United States",
|
| 197 |
+
state_province="Illinois",
|
| 198 |
+
city="Chicago",
|
| 199 |
+
latitude=41.8781,
|
| 200 |
+
longitude=-87.6298,
|
| 201 |
+
elevation=179.0,
|
| 202 |
+
climate_zone="5A",
|
| 203 |
+
heating_degree_days=3500,
|
| 204 |
+
cooling_degree_days=1000,
|
| 205 |
+
winter_design_temp=-16.7,
|
| 206 |
+
summer_design_temp_db=33.3,
|
| 207 |
+
summer_design_temp_wb=25.6,
|
| 208 |
+
summer_daily_range=8.9,
|
| 209 |
+
monthly_temps=chi_temps,
|
| 210 |
+
monthly_humidity=chi_humidity
|
| 211 |
+
),
|
| 212 |
+
"UK-LDN": ClimateLocation(
|
| 213 |
+
id="UK-LDN",
|
| 214 |
+
country="United Kingdom",
|
| 215 |
+
state_province="England",
|
| 216 |
+
city="London",
|
| 217 |
+
latitude=51.5074,
|
| 218 |
+
longitude=-0.1278,
|
| 219 |
+
elevation=35.0,
|
| 220 |
+
climate_zone="4A",
|
| 221 |
+
heating_degree_days=2500,
|
| 222 |
+
cooling_degree_days=200,
|
| 223 |
+
winter_design_temp=-3.9,
|
| 224 |
+
summer_design_temp_db=28.3,
|
| 225 |
+
summer_design_temp_wb=20.0,
|
| 226 |
+
summer_daily_range=10.0,
|
| 227 |
+
monthly_temps=lon_temps,
|
| 228 |
+
monthly_humidity=lon_humidity
|
| 229 |
+
),
|
| 230 |
+
"AU-NSW-SYD": ClimateLocation(
|
| 231 |
+
id="AU-NSW-SYD",
|
| 232 |
+
country="Australia",
|
| 233 |
+
state_province="New South Wales",
|
| 234 |
+
city="Sydney",
|
| 235 |
+
latitude=-33.8688,
|
| 236 |
+
longitude=151.2093,
|
| 237 |
+
elevation=3.0,
|
| 238 |
+
climate_zone="3C",
|
| 239 |
+
heating_degree_days=600,
|
| 240 |
+
cooling_degree_days=900,
|
| 241 |
+
winter_design_temp=7.2,
|
| 242 |
+
summer_design_temp_db=31.1,
|
| 243 |
+
summer_design_temp_wb=24.4,
|
| 244 |
+
summer_daily_range=7.8,
|
| 245 |
+
monthly_temps=syd_temps,
|
| 246 |
+
monthly_humidity=syd_humidity
|
| 247 |
+
)
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
return locations
|
| 251 |
+
|
| 252 |
+
def _group_locations_by_country_state(self) -> Dict[str, Dict[str, List[str]]]:
|
| 253 |
+
"""
|
| 254 |
+
Group locations by country and state/province.
|
| 255 |
+
|
| 256 |
+
Returns:
|
| 257 |
+
Nested dictionary of countries, states, and cities
|
| 258 |
+
"""
|
| 259 |
+
result = {}
|
| 260 |
+
|
| 261 |
+
for loc in self.locations.values():
|
| 262 |
+
if loc.country not in result:
|
| 263 |
+
result[loc.country] = {}
|
| 264 |
+
|
| 265 |
+
if loc.state_province not in result[loc.country]:
|
| 266 |
+
result[loc.country][loc.state_province] = []
|
| 267 |
+
|
| 268 |
+
result[loc.country][loc.state_province].append(loc.city)
|
| 269 |
+
|
| 270 |
+
# Sort states and cities
|
| 271 |
+
for country in result:
|
| 272 |
+
for state in result[country]:
|
| 273 |
+
result[country][state] = sorted(result[country][state])
|
| 274 |
+
|
| 275 |
+
return result
|
| 276 |
+
|
| 277 |
+
def get_location(self, location_id: str) -> Optional[ClimateLocation]:
|
| 278 |
+
"""
|
| 279 |
+
Get climate location by ID.
|
| 280 |
+
|
| 281 |
+
Args:
|
| 282 |
+
location_id: Location identifier
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
ClimateLocation object or None if not found
|
| 286 |
+
"""
|
| 287 |
+
return self.locations.get(location_id)
|
| 288 |
+
|
| 289 |
+
def find_location(self, country: str, state_province: str = None, city: str = None) -> Optional[ClimateLocation]:
|
| 290 |
+
"""
|
| 291 |
+
Find a climate location by country, state/province, and city.
|
| 292 |
+
|
| 293 |
+
Args:
|
| 294 |
+
country: Country name
|
| 295 |
+
state_province: State or province name (optional)
|
| 296 |
+
city: City name (optional)
|
| 297 |
+
|
| 298 |
+
Returns:
|
| 299 |
+
ClimateLocation object or None if not found
|
| 300 |
+
"""
|
| 301 |
+
for loc in self.locations.values():
|
| 302 |
+
if loc.country == country:
|
| 303 |
+
if state_province is None or loc.state_province == state_province:
|
| 304 |
+
if city is None or loc.city == city:
|
| 305 |
+
return loc
|
| 306 |
+
return None
|
| 307 |
+
|
| 308 |
+
def find_locations_by_climate_zone(self, climate_zone: str) -> List[ClimateLocation]:
|
| 309 |
+
"""
|
| 310 |
+
Find climate locations by climate zone.
|
| 311 |
+
|
| 312 |
+
Args:
|
| 313 |
+
climate_zone: ASHRAE climate zone
|
| 314 |
+
|
| 315 |
+
Returns:
|
| 316 |
+
List of ClimateLocation objects
|
| 317 |
+
"""
|
| 318 |
+
return [loc for loc in self.locations.values() if loc.climate_zone == climate_zone]
|
| 319 |
+
|
| 320 |
+
def get_states_for_country(self, country: str) -> List[str]:
|
| 321 |
+
"""
|
| 322 |
+
Get states/provinces for a country.
|
| 323 |
+
|
| 324 |
+
Args:
|
| 325 |
+
country: Country name
|
| 326 |
+
|
| 327 |
+
Returns:
|
| 328 |
+
List of state/province names
|
| 329 |
+
"""
|
| 330 |
+
if country in self.country_states:
|
| 331 |
+
return sorted(self.country_states[country].keys())
|
| 332 |
+
return []
|
| 333 |
+
|
| 334 |
+
def get_cities_for_state(self, country: str, state_province: str) -> List[str]:
|
| 335 |
+
"""
|
| 336 |
+
Get cities for a state/province.
|
| 337 |
+
|
| 338 |
+
Args:
|
| 339 |
+
country: Country name
|
| 340 |
+
state_province: State or province name
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
List of city names
|
| 344 |
+
"""
|
| 345 |
+
if country in self.country_states and state_province in self.country_states[country]:
|
| 346 |
+
return self.country_states[country][state_province]
|
| 347 |
+
return []
|
| 348 |
+
|
| 349 |
+
def get_location_id(self, country: str, state_province: str, city: str) -> Optional[str]:
|
| 350 |
+
"""
|
| 351 |
+
Get location ID for a city.
|
| 352 |
+
|
| 353 |
+
Args:
|
| 354 |
+
country: Country name
|
| 355 |
+
state_province: State or province name
|
| 356 |
+
city: City name
|
| 357 |
+
|
| 358 |
+
Returns:
|
| 359 |
+
Location ID or None if not found
|
| 360 |
+
"""
|
| 361 |
+
for loc_id, loc in self.locations.items():
|
| 362 |
+
if (loc.country == country and
|
| 363 |
+
loc.state_province == state_province and
|
| 364 |
+
loc.city == city):
|
| 365 |
+
return loc_id
|
| 366 |
+
return None
|
| 367 |
+
|
| 368 |
+
def export_to_json(self, file_path: str) -> None:
|
| 369 |
+
"""
|
| 370 |
+
Export all climate data to a JSON file.
|
| 371 |
+
|
| 372 |
+
Args:
|
| 373 |
+
file_path: Path to the output JSON file
|
| 374 |
+
"""
|
| 375 |
+
data = {loc_id: loc.to_dict() for loc_id, loc in self.locations.items()}
|
| 376 |
+
|
| 377 |
+
with open(file_path, 'w') as f:
|
| 378 |
+
json.dump(data, f, indent=4)
|
| 379 |
+
|
| 380 |
+
@classmethod
|
| 381 |
+
def from_json(cls, file_path: str) -> 'ClimateData':
|
| 382 |
+
"""
|
| 383 |
+
Create a ClimateData instance from a JSON file.
|
| 384 |
+
|
| 385 |
+
Args:
|
| 386 |
+
file_path: Path to the input JSON file
|
| 387 |
+
|
| 388 |
+
Returns:
|
| 389 |
+
A new ClimateData instance
|
| 390 |
+
"""
|
| 391 |
+
with open(file_path, 'r') as f:
|
| 392 |
+
data = json.load(f)
|
| 393 |
+
|
| 394 |
+
climate_data = cls()
|
| 395 |
+
climate_data.locations = {}
|
| 396 |
+
|
| 397 |
+
for loc_id, loc_dict in data.items():
|
| 398 |
+
climate_data.locations[loc_id] = ClimateLocation(
|
| 399 |
+
id=loc_dict["id"],
|
| 400 |
+
country=loc_dict["country"],
|
| 401 |
+
state_province=loc_dict["state_province"],
|
| 402 |
+
city=loc_dict["city"],
|
| 403 |
+
latitude=loc_dict["latitude"],
|
| 404 |
+
longitude=loc_dict["longitude"],
|
| 405 |
+
elevation=loc_dict["elevation"],
|
| 406 |
+
climate_zone=loc_dict["climate_zone"],
|
| 407 |
+
heating_degree_days=loc_dict["heating_degree_days"],
|
| 408 |
+
cooling_degree_days=loc_dict["cooling_degree_days"],
|
| 409 |
+
winter_design_temp=loc_dict["winter_design_temp"],
|
| 410 |
+
summer_design_temp_db=loc_dict["summer_design_temp_db"],
|
| 411 |
+
summer_design_temp_wb=loc_dict["summer_design_temp_wb"],
|
| 412 |
+
summer_daily_range=loc_dict["summer_daily_range"],
|
| 413 |
+
monthly_temps=loc_dict["monthly_temps"],
|
| 414 |
+
monthly_humidity=loc_dict["monthly_humidity"]
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
climate_data.countries = sorted(list(set(loc.country for loc in climate_data.locations.values())))
|
| 418 |
+
climate_data.country_states = climate_data._group_locations_by_country_state()
|
| 419 |
+
|
| 420 |
+
return climate_data
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# Create a singleton instance
|
| 424 |
+
climate_data = ClimateData()
|
| 425 |
+
|
| 426 |
+
# Export climate data to JSON if needed
|
| 427 |
+
if __name__ == "__main__":
|
| 428 |
+
climate_data.export_to_json(os.path.join(DATA_DIR, "climate_data.json"))
|
data/reference_data.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reference data structures for HVAC Load Calculator.
|
| 3 |
+
This module contains reference data for materials, construction types, and other HVAC-related data.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
# Define paths
|
| 12 |
+
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ReferenceData:
|
| 16 |
+
"""Class for managing reference data for the HVAC calculator."""
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
"""Initialize reference data structures."""
|
| 20 |
+
self.materials = self._load_materials()
|
| 21 |
+
self.wall_types = self._load_wall_types()
|
| 22 |
+
self.roof_types = self._load_roof_types()
|
| 23 |
+
self.floor_types = self._load_floor_types()
|
| 24 |
+
self.window_types = self._load_window_types()
|
| 25 |
+
self.door_types = self._load_door_types()
|
| 26 |
+
self.internal_loads = self._load_internal_loads()
|
| 27 |
+
|
| 28 |
+
def _load_materials(self) -> Dict[str, Dict[str, Any]]:
|
| 29 |
+
"""
|
| 30 |
+
Load material properties from reference data.
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Dictionary of material properties
|
| 34 |
+
"""
|
| 35 |
+
# This would typically load from a JSON or CSV file
|
| 36 |
+
# For now, we'll define some common materials inline
|
| 37 |
+
return {
|
| 38 |
+
"brick": {
|
| 39 |
+
"name": "Common Brick",
|
| 40 |
+
"conductivity": 0.72, # W/(m·K)
|
| 41 |
+
"density": 1920, # kg/m³
|
| 42 |
+
"specific_heat": 840, # J/(kg·K)
|
| 43 |
+
"typical_thickness": 0.1 # m
|
| 44 |
+
},
|
| 45 |
+
"concrete": {
|
| 46 |
+
"name": "Concrete",
|
| 47 |
+
"conductivity": 1.4, # W/(m·K)
|
| 48 |
+
"density": 2300, # kg/m³
|
| 49 |
+
"specific_heat": 880, # J/(kg·K)
|
| 50 |
+
"typical_thickness": 0.2 # m
|
| 51 |
+
},
|
| 52 |
+
"mineral_wool": {
|
| 53 |
+
"name": "Mineral Wool Insulation",
|
| 54 |
+
"conductivity": 0.04, # W/(m·K)
|
| 55 |
+
"density": 30, # kg/m³
|
| 56 |
+
"specific_heat": 840, # J/(kg·K)
|
| 57 |
+
"typical_thickness": 0.1 # m
|
| 58 |
+
},
|
| 59 |
+
"eps_insulation": {
|
| 60 |
+
"name": "EPS Insulation",
|
| 61 |
+
"conductivity": 0.035, # W/(m·K)
|
| 62 |
+
"density": 25, # kg/m³
|
| 63 |
+
"specific_heat": 1400, # J/(kg·K)
|
| 64 |
+
"typical_thickness": 0.1 # m
|
| 65 |
+
},
|
| 66 |
+
"gypsum_board": {
|
| 67 |
+
"name": "Gypsum Board",
|
| 68 |
+
"conductivity": 0.25, # W/(m·K)
|
| 69 |
+
"density": 900, # kg/m³
|
| 70 |
+
"specific_heat": 1000, # J/(kg·K)
|
| 71 |
+
"typical_thickness": 0.0125 # m
|
| 72 |
+
},
|
| 73 |
+
"wood": {
|
| 74 |
+
"name": "Wood (Pine)",
|
| 75 |
+
"conductivity": 0.14, # W/(m·K)
|
| 76 |
+
"density": 500, # kg/m³
|
| 77 |
+
"specific_heat": 1600, # J/(kg·K)
|
| 78 |
+
"typical_thickness": 0.025 # m
|
| 79 |
+
},
|
| 80 |
+
"steel": {
|
| 81 |
+
"name": "Steel",
|
| 82 |
+
"conductivity": 50, # W/(m·K)
|
| 83 |
+
"density": 7800, # kg/m³
|
| 84 |
+
"specific_heat": 450, # J/(kg·K)
|
| 85 |
+
"typical_thickness": 0.005 # m
|
| 86 |
+
},
|
| 87 |
+
"glass": {
|
| 88 |
+
"name": "Glass",
|
| 89 |
+
"conductivity": 1.0, # W/(m·K)
|
| 90 |
+
"density": 2500, # kg/m³
|
| 91 |
+
"specific_heat": 840, # J/(kg·K)
|
| 92 |
+
"typical_thickness": 0.006 # m
|
| 93 |
+
},
|
| 94 |
+
"air_gap": {
|
| 95 |
+
"name": "Air Gap",
|
| 96 |
+
"conductivity": 0.024, # W/(m·K)
|
| 97 |
+
"density": 1.2, # kg/m³
|
| 98 |
+
"specific_heat": 1000, # J/(kg·K)
|
| 99 |
+
"typical_thickness": 0.025 # m
|
| 100 |
+
},
|
| 101 |
+
"concrete_block": {
|
| 102 |
+
"name": "Concrete Block",
|
| 103 |
+
"conductivity": 0.51, # W/(m·K)
|
| 104 |
+
"density": 1400, # kg/m³
|
| 105 |
+
"specific_heat": 1000, # J/(kg·K)
|
| 106 |
+
"typical_thickness": 0.2 # m
|
| 107 |
+
},
|
| 108 |
+
"asphalt_shingle": {
|
| 109 |
+
"name": "Asphalt Shingle",
|
| 110 |
+
"conductivity": 0.7, # W/(m·K)
|
| 111 |
+
"density": 1100, # kg/m³
|
| 112 |
+
"specific_heat": 1260, # J/(kg·K)
|
| 113 |
+
"typical_thickness": 0.006 # m
|
| 114 |
+
},
|
| 115 |
+
"carpet": {
|
| 116 |
+
"name": "Carpet",
|
| 117 |
+
"conductivity": 0.06, # W/(m·K)
|
| 118 |
+
"density": 200, # kg/m³
|
| 119 |
+
"specific_heat": 1300, # J/(kg·K)
|
| 120 |
+
"typical_thickness": 0.01 # m
|
| 121 |
+
},
|
| 122 |
+
"vinyl_flooring": {
|
| 123 |
+
"name": "Vinyl Flooring",
|
| 124 |
+
"conductivity": 0.17, # W/(m·K)
|
| 125 |
+
"density": 1200, # kg/m³
|
| 126 |
+
"specific_heat": 1460, # J/(kg·K)
|
| 127 |
+
"typical_thickness": 0.003 # m
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
def _load_wall_types(self) -> Dict[str, Dict[str, Any]]:
|
| 132 |
+
"""
|
| 133 |
+
Load predefined wall types from reference data.
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Dictionary of wall types with properties
|
| 137 |
+
"""
|
| 138 |
+
return {
|
| 139 |
+
"brick_veneer_wood_frame": {
|
| 140 |
+
"name": "Brick veneer with wood frame",
|
| 141 |
+
"description": "Brick veneer with wood frame, insulation, and gypsum board",
|
| 142 |
+
"u_value": 0.35, # W/(m²·K)
|
| 143 |
+
"wall_group": "B", # ASHRAE wall group
|
| 144 |
+
"layers": [
|
| 145 |
+
{"material": "brick", "thickness": 0.1},
|
| 146 |
+
{"material": "air_gap", "thickness": 0.025},
|
| 147 |
+
{"material": "wood", "thickness": 0.038},
|
| 148 |
+
{"material": "mineral_wool", "thickness": 0.089},
|
| 149 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 150 |
+
]
|
| 151 |
+
},
|
| 152 |
+
"concrete_block_insulated": {
|
| 153 |
+
"name": "Concrete block with interior insulation",
|
| 154 |
+
"description": "Concrete block wall with interior insulation and gypsum board",
|
| 155 |
+
"u_value": 0.48, # W/(m²·K)
|
| 156 |
+
"wall_group": "C", # ASHRAE wall group
|
| 157 |
+
"layers": [
|
| 158 |
+
{"material": "concrete_block", "thickness": 0.2},
|
| 159 |
+
{"material": "eps_insulation", "thickness": 0.05},
|
| 160 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 161 |
+
]
|
| 162 |
+
},
|
| 163 |
+
"precast_concrete_panel": {
|
| 164 |
+
"name": "Precast concrete panel",
|
| 165 |
+
"description": "Precast concrete panel with insulation and gypsum board",
|
| 166 |
+
"u_value": 0.45, # W/(m²·K)
|
| 167 |
+
"wall_group": "D", # ASHRAE wall group
|
| 168 |
+
"layers": [
|
| 169 |
+
{"material": "concrete", "thickness": 0.1},
|
| 170 |
+
{"material": "eps_insulation", "thickness": 0.075},
|
| 171 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 172 |
+
]
|
| 173 |
+
},
|
| 174 |
+
"metal_panel_insulated": {
|
| 175 |
+
"name": "Metal panel with insulation",
|
| 176 |
+
"description": "Metal panel wall with insulation and interior finish",
|
| 177 |
+
"u_value": 0.4, # W/(m²·K)
|
| 178 |
+
"wall_group": "E", # ASHRAE wall group
|
| 179 |
+
"layers": [
|
| 180 |
+
{"material": "steel", "thickness": 0.001},
|
| 181 |
+
{"material": "mineral_wool", "thickness": 0.1},
|
| 182 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 183 |
+
]
|
| 184 |
+
},
|
| 185 |
+
"wood_frame_wall": {
|
| 186 |
+
"name": "Wood frame wall",
|
| 187 |
+
"description": "Wood frame wall with insulation and gypsum board",
|
| 188 |
+
"u_value": 0.3, # W/(m²·K)
|
| 189 |
+
"wall_group": "A", # ASHRAE wall group
|
| 190 |
+
"layers": [
|
| 191 |
+
{"material": "wood", "thickness": 0.019},
|
| 192 |
+
{"material": "air_gap", "thickness": 0.025},
|
| 193 |
+
{"material": "wood", "thickness": 0.038},
|
| 194 |
+
{"material": "mineral_wool", "thickness": 0.14},
|
| 195 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 196 |
+
]
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
def _load_roof_types(self) -> Dict[str, Dict[str, Any]]:
|
| 201 |
+
"""
|
| 202 |
+
Load predefined roof types from reference data.
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
Dictionary of roof types with properties
|
| 206 |
+
"""
|
| 207 |
+
return {
|
| 208 |
+
"flat_roof_concrete": {
|
| 209 |
+
"name": "Flat concrete roof with insulation",
|
| 210 |
+
"description": "Flat concrete roof with insulation and ceiling",
|
| 211 |
+
"u_value": 0.25, # W/(m²·K)
|
| 212 |
+
"roof_group": "B", # ASHRAE roof group
|
| 213 |
+
"layers": [
|
| 214 |
+
{"material": "concrete", "thickness": 0.15},
|
| 215 |
+
{"material": "eps_insulation", "thickness": 0.15},
|
| 216 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 217 |
+
]
|
| 218 |
+
},
|
| 219 |
+
"pitched_roof_wood": {
|
| 220 |
+
"name": "Pitched wood roof with insulation",
|
| 221 |
+
"description": "Pitched wood roof with insulation and ceiling",
|
| 222 |
+
"u_value": 0.2, # W/(m²·K)
|
| 223 |
+
"roof_group": "A", # ASHRAE roof group
|
| 224 |
+
"layers": [
|
| 225 |
+
{"material": "asphalt_shingle", "thickness": 0.006},
|
| 226 |
+
{"material": "wood", "thickness": 0.019},
|
| 227 |
+
{"material": "air_gap", "thickness": 0.025},
|
| 228 |
+
{"material": "mineral_wool", "thickness": 0.2},
|
| 229 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 230 |
+
]
|
| 231 |
+
},
|
| 232 |
+
"metal_deck_roof": {
|
| 233 |
+
"name": "Metal deck roof with insulation",
|
| 234 |
+
"description": "Metal deck roof with insulation and ceiling",
|
| 235 |
+
"u_value": 0.3, # W/(m²·K)
|
| 236 |
+
"roof_group": "C", # ASHRAE roof group
|
| 237 |
+
"layers": [
|
| 238 |
+
{"material": "steel", "thickness": 0.001},
|
| 239 |
+
{"material": "eps_insulation", "thickness": 0.1},
|
| 240 |
+
{"material": "air_gap", "thickness": 0.1},
|
| 241 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 242 |
+
]
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
def _load_floor_types(self) -> Dict[str, Dict[str, Any]]:
|
| 247 |
+
"""
|
| 248 |
+
Load predefined floor types from reference data.
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
Dictionary of floor types with properties
|
| 252 |
+
"""
|
| 253 |
+
return {
|
| 254 |
+
"concrete_slab_on_grade": {
|
| 255 |
+
"name": "Concrete slab on grade",
|
| 256 |
+
"description": "Concrete slab on grade with insulation",
|
| 257 |
+
"u_value": 0.3, # W/(m²·K)
|
| 258 |
+
"is_ground_contact": True,
|
| 259 |
+
"layers": [
|
| 260 |
+
{"material": "concrete", "thickness": 0.1},
|
| 261 |
+
{"material": "eps_insulation", "thickness": 0.05}
|
| 262 |
+
]
|
| 263 |
+
},
|
| 264 |
+
"suspended_concrete_floor": {
|
| 265 |
+
"name": "Suspended concrete floor",
|
| 266 |
+
"description": "Suspended concrete floor with insulation",
|
| 267 |
+
"u_value": 0.25, # W/(m²·K)
|
| 268 |
+
"is_ground_contact": False,
|
| 269 |
+
"layers": [
|
| 270 |
+
{"material": "concrete", "thickness": 0.15},
|
| 271 |
+
{"material": "eps_insulation", "thickness": 0.1},
|
| 272 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 273 |
+
]
|
| 274 |
+
},
|
| 275 |
+
"wood_joist_floor": {
|
| 276 |
+
"name": "Wood joist floor",
|
| 277 |
+
"description": "Wood joist floor with insulation",
|
| 278 |
+
"u_value": 0.22, # W/(m²·K)
|
| 279 |
+
"is_ground_contact": False,
|
| 280 |
+
"layers": [
|
| 281 |
+
{"material": "wood", "thickness": 0.025},
|
| 282 |
+
{"material": "air_gap", "thickness": 0.15},
|
| 283 |
+
{"material": "mineral_wool", "thickness": 0.15},
|
| 284 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 285 |
+
]
|
| 286 |
+
},
|
| 287 |
+
"carpet_on_concrete": {
|
| 288 |
+
"name": "Carpet on concrete",
|
| 289 |
+
"description": "Carpet on concrete slab with insulation",
|
| 290 |
+
"u_value": 0.28, # W/(m²·K)
|
| 291 |
+
"is_ground_contact": True,
|
| 292 |
+
"layers": [
|
| 293 |
+
{"material": "carpet", "thickness": 0.01},
|
| 294 |
+
{"material": "concrete", "thickness": 0.1},
|
| 295 |
+
{"material": "eps_insulation", "thickness": 0.05}
|
| 296 |
+
]
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
def _load_window_types(self) -> Dict[str, Dict[str, Any]]:
|
| 301 |
+
"""
|
| 302 |
+
Load predefined window types from reference data.
|
| 303 |
+
|
| 304 |
+
Returns:
|
| 305 |
+
Dictionary of window types with properties
|
| 306 |
+
"""
|
| 307 |
+
return {
|
| 308 |
+
"single_glazed": {
|
| 309 |
+
"name": "Single glazed window",
|
| 310 |
+
"description": "Single glazed window with aluminum frame",
|
| 311 |
+
"u_value": 5.8, # W/(m²·K)
|
| 312 |
+
"shgc": 0.86, # Solar Heat Gain Coefficient
|
| 313 |
+
"vt": 0.9, # Visible Transmittance
|
| 314 |
+
"glazing_layers": 1,
|
| 315 |
+
"gas_fill": "Air",
|
| 316 |
+
"frame_type": "Aluminum",
|
| 317 |
+
"low_e_coating": False
|
| 318 |
+
},
|
| 319 |
+
"double_glazed_air": {
|
| 320 |
+
"name": "Double glazed window with air",
|
| 321 |
+
"description": "Double glazed window with air gap and aluminum frame",
|
| 322 |
+
"u_value": 2.8, # W/(m²·K)
|
| 323 |
+
"shgc": 0.76, # Solar Heat Gain Coefficient
|
| 324 |
+
"vt": 0.81, # Visible Transmittance
|
| 325 |
+
"glazing_layers": 2,
|
| 326 |
+
"gas_fill": "Air",
|
| 327 |
+
"frame_type": "Aluminum",
|
| 328 |
+
"low_e_coating": False
|
| 329 |
+
},
|
| 330 |
+
"double_glazed_argon_low_e": {
|
| 331 |
+
"name": "Double glazed window with argon and low-e coating",
|
| 332 |
+
"description": "Double glazed window with argon fill, low-e coating, and vinyl frame",
|
| 333 |
+
"u_value": 1.4, # W/(m²·K)
|
| 334 |
+
"shgc": 0.4, # Solar Heat Gain Coefficient
|
| 335 |
+
"vt": 0.7, # Visible Transmittance
|
| 336 |
+
"glazing_layers": 2,
|
| 337 |
+
"gas_fill": "Argon",
|
| 338 |
+
"frame_type": "Vinyl",
|
| 339 |
+
"low_e_coating": True
|
| 340 |
+
},
|
| 341 |
+
"triple_glazed_argon_low_e": {
|
| 342 |
+
"name": "Triple glazed window with argon and low-e coating",
|
| 343 |
+
"description": "Triple glazed window with argon fill, low-e coating, and vinyl frame",
|
| 344 |
+
"u_value": 0.8, # W/(m²·K)
|
| 345 |
+
"shgc": 0.3, # Solar Heat Gain Coefficient
|
| 346 |
+
"vt": 0.6, # Visible Transmittance
|
| 347 |
+
"glazing_layers": 3,
|
| 348 |
+
"gas_fill": "Argon",
|
| 349 |
+
"frame_type": "Vinyl",
|
| 350 |
+
"low_e_coating": True
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
def _load_door_types(self) -> Dict[str, Dict[str, Any]]:
|
| 355 |
+
"""
|
| 356 |
+
Load predefined door types from reference data.
|
| 357 |
+
|
| 358 |
+
Returns:
|
| 359 |
+
Dictionary of door types with properties
|
| 360 |
+
"""
|
| 361 |
+
return {
|
| 362 |
+
"solid_wood_door": {
|
| 363 |
+
"name": "Solid wood door",
|
| 364 |
+
"description": "Solid wood door with no glazing",
|
| 365 |
+
"u_value": 2.2, # W/(m²·K)
|
| 366 |
+
"glazing_percentage": 0,
|
| 367 |
+
"door_type": "Solid"
|
| 368 |
+
},
|
| 369 |
+
"insulated_steel_door": {
|
| 370 |
+
"name": "Insulated steel door",
|
| 371 |
+
"description": "Insulated steel door with no glazing",
|
| 372 |
+
"u_value": 1.2, # W/(m²·K)
|
| 373 |
+
"glazing_percentage": 0,
|
| 374 |
+
"door_type": "Solid"
|
| 375 |
+
},
|
| 376 |
+
"partially_glazed_door": {
|
| 377 |
+
"name": "Partially glazed door",
|
| 378 |
+
"description": "Wood door with 25% glazing",
|
| 379 |
+
"u_value": 2.8, # W/(m²·K)
|
| 380 |
+
"glazing_percentage": 25,
|
| 381 |
+
"door_type": "Partially glazed",
|
| 382 |
+
"shgc": 0.76, # Solar Heat Gain Coefficient for the glazed portion
|
| 383 |
+
"vt": 0.81 # Visible Transmittance for the glazed portion
|
| 384 |
+
},
|
| 385 |
+
"glass_door": {
|
| 386 |
+
"name": "Glass door",
|
| 387 |
+
"description": "Full glass door with aluminum frame",
|
| 388 |
+
"u_value": 3.8, # W/(m²·K)
|
| 389 |
+
"glazing_percentage": 90,
|
| 390 |
+
"door_type": "Glass",
|
| 391 |
+
"shgc": 0.76, # Solar Heat Gain Coefficient
|
| 392 |
+
"vt": 0.81 # Visible Transmittance
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
def _load_internal_loads(self) -> Dict[str, Dict[str, Any]]:
|
| 397 |
+
"""
|
| 398 |
+
Load internal load data from reference data.
|
| 399 |
+
|
| 400 |
+
Returns:
|
| 401 |
+
Dictionary of internal load types with properties
|
| 402 |
+
"""
|
| 403 |
+
return {
|
| 404 |
+
"occupancy": {
|
| 405 |
+
"seated_very_light_work": {
|
| 406 |
+
"name": "Seated, very light work",
|
| 407 |
+
"sensible_heat": 70, # W per person
|
| 408 |
+
"latent_heat": 45 # W per person
|
| 409 |
+
},
|
| 410 |
+
"office_work_standing": {
|
| 411 |
+
"name": "Office work, standing",
|
| 412 |
+
"sensible_heat": 75, # W per person
|
| 413 |
+
"latent_heat": 55 # W per person
|
| 414 |
+
},
|
| 415 |
+
"light_physical_work": {
|
| 416 |
+
"name": "Light physical work",
|
| 417 |
+
"sensible_heat": 80, # W per person
|
| 418 |
+
"latent_heat": 140 # W per person
|
| 419 |
+
},
|
| 420 |
+
"medium_physical_work": {
|
| 421 |
+
"name": "Medium physical work",
|
| 422 |
+
"sensible_heat": 90, # W per person
|
| 423 |
+
"latent_heat": 185 # W per person
|
| 424 |
+
},
|
| 425 |
+
"heavy_physical_work": {
|
| 426 |
+
"name": "Heavy physical work",
|
| 427 |
+
"sensible_heat": 170, # W per person
|
| 428 |
+
"latent_heat": 255 # W per person
|
| 429 |
+
}
|
| 430 |
+
},
|
| 431 |
+
"lighting": {
|
| 432 |
+
"led": {
|
| 433 |
+
"name": "LED",
|
| 434 |
+
"power_density_range": [5, 10], # W/m²
|
| 435 |
+
"heat_to_space": 0.9 # Fraction of power that becomes heat
|
| 436 |
+
},
|
| 437 |
+
"fluorescent": {
|
| 438 |
+
"name": "Fluorescent",
|
| 439 |
+
"power_density_range": [10, 15], # W/m²
|
| 440 |
+
"heat_to_space": 0.95 # Fraction of power that becomes heat
|
| 441 |
+
},
|
| 442 |
+
"incandescent": {
|
| 443 |
+
"name": "Incandescent",
|
| 444 |
+
"power_density_range": [15, 25], # W/m²
|
| 445 |
+
"heat_to_space": 0.98 # Fraction of power that becomes heat
|
| 446 |
+
},
|
| 447 |
+
"halogen": {
|
| 448 |
+
"name": "Halogen",
|
| 449 |
+
"power_density_range": [12, 20], # W/m²
|
| 450 |
+
"heat_to_space": 0.97 # Fraction of power that becomes heat
|
| 451 |
+
}
|
| 452 |
+
},
|
| 453 |
+
"equipment": {
|
| 454 |
+
"office_equipment": {
|
| 455 |
+
"name": "Office Equipment",
|
| 456 |
+
"power_density_range": [10, 20], # W/m²
|
| 457 |
+
"sensible_fraction": 0.9,
|
| 458 |
+
"latent_fraction": 0.1
|
| 459 |
+
},
|
| 460 |
+
"kitchen_equipment": {
|
| 461 |
+
"name": "Kitchen Equipment",
|
| 462 |
+
"power_density_range": [30, 200], # W/m²
|
| 463 |
+
"sensible_fraction": 0.6,
|
| 464 |
+
"latent_fraction": 0.4
|
| 465 |
+
},
|
| 466 |
+
"manufacturing_equipment": {
|
| 467 |
+
"name": "Manufacturing Equipment",
|
| 468 |
+
"power_density_range": [20, 100], # W/m²
|
| 469 |
+
"sensible_fraction": 0.8,
|
| 470 |
+
"latent_fraction": 0.2
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
def get_material(self, material_id: str) -> Optional[Dict[str, Any]]:
|
| 476 |
+
"""
|
| 477 |
+
Get material properties by ID.
|
| 478 |
+
|
| 479 |
+
Args:
|
| 480 |
+
material_id: Material identifier
|
| 481 |
+
|
| 482 |
+
Returns:
|
| 483 |
+
Dictionary of material properties or None if not found
|
| 484 |
+
"""
|
| 485 |
+
return self.materials.get(material_id)
|
| 486 |
+
|
| 487 |
+
def get_wall_type(self, wall_type_id: str) -> Optional[Dict[str, Any]]:
|
| 488 |
+
"""
|
| 489 |
+
Get wall type properties by ID.
|
| 490 |
+
|
| 491 |
+
Args:
|
| 492 |
+
wall_type_id: Wall type identifier
|
| 493 |
+
|
| 494 |
+
Returns:
|
| 495 |
+
Dictionary of wall type properties or None if not found
|
| 496 |
+
"""
|
| 497 |
+
return self.wall_types.get(wall_type_id)
|
| 498 |
+
|
| 499 |
+
def get_roof_type(self, roof_type_id: str) -> Optional[Dict[str, Any]]:
|
| 500 |
+
"""
|
| 501 |
+
Get roof type properties by ID.
|
| 502 |
+
|
| 503 |
+
Args:
|
| 504 |
+
roof_type_id: Roof type identifier
|
| 505 |
+
|
| 506 |
+
Returns:
|
| 507 |
+
Dictionary of roof type properties or None if not found
|
| 508 |
+
"""
|
| 509 |
+
return self.roof_types.get(roof_type_id)
|
| 510 |
+
|
| 511 |
+
def get_floor_type(self, floor_type_id: str) -> Optional[Dict[str, Any]]:
|
| 512 |
+
"""
|
| 513 |
+
Get floor type properties by ID.
|
| 514 |
+
|
| 515 |
+
Args:
|
| 516 |
+
floor_type_id: Floor type identifier
|
| 517 |
+
|
| 518 |
+
Returns:
|
| 519 |
+
Dictionary of floor type properties or None if not found
|
| 520 |
+
"""
|
| 521 |
+
return self.floor_types.get(floor_type_id)
|
| 522 |
+
|
| 523 |
+
def get_window_type(self, window_type_id: str) -> Optional[Dict[str, Any]]:
|
| 524 |
+
"""
|
| 525 |
+
Get window type properties by ID.
|
| 526 |
+
|
| 527 |
+
Args:
|
| 528 |
+
window_type_id: Window type identifier
|
| 529 |
+
|
| 530 |
+
Returns:
|
| 531 |
+
Dictionary of window type properties or None if not found
|
| 532 |
+
"""
|
| 533 |
+
return self.window_types.get(window_type_id)
|
| 534 |
+
|
| 535 |
+
def get_door_type(self, door_type_id: str) -> Optional[Dict[str, Any]]:
|
| 536 |
+
"""
|
| 537 |
+
Get door type properties by ID.
|
| 538 |
+
|
| 539 |
+
Args:
|
| 540 |
+
door_type_id: Door type identifier
|
| 541 |
+
|
| 542 |
+
Returns:
|
| 543 |
+
Dictionary of door type properties or None if not found
|
| 544 |
+
"""
|
| 545 |
+
return self.door_types.get(door_type_id)
|
| 546 |
+
|
| 547 |
+
def get_internal_load(self, load_type: str, load_id: str) -> Optional[Dict[str, Any]]:
|
| 548 |
+
"""
|
| 549 |
+
Get internal load properties by type and ID.
|
| 550 |
+
|
| 551 |
+
Args:
|
| 552 |
+
load_type: Type of internal load (occupancy, lighting, equipment)
|
| 553 |
+
load_id: Internal load identifier
|
| 554 |
+
|
| 555 |
+
Returns:
|
| 556 |
+
Dictionary of internal load properties or None if not found
|
| 557 |
+
"""
|
| 558 |
+
if load_type in self.internal_loads:
|
| 559 |
+
return self.internal_loads[load_type].get(load_id)
|
| 560 |
+
return None
|
| 561 |
+
|
| 562 |
+
def export_to_json(self, file_path: str) -> None:
|
| 563 |
+
"""
|
| 564 |
+
Export all reference data to a JSON file.
|
| 565 |
+
|
| 566 |
+
Args:
|
| 567 |
+
file_path: Path to the output JSON file
|
| 568 |
+
"""
|
| 569 |
+
data = {
|
| 570 |
+
"materials": self.materials,
|
| 571 |
+
"wall_types": self.wall_types,
|
| 572 |
+
"roof_types": self.roof_types,
|
| 573 |
+
"floor_types": self.floor_types,
|
| 574 |
+
"window_types": self.window_types,
|
| 575 |
+
"door_types": self.door_types,
|
| 576 |
+
"internal_loads": self.internal_loads
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
with open(file_path, 'w') as f:
|
| 580 |
+
json.dump(data, f, indent=4)
|
| 581 |
+
|
| 582 |
+
@classmethod
|
| 583 |
+
def from_json(cls, file_path: str) -> 'ReferenceData':
|
| 584 |
+
"""
|
| 585 |
+
Create a ReferenceData instance from a JSON file.
|
| 586 |
+
|
| 587 |
+
Args:
|
| 588 |
+
file_path: Path to the input JSON file
|
| 589 |
+
|
| 590 |
+
Returns:
|
| 591 |
+
A new ReferenceData instance
|
| 592 |
+
"""
|
| 593 |
+
with open(file_path, 'r') as f:
|
| 594 |
+
data = json.load(f)
|
| 595 |
+
|
| 596 |
+
ref_data = cls()
|
| 597 |
+
ref_data.materials = data.get("materials", ref_data.materials)
|
| 598 |
+
ref_data.wall_types = data.get("wall_types", ref_data.wall_types)
|
| 599 |
+
ref_data.roof_types = data.get("roof_types", ref_data.roof_types)
|
| 600 |
+
ref_data.floor_types = data.get("floor_types", ref_data.floor_types)
|
| 601 |
+
ref_data.window_types = data.get("window_types", ref_data.window_types)
|
| 602 |
+
ref_data.door_types = data.get("door_types", ref_data.door_types)
|
| 603 |
+
ref_data.internal_loads = data.get("internal_loads", ref_data.internal_loads)
|
| 604 |
+
|
| 605 |
+
return ref_data
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
# Create a singleton instance
|
| 609 |
+
reference_data = ReferenceData()
|
| 610 |
+
|
| 611 |
+
# Export reference data to JSON if needed
|
| 612 |
+
if __name__ == "__main__":
|
| 613 |
+
reference_data.export_to_json(os.path.join(DATA_DIR, "reference_data.json"))
|
utils/area_calculation_system.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Area calculation system module for HVAC Load Calculator.
|
| 3 |
+
This module implements net wall area calculation and area validation functions.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
|
| 13 |
+
# Import data models
|
| 14 |
+
from data.building_components import Wall, Window, Door, Orientation, ComponentType
|
| 15 |
+
|
| 16 |
+
# Define paths
|
| 17 |
+
DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AreaCalculationSystem:
|
| 21 |
+
"""Class for managing area calculations and validations."""
|
| 22 |
+
|
| 23 |
+
def __init__(self):
|
| 24 |
+
"""Initialize area calculation system."""
|
| 25 |
+
self.walls = {}
|
| 26 |
+
self.windows = {}
|
| 27 |
+
self.doors = {}
|
| 28 |
+
|
| 29 |
+
def add_wall(self, wall: Wall) -> None:
|
| 30 |
+
"""
|
| 31 |
+
Add a wall to the area calculation system.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
wall: Wall object
|
| 35 |
+
"""
|
| 36 |
+
self.walls[wall.id] = wall
|
| 37 |
+
|
| 38 |
+
def add_window(self, window: Window) -> None:
|
| 39 |
+
"""
|
| 40 |
+
Add a window to the area calculation system.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
window: Window object
|
| 44 |
+
"""
|
| 45 |
+
self.windows[window.id] = window
|
| 46 |
+
|
| 47 |
+
def add_door(self, door: Door) -> None:
|
| 48 |
+
"""
|
| 49 |
+
Add a door to the area calculation system.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
door: Door object
|
| 53 |
+
"""
|
| 54 |
+
self.doors[door.id] = door
|
| 55 |
+
|
| 56 |
+
def remove_wall(self, wall_id: str) -> bool:
|
| 57 |
+
"""
|
| 58 |
+
Remove a wall from the area calculation system.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
wall_id: Wall identifier
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
True if the wall was removed, False otherwise
|
| 65 |
+
"""
|
| 66 |
+
if wall_id not in self.walls:
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
# Remove all windows and doors associated with this wall
|
| 70 |
+
for window_id, window in list(self.windows.items()):
|
| 71 |
+
if window.wall_id == wall_id:
|
| 72 |
+
del self.windows[window_id]
|
| 73 |
+
|
| 74 |
+
for door_id, door in list(self.doors.items()):
|
| 75 |
+
if door.wall_id == wall_id:
|
| 76 |
+
del self.doors[door_id]
|
| 77 |
+
|
| 78 |
+
del self.walls[wall_id]
|
| 79 |
+
return True
|
| 80 |
+
|
| 81 |
+
def remove_window(self, window_id: str) -> bool:
|
| 82 |
+
"""
|
| 83 |
+
Remove a window from the area calculation system.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
window_id: Window identifier
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
True if the window was removed, False otherwise
|
| 90 |
+
"""
|
| 91 |
+
if window_id not in self.windows:
|
| 92 |
+
return False
|
| 93 |
+
|
| 94 |
+
# Remove window from associated wall
|
| 95 |
+
window = self.windows[window_id]
|
| 96 |
+
if window.wall_id and window.wall_id in self.walls:
|
| 97 |
+
wall = self.walls[window.wall_id]
|
| 98 |
+
if window_id in wall.windows:
|
| 99 |
+
wall.windows.remove(window_id)
|
| 100 |
+
self._update_wall_net_area(wall.id)
|
| 101 |
+
|
| 102 |
+
del self.windows[window_id]
|
| 103 |
+
return True
|
| 104 |
+
|
| 105 |
+
def remove_door(self, door_id: str) -> bool:
|
| 106 |
+
"""
|
| 107 |
+
Remove a door from the area calculation system.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
door_id: Door identifier
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
True if the door was removed, False otherwise
|
| 114 |
+
"""
|
| 115 |
+
if door_id not in self.doors:
|
| 116 |
+
return False
|
| 117 |
+
|
| 118 |
+
# Remove door from associated wall
|
| 119 |
+
door = self.doors[door_id]
|
| 120 |
+
if door.wall_id and door.wall_id in self.walls:
|
| 121 |
+
wall = self.walls[door.wall_id]
|
| 122 |
+
if door_id in wall.doors:
|
| 123 |
+
wall.doors.remove(door_id)
|
| 124 |
+
self._update_wall_net_area(wall.id)
|
| 125 |
+
|
| 126 |
+
del self.doors[door_id]
|
| 127 |
+
return True
|
| 128 |
+
|
| 129 |
+
def assign_window_to_wall(self, window_id: str, wall_id: str) -> bool:
|
| 130 |
+
"""
|
| 131 |
+
Assign a window to a wall.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
window_id: Window identifier
|
| 135 |
+
wall_id: Wall identifier
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
True if the window was assigned, False otherwise
|
| 139 |
+
"""
|
| 140 |
+
if window_id not in self.windows or wall_id not in self.walls:
|
| 141 |
+
return False
|
| 142 |
+
|
| 143 |
+
window = self.windows[window_id]
|
| 144 |
+
wall = self.walls[wall_id]
|
| 145 |
+
|
| 146 |
+
# Remove window from previous wall if assigned
|
| 147 |
+
if window.wall_id and window.wall_id in self.walls and window.wall_id != wall_id:
|
| 148 |
+
prev_wall = self.walls[window.wall_id]
|
| 149 |
+
if window_id in prev_wall.windows:
|
| 150 |
+
prev_wall.windows.remove(window_id)
|
| 151 |
+
self._update_wall_net_area(prev_wall.id)
|
| 152 |
+
|
| 153 |
+
# Assign window to new wall
|
| 154 |
+
window.wall_id = wall_id
|
| 155 |
+
window.orientation = wall.orientation
|
| 156 |
+
|
| 157 |
+
# Add window to wall's window list if not already there
|
| 158 |
+
if window_id not in wall.windows:
|
| 159 |
+
wall.windows.append(window_id)
|
| 160 |
+
|
| 161 |
+
# Update wall net area
|
| 162 |
+
self._update_wall_net_area(wall_id)
|
| 163 |
+
|
| 164 |
+
return True
|
| 165 |
+
|
| 166 |
+
def assign_door_to_wall(self, door_id: str, wall_id: str) -> bool:
|
| 167 |
+
"""
|
| 168 |
+
Assign a door to a wall.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
door_id: Door identifier
|
| 172 |
+
wall_id: Wall identifier
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
True if the door was assigned, False otherwise
|
| 176 |
+
"""
|
| 177 |
+
if door_id not in self.doors or wall_id not in self.walls:
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
door = self.doors[door_id]
|
| 181 |
+
wall = self.walls[wall_id]
|
| 182 |
+
|
| 183 |
+
# Remove door from previous wall if assigned
|
| 184 |
+
if door.wall_id and door.wall_id in self.walls and door.wall_id != wall_id:
|
| 185 |
+
prev_wall = self.walls[door.wall_id]
|
| 186 |
+
if door_id in prev_wall.doors:
|
| 187 |
+
prev_wall.doors.remove(door_id)
|
| 188 |
+
self._update_wall_net_area(prev_wall.id)
|
| 189 |
+
|
| 190 |
+
# Assign door to new wall
|
| 191 |
+
door.wall_id = wall_id
|
| 192 |
+
door.orientation = wall.orientation
|
| 193 |
+
|
| 194 |
+
# Add door to wall's door list if not already there
|
| 195 |
+
if door_id not in wall.doors:
|
| 196 |
+
wall.doors.append(door_id)
|
| 197 |
+
|
| 198 |
+
# Update wall net area
|
| 199 |
+
self._update_wall_net_area(wall_id)
|
| 200 |
+
|
| 201 |
+
return True
|
| 202 |
+
|
| 203 |
+
def _update_wall_net_area(self, wall_id: str) -> None:
|
| 204 |
+
"""
|
| 205 |
+
Update the net area of a wall by subtracting windows and doors.
|
| 206 |
+
|
| 207 |
+
Args:
|
| 208 |
+
wall_id: Wall identifier
|
| 209 |
+
"""
|
| 210 |
+
if wall_id not in self.walls:
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
wall = self.walls[wall_id]
|
| 214 |
+
|
| 215 |
+
# Calculate total window area
|
| 216 |
+
total_window_area = sum(self.windows[window_id].area
|
| 217 |
+
for window_id in wall.windows
|
| 218 |
+
if window_id in self.windows)
|
| 219 |
+
|
| 220 |
+
# Calculate total door area
|
| 221 |
+
total_door_area = sum(self.doors[door_id].area
|
| 222 |
+
for door_id in wall.doors
|
| 223 |
+
if door_id in self.doors)
|
| 224 |
+
|
| 225 |
+
# Update wall net area
|
| 226 |
+
if wall.gross_area is None:
|
| 227 |
+
wall.gross_area = wall.area
|
| 228 |
+
|
| 229 |
+
wall.net_area = wall.gross_area - total_window_area - total_door_area
|
| 230 |
+
wall.area = wall.net_area # Update the main area property
|
| 231 |
+
|
| 232 |
+
def update_all_net_areas(self) -> None:
|
| 233 |
+
"""Update the net areas of all walls."""
|
| 234 |
+
for wall_id in self.walls:
|
| 235 |
+
self._update_wall_net_area(wall_id)
|
| 236 |
+
|
| 237 |
+
def validate_areas(self) -> List[Dict[str, Any]]:
|
| 238 |
+
"""
|
| 239 |
+
Validate all areas and return a list of validation issues.
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
List of validation issues
|
| 243 |
+
"""
|
| 244 |
+
issues = []
|
| 245 |
+
|
| 246 |
+
# Check for negative or zero net wall areas
|
| 247 |
+
for wall_id, wall in self.walls.items():
|
| 248 |
+
if wall.net_area <= 0:
|
| 249 |
+
issues.append({
|
| 250 |
+
"type": "error",
|
| 251 |
+
"component_id": wall_id,
|
| 252 |
+
"component_type": "Wall",
|
| 253 |
+
"message": f"Wall '{wall.name}' has a negative or zero net area. "
|
| 254 |
+
f"Gross area: {wall.gross_area} m², "
|
| 255 |
+
f"Window area: {sum(self.windows[window_id].area for window_id in wall.windows if window_id in self.windows)} m², "
|
| 256 |
+
f"Door area: {sum(self.doors[door_id].area for door_id in wall.doors if door_id in self.doors)} m², "
|
| 257 |
+
f"Net area: {wall.net_area} m²."
|
| 258 |
+
})
|
| 259 |
+
elif wall.net_area < 0.5:
|
| 260 |
+
issues.append({
|
| 261 |
+
"type": "warning",
|
| 262 |
+
"component_id": wall_id,
|
| 263 |
+
"component_type": "Wall",
|
| 264 |
+
"message": f"Wall '{wall.name}' has a very small net area ({wall.net_area} m²). "
|
| 265 |
+
f"Consider adjusting window and door sizes."
|
| 266 |
+
})
|
| 267 |
+
|
| 268 |
+
# Check for windows without walls
|
| 269 |
+
for window_id, window in self.windows.items():
|
| 270 |
+
if not window.wall_id:
|
| 271 |
+
issues.append({
|
| 272 |
+
"type": "warning",
|
| 273 |
+
"component_id": window_id,
|
| 274 |
+
"component_type": "Window",
|
| 275 |
+
"message": f"Window '{window.name}' is not assigned to any wall."
|
| 276 |
+
})
|
| 277 |
+
elif window.wall_id not in self.walls:
|
| 278 |
+
issues.append({
|
| 279 |
+
"type": "error",
|
| 280 |
+
"component_id": window_id,
|
| 281 |
+
"component_type": "Window",
|
| 282 |
+
"message": f"Window '{window.name}' is assigned to a non-existent wall (ID: {window.wall_id})."
|
| 283 |
+
})
|
| 284 |
+
|
| 285 |
+
# Check for doors without walls
|
| 286 |
+
for door_id, door in self.doors.items():
|
| 287 |
+
if not door.wall_id:
|
| 288 |
+
issues.append({
|
| 289 |
+
"type": "warning",
|
| 290 |
+
"component_id": door_id,
|
| 291 |
+
"component_type": "Door",
|
| 292 |
+
"message": f"Door '{door.name}' is not assigned to any wall."
|
| 293 |
+
})
|
| 294 |
+
elif door.wall_id not in self.walls:
|
| 295 |
+
issues.append({
|
| 296 |
+
"type": "error",
|
| 297 |
+
"component_id": door_id,
|
| 298 |
+
"component_type": "Door",
|
| 299 |
+
"message": f"Door '{door.name}' is assigned to a non-existent wall (ID: {door.wall_id})."
|
| 300 |
+
})
|
| 301 |
+
|
| 302 |
+
# Check for windows and doors with zero area
|
| 303 |
+
for window_id, window in self.windows.items():
|
| 304 |
+
if window.area <= 0:
|
| 305 |
+
issues.append({
|
| 306 |
+
"type": "error",
|
| 307 |
+
"component_id": window_id,
|
| 308 |
+
"component_type": "Window",
|
| 309 |
+
"message": f"Window '{window.name}' has a zero or negative area ({window.area} m²)."
|
| 310 |
+
})
|
| 311 |
+
|
| 312 |
+
for door_id, door in self.doors.items():
|
| 313 |
+
if door.area <= 0:
|
| 314 |
+
issues.append({
|
| 315 |
+
"type": "error",
|
| 316 |
+
"component_id": door_id,
|
| 317 |
+
"component_type": "Door",
|
| 318 |
+
"message": f"Door '{door.name}' has a zero or negative area ({door.area} m²)."
|
| 319 |
+
})
|
| 320 |
+
|
| 321 |
+
return issues
|
| 322 |
+
|
| 323 |
+
def get_wall_components(self, wall_id: str) -> Dict[str, List[str]]:
|
| 324 |
+
"""
|
| 325 |
+
Get all components (windows and doors) associated with a wall.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
wall_id: Wall identifier
|
| 329 |
+
|
| 330 |
+
Returns:
|
| 331 |
+
Dictionary with lists of window and door IDs
|
| 332 |
+
"""
|
| 333 |
+
if wall_id not in self.walls:
|
| 334 |
+
return {"windows": [], "doors": []}
|
| 335 |
+
|
| 336 |
+
wall = self.walls[wall_id]
|
| 337 |
+
return {
|
| 338 |
+
"windows": [window_id for window_id in wall.windows if window_id in self.windows],
|
| 339 |
+
"doors": [door_id for door_id in wall.doors if door_id in self.doors]
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
def get_wall_area_breakdown(self, wall_id: str) -> Dict[str, float]:
|
| 343 |
+
"""
|
| 344 |
+
Get a breakdown of wall areas (gross, net, windows, doors).
|
| 345 |
+
|
| 346 |
+
Args:
|
| 347 |
+
wall_id: Wall identifier
|
| 348 |
+
|
| 349 |
+
Returns:
|
| 350 |
+
Dictionary with area breakdown
|
| 351 |
+
"""
|
| 352 |
+
if wall_id not in self.walls:
|
| 353 |
+
return {}
|
| 354 |
+
|
| 355 |
+
wall = self.walls[wall_id]
|
| 356 |
+
|
| 357 |
+
# Calculate total window area
|
| 358 |
+
window_area = sum(self.windows[window_id].area
|
| 359 |
+
for window_id in wall.windows
|
| 360 |
+
if window_id in self.windows)
|
| 361 |
+
|
| 362 |
+
# Calculate total door area
|
| 363 |
+
door_area = sum(self.doors[door_id].area
|
| 364 |
+
for door_id in wall.doors
|
| 365 |
+
if door_id in self.doors)
|
| 366 |
+
|
| 367 |
+
return {
|
| 368 |
+
"gross_area": wall.gross_area,
|
| 369 |
+
"net_area": wall.net_area,
|
| 370 |
+
"window_area": window_area,
|
| 371 |
+
"door_area": door_area
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
def get_total_areas(self) -> Dict[str, float]:
|
| 375 |
+
"""
|
| 376 |
+
Get total areas for all component types.
|
| 377 |
+
|
| 378 |
+
Returns:
|
| 379 |
+
Dictionary with total areas
|
| 380 |
+
"""
|
| 381 |
+
# Calculate total wall areas
|
| 382 |
+
total_wall_gross_area = sum(wall.gross_area for wall in self.walls.values() if wall.gross_area is not None)
|
| 383 |
+
total_wall_net_area = sum(wall.net_area for wall in self.walls.values() if wall.net_area is not None)
|
| 384 |
+
|
| 385 |
+
# Calculate total window area
|
| 386 |
+
total_window_area = sum(window.area for window in self.windows.values())
|
| 387 |
+
|
| 388 |
+
# Calculate total door area
|
| 389 |
+
total_door_area = sum(door.area for door in self.doors.values())
|
| 390 |
+
|
| 391 |
+
return {
|
| 392 |
+
"total_wall_gross_area": total_wall_gross_area,
|
| 393 |
+
"total_wall_net_area": total_wall_net_area,
|
| 394 |
+
"total_window_area": total_window_area,
|
| 395 |
+
"total_door_area": total_door_area
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
def get_areas_by_orientation(self) -> Dict[str, Dict[str, float]]:
|
| 399 |
+
"""
|
| 400 |
+
Get areas for all component types grouped by orientation.
|
| 401 |
+
|
| 402 |
+
Returns:
|
| 403 |
+
Dictionary with areas by orientation
|
| 404 |
+
"""
|
| 405 |
+
# Initialize result dictionary
|
| 406 |
+
result = {}
|
| 407 |
+
|
| 408 |
+
# Process walls
|
| 409 |
+
for wall in self.walls.values():
|
| 410 |
+
orientation = wall.orientation.value
|
| 411 |
+
if orientation not in result:
|
| 412 |
+
result[orientation] = {
|
| 413 |
+
"wall_gross_area": 0,
|
| 414 |
+
"wall_net_area": 0,
|
| 415 |
+
"window_area": 0,
|
| 416 |
+
"door_area": 0
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
result[orientation]["wall_gross_area"] += wall.gross_area if wall.gross_area is not None else 0
|
| 420 |
+
result[orientation]["wall_net_area"] += wall.net_area if wall.net_area is not None else 0
|
| 421 |
+
|
| 422 |
+
# Process windows
|
| 423 |
+
for window in self.windows.values():
|
| 424 |
+
orientation = window.orientation.value
|
| 425 |
+
if orientation not in result:
|
| 426 |
+
result[orientation] = {
|
| 427 |
+
"wall_gross_area": 0,
|
| 428 |
+
"wall_net_area": 0,
|
| 429 |
+
"window_area": 0,
|
| 430 |
+
"door_area": 0
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
result[orientation]["window_area"] += window.area
|
| 434 |
+
|
| 435 |
+
# Process doors
|
| 436 |
+
for door in self.doors.values():
|
| 437 |
+
orientation = door.orientation.value
|
| 438 |
+
if orientation not in result:
|
| 439 |
+
result[orientation] = {
|
| 440 |
+
"wall_gross_area": 0,
|
| 441 |
+
"wall_net_area": 0,
|
| 442 |
+
"window_area": 0,
|
| 443 |
+
"door_area": 0
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
result[orientation]["door_area"] += door.area
|
| 447 |
+
|
| 448 |
+
return result
|
| 449 |
+
|
| 450 |
+
def export_to_json(self, file_path: str) -> None:
|
| 451 |
+
"""
|
| 452 |
+
Export all components to a JSON file.
|
| 453 |
+
|
| 454 |
+
Args:
|
| 455 |
+
file_path: Path to the output JSON file
|
| 456 |
+
"""
|
| 457 |
+
data = {
|
| 458 |
+
"walls": {wall_id: wall.to_dict() for wall_id, wall in self.walls.items()},
|
| 459 |
+
"windows": {window_id: window.to_dict() for window_id, window in self.windows.items()},
|
| 460 |
+
"doors": {door_id: door.to_dict() for door_id, door in self.doors.items()}
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
with open(file_path, 'w') as f:
|
| 464 |
+
json.dump(data, f, indent=4)
|
| 465 |
+
|
| 466 |
+
def import_from_json(self, file_path: str) -> Tuple[int, int, int]:
|
| 467 |
+
"""
|
| 468 |
+
Import components from a JSON file.
|
| 469 |
+
|
| 470 |
+
Args:
|
| 471 |
+
file_path: Path to the input JSON file
|
| 472 |
+
|
| 473 |
+
Returns:
|
| 474 |
+
Tuple with counts of walls, windows, and doors imported
|
| 475 |
+
"""
|
| 476 |
+
from data.building_components import BuildingComponentFactory
|
| 477 |
+
|
| 478 |
+
with open(file_path, 'r') as f:
|
| 479 |
+
data = json.load(f)
|
| 480 |
+
|
| 481 |
+
wall_count = 0
|
| 482 |
+
window_count = 0
|
| 483 |
+
door_count = 0
|
| 484 |
+
|
| 485 |
+
# Import walls
|
| 486 |
+
for wall_id, wall_data in data.get("walls", {}).items():
|
| 487 |
+
try:
|
| 488 |
+
wall = BuildingComponentFactory.create_component(wall_data)
|
| 489 |
+
self.walls[wall_id] = wall
|
| 490 |
+
wall_count += 1
|
| 491 |
+
except Exception as e:
|
| 492 |
+
print(f"Error importing wall {wall_id}: {e}")
|
| 493 |
+
|
| 494 |
+
# Import windows
|
| 495 |
+
for window_id, window_data in data.get("windows", {}).items():
|
| 496 |
+
try:
|
| 497 |
+
window = BuildingComponentFactory.create_component(window_data)
|
| 498 |
+
self.windows[window_id] = window
|
| 499 |
+
window_count += 1
|
| 500 |
+
except Exception as e:
|
| 501 |
+
print(f"Error importing window {window_id}: {e}")
|
| 502 |
+
|
| 503 |
+
# Import doors
|
| 504 |
+
for door_id, door_data in data.get("doors", {}).items():
|
| 505 |
+
try:
|
| 506 |
+
door = BuildingComponentFactory.create_component(door_data)
|
| 507 |
+
self.doors[door_id] = door
|
| 508 |
+
door_count += 1
|
| 509 |
+
except Exception as e:
|
| 510 |
+
print(f"Error importing door {door_id}: {e}")
|
| 511 |
+
|
| 512 |
+
# Update all net areas
|
| 513 |
+
self.update_all_net_areas()
|
| 514 |
+
|
| 515 |
+
return (wall_count, window_count, door_count)
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
# Create a singleton instance
|
| 519 |
+
area_calculation_system = AreaCalculationSystem()
|
| 520 |
+
|
| 521 |
+
# Export area calculation system to JSON if needed
|
| 522 |
+
if __name__ == "__main__":
|
| 523 |
+
area_calculation_system.export_to_json(os.path.join(DATA_DIR, "data", "area_calculation_system.json"))
|
utils/component_library.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Component library module for HVAC Load Calculator.
|
| 3 |
+
This module implements the preset component database and component selection interface.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
import uuid
|
| 12 |
+
from dataclasses import asdict
|
| 13 |
+
|
| 14 |
+
# Import data models
|
| 15 |
+
from data.building_components import (
|
| 16 |
+
BuildingComponent, Wall, Roof, Floor, Window, Door, Skylight,
|
| 17 |
+
MaterialLayer, Orientation, ComponentType, BuildingComponentFactory
|
| 18 |
+
)
|
| 19 |
+
from data.reference_data import reference_data
|
| 20 |
+
|
| 21 |
+
# Define paths
|
| 22 |
+
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ComponentLibrary:
|
| 26 |
+
"""Class for managing building component library."""
|
| 27 |
+
|
| 28 |
+
def __init__(self):
|
| 29 |
+
"""Initialize component library."""
|
| 30 |
+
self.components = {}
|
| 31 |
+
self.load_preset_components()
|
| 32 |
+
|
| 33 |
+
def load_preset_components(self):
|
| 34 |
+
"""Load preset components from reference data."""
|
| 35 |
+
# Load preset walls
|
| 36 |
+
for wall_id, wall_data in reference_data.wall_types.items():
|
| 37 |
+
# Create material layers
|
| 38 |
+
material_layers = []
|
| 39 |
+
for layer_data in wall_data.get("layers", []):
|
| 40 |
+
material_id = layer_data.get("material")
|
| 41 |
+
thickness = layer_data.get("thickness")
|
| 42 |
+
|
| 43 |
+
material = reference_data.get_material(material_id)
|
| 44 |
+
if material:
|
| 45 |
+
layer = MaterialLayer(
|
| 46 |
+
name=material["name"],
|
| 47 |
+
thickness=thickness,
|
| 48 |
+
conductivity=material["conductivity"],
|
| 49 |
+
density=material.get("density"),
|
| 50 |
+
specific_heat=material.get("specific_heat")
|
| 51 |
+
)
|
| 52 |
+
material_layers.append(layer)
|
| 53 |
+
|
| 54 |
+
# Create wall component
|
| 55 |
+
component_id = f"preset_wall_{wall_id}"
|
| 56 |
+
wall = Wall(
|
| 57 |
+
id=component_id,
|
| 58 |
+
name=wall_data["name"],
|
| 59 |
+
component_type=ComponentType.WALL,
|
| 60 |
+
u_value=wall_data["u_value"],
|
| 61 |
+
area=0.0, # Area will be set when component is used
|
| 62 |
+
orientation=Orientation.NOT_APPLICABLE, # Orientation will be set when component is used
|
| 63 |
+
wall_type=wall_data["name"],
|
| 64 |
+
wall_group=wall_data["wall_group"],
|
| 65 |
+
material_layers=material_layers
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
self.components[component_id] = wall
|
| 69 |
+
|
| 70 |
+
# Load preset roofs
|
| 71 |
+
for roof_id, roof_data in reference_data.roof_types.items():
|
| 72 |
+
# Create material layers
|
| 73 |
+
material_layers = []
|
| 74 |
+
for layer_data in roof_data.get("layers", []):
|
| 75 |
+
material_id = layer_data.get("material")
|
| 76 |
+
thickness = layer_data.get("thickness")
|
| 77 |
+
|
| 78 |
+
material = reference_data.get_material(material_id)
|
| 79 |
+
if material:
|
| 80 |
+
layer = MaterialLayer(
|
| 81 |
+
name=material["name"],
|
| 82 |
+
thickness=thickness,
|
| 83 |
+
conductivity=material["conductivity"],
|
| 84 |
+
density=material.get("density"),
|
| 85 |
+
specific_heat=material.get("specific_heat")
|
| 86 |
+
)
|
| 87 |
+
material_layers.append(layer)
|
| 88 |
+
|
| 89 |
+
# Create roof component
|
| 90 |
+
component_id = f"preset_roof_{roof_id}"
|
| 91 |
+
roof = Roof(
|
| 92 |
+
id=component_id,
|
| 93 |
+
name=roof_data["name"],
|
| 94 |
+
component_type=ComponentType.ROOF,
|
| 95 |
+
u_value=roof_data["u_value"],
|
| 96 |
+
area=0.0, # Area will be set when component is used
|
| 97 |
+
orientation=Orientation.HORIZONTAL,
|
| 98 |
+
roof_type=roof_data["name"],
|
| 99 |
+
roof_group=roof_data["roof_group"],
|
| 100 |
+
material_layers=material_layers
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
self.components[component_id] = roof
|
| 104 |
+
|
| 105 |
+
# Load preset floors
|
| 106 |
+
for floor_id, floor_data in reference_data.floor_types.items():
|
| 107 |
+
# Create material layers
|
| 108 |
+
material_layers = []
|
| 109 |
+
for layer_data in floor_data.get("layers", []):
|
| 110 |
+
material_id = layer_data.get("material")
|
| 111 |
+
thickness = layer_data.get("thickness")
|
| 112 |
+
|
| 113 |
+
material = reference_data.get_material(material_id)
|
| 114 |
+
if material:
|
| 115 |
+
layer = MaterialLayer(
|
| 116 |
+
name=material["name"],
|
| 117 |
+
thickness=thickness,
|
| 118 |
+
conductivity=material["conductivity"],
|
| 119 |
+
density=material.get("density"),
|
| 120 |
+
specific_heat=material.get("specific_heat")
|
| 121 |
+
)
|
| 122 |
+
material_layers.append(layer)
|
| 123 |
+
|
| 124 |
+
# Create floor component
|
| 125 |
+
component_id = f"preset_floor_{floor_id}"
|
| 126 |
+
floor = Floor(
|
| 127 |
+
id=component_id,
|
| 128 |
+
name=floor_data["name"],
|
| 129 |
+
component_type=ComponentType.FLOOR,
|
| 130 |
+
u_value=floor_data["u_value"],
|
| 131 |
+
area=0.0, # Area will be set when component is used
|
| 132 |
+
orientation=Orientation.HORIZONTAL,
|
| 133 |
+
floor_type=floor_data["name"],
|
| 134 |
+
is_ground_contact=floor_data["is_ground_contact"],
|
| 135 |
+
material_layers=material_layers
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
self.components[component_id] = floor
|
| 139 |
+
|
| 140 |
+
# Load preset windows
|
| 141 |
+
for window_id, window_data in reference_data.window_types.items():
|
| 142 |
+
# Create window component
|
| 143 |
+
component_id = f"preset_window_{window_id}"
|
| 144 |
+
window = Window(
|
| 145 |
+
id=component_id,
|
| 146 |
+
name=window_data["name"],
|
| 147 |
+
component_type=ComponentType.WINDOW,
|
| 148 |
+
u_value=window_data["u_value"],
|
| 149 |
+
area=0.0, # Area will be set when component is used
|
| 150 |
+
orientation=Orientation.NOT_APPLICABLE, # Orientation will be set when component is used
|
| 151 |
+
shgc=window_data["shgc"],
|
| 152 |
+
vt=window_data["vt"],
|
| 153 |
+
window_type=window_data["name"],
|
| 154 |
+
glazing_layers=window_data["glazing_layers"],
|
| 155 |
+
gas_fill=window_data["gas_fill"],
|
| 156 |
+
low_e_coating=window_data["low_e_coating"]
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
self.components[component_id] = window
|
| 160 |
+
|
| 161 |
+
# Load preset doors
|
| 162 |
+
for door_id, door_data in reference_data.door_types.items():
|
| 163 |
+
# Create door component
|
| 164 |
+
component_id = f"preset_door_{door_id}"
|
| 165 |
+
door = Door(
|
| 166 |
+
id=component_id,
|
| 167 |
+
name=door_data["name"],
|
| 168 |
+
component_type=ComponentType.DOOR,
|
| 169 |
+
u_value=door_data["u_value"],
|
| 170 |
+
area=0.0, # Area will be set when component is used
|
| 171 |
+
orientation=Orientation.NOT_APPLICABLE, # Orientation will be set when component is used
|
| 172 |
+
door_type=door_data["door_type"],
|
| 173 |
+
glazing_percentage=door_data["glazing_percentage"],
|
| 174 |
+
shgc=door_data.get("shgc", 0.0),
|
| 175 |
+
vt=door_data.get("vt", 0.0)
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
self.components[component_id] = door
|
| 179 |
+
|
| 180 |
+
def get_component(self, component_id: str) -> Optional[BuildingComponent]:
|
| 181 |
+
"""
|
| 182 |
+
Get a component by ID.
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
component_id: Component identifier
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
BuildingComponent object or None if not found
|
| 189 |
+
"""
|
| 190 |
+
return self.components.get(component_id)
|
| 191 |
+
|
| 192 |
+
def get_components_by_type(self, component_type: ComponentType) -> List[BuildingComponent]:
|
| 193 |
+
"""
|
| 194 |
+
Get all components of a specific type.
|
| 195 |
+
|
| 196 |
+
Args:
|
| 197 |
+
component_type: Component type
|
| 198 |
+
|
| 199 |
+
Returns:
|
| 200 |
+
List of BuildingComponent objects
|
| 201 |
+
"""
|
| 202 |
+
return [comp for comp in self.components.values() if comp.component_type == component_type]
|
| 203 |
+
|
| 204 |
+
def get_preset_components_by_type(self, component_type: ComponentType) -> List[BuildingComponent]:
|
| 205 |
+
"""
|
| 206 |
+
Get all preset components of a specific type.
|
| 207 |
+
|
| 208 |
+
Args:
|
| 209 |
+
component_type: Component type
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
List of BuildingComponent objects
|
| 213 |
+
"""
|
| 214 |
+
return [comp for comp in self.components.values()
|
| 215 |
+
if comp.component_type == component_type and comp.id.startswith("preset_")]
|
| 216 |
+
|
| 217 |
+
def get_custom_components_by_type(self, component_type: ComponentType) -> List[BuildingComponent]:
|
| 218 |
+
"""
|
| 219 |
+
Get all custom components of a specific type.
|
| 220 |
+
|
| 221 |
+
Args:
|
| 222 |
+
component_type: Component type
|
| 223 |
+
|
| 224 |
+
Returns:
|
| 225 |
+
List of BuildingComponent objects
|
| 226 |
+
"""
|
| 227 |
+
return [comp for comp in self.components.values()
|
| 228 |
+
if comp.component_type == component_type and comp.id.startswith("custom_")]
|
| 229 |
+
|
| 230 |
+
def add_component(self, component: BuildingComponent) -> str:
|
| 231 |
+
"""
|
| 232 |
+
Add a component to the library.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
component: BuildingComponent object
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
Component ID
|
| 239 |
+
"""
|
| 240 |
+
if component.id in self.components:
|
| 241 |
+
# Generate a new ID if the component ID already exists
|
| 242 |
+
component.id = f"custom_{component.component_type.value.lower()}_{str(uuid.uuid4())[:8]}"
|
| 243 |
+
|
| 244 |
+
self.components[component.id] = component
|
| 245 |
+
return component.id
|
| 246 |
+
|
| 247 |
+
def update_component(self, component_id: str, component: BuildingComponent) -> bool:
|
| 248 |
+
"""
|
| 249 |
+
Update a component in the library.
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
component_id: ID of the component to update
|
| 253 |
+
component: Updated BuildingComponent object
|
| 254 |
+
|
| 255 |
+
Returns:
|
| 256 |
+
True if the component was updated, False otherwise
|
| 257 |
+
"""
|
| 258 |
+
if component_id not in self.components:
|
| 259 |
+
return False
|
| 260 |
+
|
| 261 |
+
# Preserve the original ID
|
| 262 |
+
component.id = component_id
|
| 263 |
+
self.components[component_id] = component
|
| 264 |
+
return True
|
| 265 |
+
|
| 266 |
+
def remove_component(self, component_id: str) -> bool:
|
| 267 |
+
"""
|
| 268 |
+
Remove a component from the library.
|
| 269 |
+
|
| 270 |
+
Args:
|
| 271 |
+
component_id: ID of the component to remove
|
| 272 |
+
|
| 273 |
+
Returns:
|
| 274 |
+
True if the component was removed, False otherwise
|
| 275 |
+
"""
|
| 276 |
+
if component_id not in self.components:
|
| 277 |
+
return False
|
| 278 |
+
|
| 279 |
+
# Don't allow removing preset components
|
| 280 |
+
if component_id.startswith("preset_"):
|
| 281 |
+
return False
|
| 282 |
+
|
| 283 |
+
del self.components[component_id]
|
| 284 |
+
return True
|
| 285 |
+
|
| 286 |
+
def clone_component(self, component_id: str, new_name: str = None) -> Optional[str]:
|
| 287 |
+
"""
|
| 288 |
+
Clone a component in the library.
|
| 289 |
+
|
| 290 |
+
Args:
|
| 291 |
+
component_id: ID of the component to clone
|
| 292 |
+
new_name: Name for the cloned component (optional)
|
| 293 |
+
|
| 294 |
+
Returns:
|
| 295 |
+
ID of the cloned component or None if the original component was not found
|
| 296 |
+
"""
|
| 297 |
+
if component_id not in self.components:
|
| 298 |
+
return None
|
| 299 |
+
|
| 300 |
+
# Get the original component
|
| 301 |
+
original = self.components[component_id]
|
| 302 |
+
|
| 303 |
+
# Create a copy of the component
|
| 304 |
+
component_dict = asdict(original)
|
| 305 |
+
|
| 306 |
+
# Generate a new ID
|
| 307 |
+
component_dict["id"] = f"custom_{original.component_type.value.lower()}_{str(uuid.uuid4())[:8]}"
|
| 308 |
+
|
| 309 |
+
# Set new name if provided
|
| 310 |
+
if new_name:
|
| 311 |
+
component_dict["name"] = new_name
|
| 312 |
+
else:
|
| 313 |
+
component_dict["name"] = f"Copy of {original.name}"
|
| 314 |
+
|
| 315 |
+
# Create a new component
|
| 316 |
+
new_component = BuildingComponentFactory.create_component(component_dict)
|
| 317 |
+
|
| 318 |
+
# Add the new component to the library
|
| 319 |
+
self.components[new_component.id] = new_component
|
| 320 |
+
|
| 321 |
+
return new_component.id
|
| 322 |
+
|
| 323 |
+
def export_to_json(self, file_path: str) -> None:
|
| 324 |
+
"""
|
| 325 |
+
Export all components to a JSON file.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
file_path: Path to the output JSON file
|
| 329 |
+
"""
|
| 330 |
+
data = {comp_id: comp.to_dict() for comp_id, comp in self.components.items()}
|
| 331 |
+
|
| 332 |
+
with open(file_path, 'w') as f:
|
| 333 |
+
json.dump(data, f, indent=4)
|
| 334 |
+
|
| 335 |
+
def import_from_json(self, file_path: str) -> int:
|
| 336 |
+
"""
|
| 337 |
+
Import components from a JSON file.
|
| 338 |
+
|
| 339 |
+
Args:
|
| 340 |
+
file_path: Path to the input JSON file
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
Number of components imported
|
| 344 |
+
"""
|
| 345 |
+
with open(file_path, 'r') as f:
|
| 346 |
+
data = json.load(f)
|
| 347 |
+
|
| 348 |
+
count = 0
|
| 349 |
+
for comp_id, comp_data in data.items():
|
| 350 |
+
try:
|
| 351 |
+
component = BuildingComponentFactory.create_component(comp_data)
|
| 352 |
+
self.components[comp_id] = component
|
| 353 |
+
count += 1
|
| 354 |
+
except Exception as e:
|
| 355 |
+
print(f"Error importing component {comp_id}: {e}")
|
| 356 |
+
|
| 357 |
+
return count
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
# Create a singleton instance
|
| 361 |
+
component_library = ComponentLibrary()
|
| 362 |
+
|
| 363 |
+
# Export component library to JSON if needed
|
| 364 |
+
if __name__ == "__main__":
|
| 365 |
+
component_library.export_to_json(os.path.join(DATA_DIR, "component_library.json"))
|
utils/component_visualization.py
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hierarchical component visualization module for HVAC Load Calculator.
|
| 3 |
+
This module provides visualization tools for building components.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
import plotly.express as px
|
| 11 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 12 |
+
import math
|
| 13 |
+
|
| 14 |
+
# Import data models
|
| 15 |
+
from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ComponentVisualization:
|
| 19 |
+
"""Class for hierarchical component visualization."""
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def create_component_summary_table(components: Dict[str, List[Any]]) -> pd.DataFrame:
|
| 23 |
+
"""
|
| 24 |
+
Create a summary table of building components.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
components: Dictionary with lists of building components
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
DataFrame with component summary
|
| 31 |
+
"""
|
| 32 |
+
# Initialize data
|
| 33 |
+
data = []
|
| 34 |
+
|
| 35 |
+
# Process walls
|
| 36 |
+
for wall in components.get("walls", []):
|
| 37 |
+
data.append({
|
| 38 |
+
"Component Type": "Wall",
|
| 39 |
+
"Name": wall.name,
|
| 40 |
+
"Orientation": wall.orientation.name,
|
| 41 |
+
"Area (m²)": wall.area,
|
| 42 |
+
"U-Value (W/m²·K)": wall.u_value,
|
| 43 |
+
"Heat Transfer (W/K)": wall.area * wall.u_value
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
# Process roofs
|
| 47 |
+
for roof in components.get("roofs", []):
|
| 48 |
+
data.append({
|
| 49 |
+
"Component Type": "Roof",
|
| 50 |
+
"Name": roof.name,
|
| 51 |
+
"Orientation": roof.orientation.name,
|
| 52 |
+
"Area (m²)": roof.area,
|
| 53 |
+
"U-Value (W/m²·K)": roof.u_value,
|
| 54 |
+
"Heat Transfer (W/K)": roof.area * roof.u_value
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
# Process floors
|
| 58 |
+
for floor in components.get("floors", []):
|
| 59 |
+
data.append({
|
| 60 |
+
"Component Type": "Floor",
|
| 61 |
+
"Name": floor.name,
|
| 62 |
+
"Orientation": "Horizontal",
|
| 63 |
+
"Area (m²)": floor.area,
|
| 64 |
+
"U-Value (W/m²·K)": floor.u_value,
|
| 65 |
+
"Heat Transfer (W/K)": floor.area * floor.u_value
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
# Process windows
|
| 69 |
+
for window in components.get("windows", []):
|
| 70 |
+
data.append({
|
| 71 |
+
"Component Type": "Window",
|
| 72 |
+
"Name": window.name,
|
| 73 |
+
"Orientation": window.orientation.name,
|
| 74 |
+
"Area (m²)": window.area,
|
| 75 |
+
"U-Value (W/m²·K)": window.u_value,
|
| 76 |
+
"Heat Transfer (W/K)": window.area * window.u_value,
|
| 77 |
+
"SHGC": window.shgc if hasattr(window, "shgc") else None
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
# Process doors
|
| 81 |
+
for door in components.get("doors", []):
|
| 82 |
+
data.append({
|
| 83 |
+
"Component Type": "Door",
|
| 84 |
+
"Name": door.name,
|
| 85 |
+
"Orientation": door.orientation.name,
|
| 86 |
+
"Area (m²)": door.area,
|
| 87 |
+
"U-Value (W/m²·K)": door.u_value,
|
| 88 |
+
"Heat Transfer (W/K)": door.area * door.u_value
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
# Create DataFrame
|
| 92 |
+
df = pd.DataFrame(data)
|
| 93 |
+
|
| 94 |
+
return df
|
| 95 |
+
|
| 96 |
+
@staticmethod
|
| 97 |
+
def create_component_area_chart(components: Dict[str, List[Any]]) -> go.Figure:
|
| 98 |
+
"""
|
| 99 |
+
Create a pie chart of component areas.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
components: Dictionary with lists of building components
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Plotly figure with component area breakdown
|
| 106 |
+
"""
|
| 107 |
+
# Calculate total areas by component type
|
| 108 |
+
areas = {
|
| 109 |
+
"Walls": sum(wall.area for wall in components.get("walls", [])),
|
| 110 |
+
"Roofs": sum(roof.area for roof in components.get("roofs", [])),
|
| 111 |
+
"Floors": sum(floor.area for floor in components.get("floors", [])),
|
| 112 |
+
"Windows": sum(window.area for window in components.get("windows", [])),
|
| 113 |
+
"Doors": sum(door.area for door in components.get("doors", []))
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
# Create labels and values
|
| 117 |
+
labels = list(areas.keys())
|
| 118 |
+
values = list(areas.values())
|
| 119 |
+
|
| 120 |
+
# Create pie chart
|
| 121 |
+
fig = go.Figure(data=[go.Pie(
|
| 122 |
+
labels=labels,
|
| 123 |
+
values=values,
|
| 124 |
+
hole=0.3,
|
| 125 |
+
textinfo="label+percent",
|
| 126 |
+
insidetextorientation="radial"
|
| 127 |
+
)])
|
| 128 |
+
|
| 129 |
+
# Update layout
|
| 130 |
+
fig.update_layout(
|
| 131 |
+
title="Building Component Areas",
|
| 132 |
+
height=500,
|
| 133 |
+
legend=dict(
|
| 134 |
+
orientation="h",
|
| 135 |
+
yanchor="bottom",
|
| 136 |
+
y=1.02,
|
| 137 |
+
xanchor="right",
|
| 138 |
+
x=1
|
| 139 |
+
)
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
return fig
|
| 143 |
+
|
| 144 |
+
@staticmethod
|
| 145 |
+
def create_orientation_area_chart(components: Dict[str, List[Any]]) -> go.Figure:
|
| 146 |
+
"""
|
| 147 |
+
Create a bar chart of areas by orientation.
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
components: Dictionary with lists of building components
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
Plotly figure with area breakdown by orientation
|
| 154 |
+
"""
|
| 155 |
+
# Initialize areas by orientation
|
| 156 |
+
orientation_areas = {
|
| 157 |
+
"NORTH": 0,
|
| 158 |
+
"NORTHEAST": 0,
|
| 159 |
+
"EAST": 0,
|
| 160 |
+
"SOUTHEAST": 0,
|
| 161 |
+
"SOUTH": 0,
|
| 162 |
+
"SOUTHWEST": 0,
|
| 163 |
+
"WEST": 0,
|
| 164 |
+
"NORTHWEST": 0,
|
| 165 |
+
"HORIZONTAL": 0
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
# Calculate areas by orientation for walls
|
| 169 |
+
for wall in components.get("walls", []):
|
| 170 |
+
orientation_areas[wall.orientation.name] += wall.area
|
| 171 |
+
|
| 172 |
+
# Calculate areas by orientation for windows
|
| 173 |
+
for window in components.get("windows", []):
|
| 174 |
+
orientation_areas[window.orientation.name] += window.area
|
| 175 |
+
|
| 176 |
+
# Calculate areas by orientation for doors
|
| 177 |
+
for door in components.get("doors", []):
|
| 178 |
+
orientation_areas[door.orientation.name] += door.area
|
| 179 |
+
|
| 180 |
+
# Add roofs and floors to horizontal
|
| 181 |
+
for roof in components.get("roofs", []):
|
| 182 |
+
if roof.orientation.name == "HORIZONTAL":
|
| 183 |
+
orientation_areas["HORIZONTAL"] += roof.area
|
| 184 |
+
else:
|
| 185 |
+
orientation_areas[roof.orientation.name] += roof.area
|
| 186 |
+
|
| 187 |
+
for floor in components.get("floors", []):
|
| 188 |
+
orientation_areas["HORIZONTAL"] += floor.area
|
| 189 |
+
|
| 190 |
+
# Create labels and values
|
| 191 |
+
orientations = []
|
| 192 |
+
areas = []
|
| 193 |
+
|
| 194 |
+
for orientation, area in orientation_areas.items():
|
| 195 |
+
if area > 0:
|
| 196 |
+
orientations.append(orientation)
|
| 197 |
+
areas.append(area)
|
| 198 |
+
|
| 199 |
+
# Create bar chart
|
| 200 |
+
fig = go.Figure(data=[go.Bar(
|
| 201 |
+
x=orientations,
|
| 202 |
+
y=areas,
|
| 203 |
+
text=areas,
|
| 204 |
+
texttemplate="%{y:.1f} m²",
|
| 205 |
+
textposition="auto"
|
| 206 |
+
)])
|
| 207 |
+
|
| 208 |
+
# Update layout
|
| 209 |
+
fig.update_layout(
|
| 210 |
+
title="Building Component Areas by Orientation",
|
| 211 |
+
xaxis_title="Orientation",
|
| 212 |
+
yaxis_title="Area (m²)",
|
| 213 |
+
height=500
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
return fig
|
| 217 |
+
|
| 218 |
+
@staticmethod
|
| 219 |
+
def create_heat_transfer_chart(components: Dict[str, List[Any]]) -> go.Figure:
|
| 220 |
+
"""
|
| 221 |
+
Create a bar chart of heat transfer coefficients by component type.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
components: Dictionary with lists of building components
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
Plotly figure with heat transfer breakdown
|
| 228 |
+
"""
|
| 229 |
+
# Calculate heat transfer by component type
|
| 230 |
+
heat_transfer = {
|
| 231 |
+
"Walls": sum(wall.area * wall.u_value for wall in components.get("walls", [])),
|
| 232 |
+
"Roofs": sum(roof.area * roof.u_value for roof in components.get("roofs", [])),
|
| 233 |
+
"Floors": sum(floor.area * floor.u_value for floor in components.get("floors", [])),
|
| 234 |
+
"Windows": sum(window.area * window.u_value for window in components.get("windows", [])),
|
| 235 |
+
"Doors": sum(door.area * door.u_value for door in components.get("doors", []))
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
# Create labels and values
|
| 239 |
+
labels = list(heat_transfer.keys())
|
| 240 |
+
values = list(heat_transfer.values())
|
| 241 |
+
|
| 242 |
+
# Create bar chart
|
| 243 |
+
fig = go.Figure(data=[go.Bar(
|
| 244 |
+
x=labels,
|
| 245 |
+
y=values,
|
| 246 |
+
text=values,
|
| 247 |
+
texttemplate="%{y:.1f} W/K",
|
| 248 |
+
textposition="auto"
|
| 249 |
+
)])
|
| 250 |
+
|
| 251 |
+
# Update layout
|
| 252 |
+
fig.update_layout(
|
| 253 |
+
title="Heat Transfer Coefficients by Component Type",
|
| 254 |
+
xaxis_title="Component Type",
|
| 255 |
+
yaxis_title="Heat Transfer Coefficient (W/K)",
|
| 256 |
+
height=500
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
return fig
|
| 260 |
+
|
| 261 |
+
@staticmethod
|
| 262 |
+
def create_3d_building_model(components: Dict[str, List[Any]]) -> go.Figure:
|
| 263 |
+
"""
|
| 264 |
+
Create a 3D visualization of the building components.
|
| 265 |
+
|
| 266 |
+
Args:
|
| 267 |
+
components: Dictionary with lists of building components
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
Plotly figure with 3D building model
|
| 271 |
+
"""
|
| 272 |
+
# Initialize figure
|
| 273 |
+
fig = go.Figure()
|
| 274 |
+
|
| 275 |
+
# Define colors
|
| 276 |
+
colors = {
|
| 277 |
+
"Wall": "lightblue",
|
| 278 |
+
"Roof": "red",
|
| 279 |
+
"Floor": "brown",
|
| 280 |
+
"Window": "skyblue",
|
| 281 |
+
"Door": "orange"
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
# Define orientation vectors
|
| 285 |
+
orientation_vectors = {
|
| 286 |
+
"NORTH": (0, 1, 0),
|
| 287 |
+
"NORTHEAST": (0.7071, 0.7071, 0),
|
| 288 |
+
"EAST": (1, 0, 0),
|
| 289 |
+
"SOUTHEAST": (0.7071, -0.7071, 0),
|
| 290 |
+
"SOUTH": (0, -1, 0),
|
| 291 |
+
"SOUTHWEST": (-0.7071, -0.7071, 0),
|
| 292 |
+
"WEST": (-1, 0, 0),
|
| 293 |
+
"NORTHWEST": (-0.7071, 0.7071, 0),
|
| 294 |
+
"HORIZONTAL": (0, 0, 1)
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
# Define building dimensions (simplified model)
|
| 298 |
+
building_width = 10
|
| 299 |
+
building_depth = 10
|
| 300 |
+
building_height = 3
|
| 301 |
+
|
| 302 |
+
# Create walls
|
| 303 |
+
for i, wall in enumerate(components.get("walls", [])):
|
| 304 |
+
orientation = wall.orientation.name
|
| 305 |
+
vector = orientation_vectors[orientation]
|
| 306 |
+
|
| 307 |
+
# Determine wall position and dimensions
|
| 308 |
+
if orientation in ["NORTH", "SOUTH"]:
|
| 309 |
+
width = building_width
|
| 310 |
+
height = building_height
|
| 311 |
+
depth = 0.3
|
| 312 |
+
|
| 313 |
+
if orientation == "NORTH":
|
| 314 |
+
x = 0
|
| 315 |
+
y = building_depth / 2
|
| 316 |
+
else: # SOUTH
|
| 317 |
+
x = 0
|
| 318 |
+
y = -building_depth / 2
|
| 319 |
+
|
| 320 |
+
z = building_height / 2
|
| 321 |
+
|
| 322 |
+
elif orientation in ["EAST", "WEST"]:
|
| 323 |
+
width = 0.3
|
| 324 |
+
height = building_height
|
| 325 |
+
depth = building_depth
|
| 326 |
+
|
| 327 |
+
if orientation == "EAST":
|
| 328 |
+
x = building_width / 2
|
| 329 |
+
y = 0
|
| 330 |
+
else: # WEST
|
| 331 |
+
x = -building_width / 2
|
| 332 |
+
y = 0
|
| 333 |
+
|
| 334 |
+
z = building_height / 2
|
| 335 |
+
|
| 336 |
+
else: # Diagonal orientations
|
| 337 |
+
width = building_width / 2
|
| 338 |
+
height = building_height
|
| 339 |
+
depth = 0.3
|
| 340 |
+
|
| 341 |
+
if orientation == "NORTHEAST":
|
| 342 |
+
x = building_width / 4
|
| 343 |
+
y = building_depth / 4
|
| 344 |
+
elif orientation == "SOUTHEAST":
|
| 345 |
+
x = building_width / 4
|
| 346 |
+
y = -building_depth / 4
|
| 347 |
+
elif orientation == "SOUTHWEST":
|
| 348 |
+
x = -building_width / 4
|
| 349 |
+
y = -building_depth / 4
|
| 350 |
+
else: # NORTHWEST
|
| 351 |
+
x = -building_width / 4
|
| 352 |
+
y = building_depth / 4
|
| 353 |
+
|
| 354 |
+
z = building_height / 2
|
| 355 |
+
|
| 356 |
+
# Add wall to figure
|
| 357 |
+
fig.add_trace(go.Mesh3d(
|
| 358 |
+
x=[x - width/2, x + width/2, x + width/2, x - width/2, x - width/2, x + width/2, x + width/2, x - width/2],
|
| 359 |
+
y=[y - depth/2, y - depth/2, y + depth/2, y + depth/2, y - depth/2, y - depth/2, y + depth/2, y + depth/2],
|
| 360 |
+
z=[z - height/2, z - height/2, z - height/2, z - height/2, z + height/2, z + height/2, z + height/2, z + height/2],
|
| 361 |
+
i=[0, 0, 0, 1, 4, 4],
|
| 362 |
+
j=[1, 2, 4, 2, 5, 6],
|
| 363 |
+
k=[2, 3, 7, 3, 6, 7],
|
| 364 |
+
color=colors["Wall"],
|
| 365 |
+
opacity=0.7,
|
| 366 |
+
name=f"Wall: {wall.name}"
|
| 367 |
+
))
|
| 368 |
+
|
| 369 |
+
# Create roof
|
| 370 |
+
for i, roof in enumerate(components.get("roofs", [])):
|
| 371 |
+
# Add roof to figure
|
| 372 |
+
fig.add_trace(go.Mesh3d(
|
| 373 |
+
x=[-building_width/2, building_width/2, building_width/2, -building_width/2],
|
| 374 |
+
y=[-building_depth/2, -building_depth/2, building_depth/2, building_depth/2],
|
| 375 |
+
z=[building_height, building_height, building_height, building_height],
|
| 376 |
+
i=[0],
|
| 377 |
+
j=[1],
|
| 378 |
+
k=[2],
|
| 379 |
+
color=colors["Roof"],
|
| 380 |
+
opacity=0.7,
|
| 381 |
+
name=f"Roof: {roof.name}"
|
| 382 |
+
))
|
| 383 |
+
|
| 384 |
+
fig.add_trace(go.Mesh3d(
|
| 385 |
+
x=[-building_width/2, -building_width/2, building_width/2],
|
| 386 |
+
y=[building_depth/2, -building_depth/2, -building_depth/2],
|
| 387 |
+
z=[building_height, building_height, building_height],
|
| 388 |
+
i=[0],
|
| 389 |
+
j=[1],
|
| 390 |
+
k=[2],
|
| 391 |
+
color=colors["Roof"],
|
| 392 |
+
opacity=0.7,
|
| 393 |
+
name=f"Roof: {roof.name}"
|
| 394 |
+
))
|
| 395 |
+
|
| 396 |
+
# Create floor
|
| 397 |
+
for i, floor in enumerate(components.get("floors", [])):
|
| 398 |
+
# Add floor to figure
|
| 399 |
+
fig.add_trace(go.Mesh3d(
|
| 400 |
+
x=[-building_width/2, building_width/2, building_width/2, -building_width/2],
|
| 401 |
+
y=[-building_depth/2, -building_depth/2, building_depth/2, building_depth/2],
|
| 402 |
+
z=[0, 0, 0, 0],
|
| 403 |
+
i=[0],
|
| 404 |
+
j=[1],
|
| 405 |
+
k=[2],
|
| 406 |
+
color=colors["Floor"],
|
| 407 |
+
opacity=0.7,
|
| 408 |
+
name=f"Floor: {floor.name}"
|
| 409 |
+
))
|
| 410 |
+
|
| 411 |
+
fig.add_trace(go.Mesh3d(
|
| 412 |
+
x=[-building_width/2, -building_width/2, building_width/2],
|
| 413 |
+
y=[building_depth/2, -building_depth/2, -building_depth/2],
|
| 414 |
+
z=[0, 0, 0],
|
| 415 |
+
i=[0],
|
| 416 |
+
j=[1],
|
| 417 |
+
k=[2],
|
| 418 |
+
color=colors["Floor"],
|
| 419 |
+
opacity=0.7,
|
| 420 |
+
name=f"Floor: {floor.name}"
|
| 421 |
+
))
|
| 422 |
+
|
| 423 |
+
# Create windows
|
| 424 |
+
for i, window in enumerate(components.get("windows", [])):
|
| 425 |
+
orientation = window.orientation.name
|
| 426 |
+
vector = orientation_vectors[orientation]
|
| 427 |
+
|
| 428 |
+
# Determine window position and dimensions
|
| 429 |
+
window_width = 1.5
|
| 430 |
+
window_height = 1.2
|
| 431 |
+
window_depth = 0.1
|
| 432 |
+
|
| 433 |
+
if orientation == "NORTH":
|
| 434 |
+
x = i * 3 - building_width/4
|
| 435 |
+
y = building_depth / 2
|
| 436 |
+
z = building_height / 2
|
| 437 |
+
elif orientation == "SOUTH":
|
| 438 |
+
x = i * 3 - building_width/4
|
| 439 |
+
y = -building_depth / 2
|
| 440 |
+
z = building_height / 2
|
| 441 |
+
elif orientation == "EAST":
|
| 442 |
+
x = building_width / 2
|
| 443 |
+
y = i * 3 - building_depth/4
|
| 444 |
+
z = building_height / 2
|
| 445 |
+
elif orientation == "WEST":
|
| 446 |
+
x = -building_width / 2
|
| 447 |
+
y = i * 3 - building_depth/4
|
| 448 |
+
z = building_height / 2
|
| 449 |
+
else:
|
| 450 |
+
# Skip diagonal orientations for simplicity
|
| 451 |
+
continue
|
| 452 |
+
|
| 453 |
+
# Add window to figure
|
| 454 |
+
fig.add_trace(go.Mesh3d(
|
| 455 |
+
x=[x - window_width/2, x + window_width/2, x + window_width/2, x - window_width/2, x - window_width/2, x + window_width/2, x + window_width/2, x - window_width/2],
|
| 456 |
+
y=[y - window_depth/2, y - window_depth/2, y + window_depth/2, y + window_depth/2, y - window_depth/2, y - window_depth/2, y + window_depth/2, y + window_depth/2],
|
| 457 |
+
z=[z - window_height/2, z - window_height/2, z - window_height/2, z - window_height/2, z + window_height/2, z + window_height/2, z + window_height/2, z + window_height/2],
|
| 458 |
+
i=[0, 0, 0, 1, 4, 4],
|
| 459 |
+
j=[1, 2, 4, 2, 5, 6],
|
| 460 |
+
k=[2, 3, 7, 3, 6, 7],
|
| 461 |
+
color=colors["Window"],
|
| 462 |
+
opacity=0.5,
|
| 463 |
+
name=f"Window: {window.name}"
|
| 464 |
+
))
|
| 465 |
+
|
| 466 |
+
# Create doors
|
| 467 |
+
for i, door in enumerate(components.get("doors", [])):
|
| 468 |
+
orientation = door.orientation.name
|
| 469 |
+
vector = orientation_vectors[orientation]
|
| 470 |
+
|
| 471 |
+
# Determine door position and dimensions
|
| 472 |
+
door_width = 1.0
|
| 473 |
+
door_height = 2.0
|
| 474 |
+
door_depth = 0.1
|
| 475 |
+
|
| 476 |
+
if orientation == "NORTH":
|
| 477 |
+
x = i * 3
|
| 478 |
+
y = building_depth / 2
|
| 479 |
+
z = door_height / 2
|
| 480 |
+
elif orientation == "SOUTH":
|
| 481 |
+
x = i * 3
|
| 482 |
+
y = -building_depth / 2
|
| 483 |
+
z = door_height / 2
|
| 484 |
+
elif orientation == "EAST":
|
| 485 |
+
x = building_width / 2
|
| 486 |
+
y = i * 3
|
| 487 |
+
z = door_height / 2
|
| 488 |
+
elif orientation == "WEST":
|
| 489 |
+
x = -building_width / 2
|
| 490 |
+
y = i * 3
|
| 491 |
+
z = door_height / 2
|
| 492 |
+
else:
|
| 493 |
+
# Skip diagonal orientations for simplicity
|
| 494 |
+
continue
|
| 495 |
+
|
| 496 |
+
# Add door to figure
|
| 497 |
+
fig.add_trace(go.Mesh3d(
|
| 498 |
+
x=[x - door_width/2, x + door_width/2, x + door_width/2, x - door_width/2, x - door_width/2, x + door_width/2, x + door_width/2, x - door_width/2],
|
| 499 |
+
y=[y - door_depth/2, y - door_depth/2, y + door_depth/2, y + door_depth/2, y - door_depth/2, y - door_depth/2, y + door_depth/2, y + door_depth/2],
|
| 500 |
+
z=[z - door_height/2, z - door_height/2, z - door_height/2, z - door_height/2, z + door_height/2, z + door_height/2, z + door_height/2, z + door_height/2],
|
| 501 |
+
i=[0, 0, 0, 1, 4, 4],
|
| 502 |
+
j=[1, 2, 4, 2, 5, 6],
|
| 503 |
+
k=[2, 3, 7, 3, 6, 7],
|
| 504 |
+
color=colors["Door"],
|
| 505 |
+
opacity=0.7,
|
| 506 |
+
name=f"Door: {door.name}"
|
| 507 |
+
))
|
| 508 |
+
|
| 509 |
+
# Update layout
|
| 510 |
+
fig.update_layout(
|
| 511 |
+
title="3D Building Model",
|
| 512 |
+
scene=dict(
|
| 513 |
+
xaxis_title="X",
|
| 514 |
+
yaxis_title="Y",
|
| 515 |
+
zaxis_title="Z",
|
| 516 |
+
aspectmode="data"
|
| 517 |
+
),
|
| 518 |
+
height=700,
|
| 519 |
+
margin=dict(l=0, r=0, b=0, t=30)
|
| 520 |
+
)
|
| 521 |
+
|
| 522 |
+
return fig
|
| 523 |
+
|
| 524 |
+
@staticmethod
|
| 525 |
+
def display_component_visualization(components: Dict[str, List[Any]]) -> None:
|
| 526 |
+
"""
|
| 527 |
+
Display component visualization in Streamlit.
|
| 528 |
+
|
| 529 |
+
Args:
|
| 530 |
+
components: Dictionary with lists of building components
|
| 531 |
+
"""
|
| 532 |
+
st.header("Building Component Visualization")
|
| 533 |
+
|
| 534 |
+
# Create tabs for different visualizations
|
| 535 |
+
tab1, tab2, tab3, tab4, tab5 = st.tabs([
|
| 536 |
+
"Component Summary",
|
| 537 |
+
"Area Breakdown",
|
| 538 |
+
"Orientation Analysis",
|
| 539 |
+
"Heat Transfer Analysis",
|
| 540 |
+
"3D Building Model"
|
| 541 |
+
])
|
| 542 |
+
|
| 543 |
+
with tab1:
|
| 544 |
+
st.subheader("Component Summary")
|
| 545 |
+
df = ComponentVisualization.create_component_summary_table(components)
|
| 546 |
+
st.dataframe(df, use_container_width=True)
|
| 547 |
+
|
| 548 |
+
# Add download button for CSV
|
| 549 |
+
csv = df.to_csv(index=False).encode('utf-8')
|
| 550 |
+
st.download_button(
|
| 551 |
+
label="Download Component Summary as CSV",
|
| 552 |
+
data=csv,
|
| 553 |
+
file_name="component_summary.csv",
|
| 554 |
+
mime="text/csv"
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
with tab2:
|
| 558 |
+
st.subheader("Area Breakdown")
|
| 559 |
+
fig = ComponentVisualization.create_component_area_chart(components)
|
| 560 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 561 |
+
|
| 562 |
+
with tab3:
|
| 563 |
+
st.subheader("Orientation Analysis")
|
| 564 |
+
fig = ComponentVisualization.create_orientation_area_chart(components)
|
| 565 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 566 |
+
|
| 567 |
+
with tab4:
|
| 568 |
+
st.subheader("Heat Transfer Analysis")
|
| 569 |
+
fig = ComponentVisualization.create_heat_transfer_chart(components)
|
| 570 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 571 |
+
|
| 572 |
+
with tab5:
|
| 573 |
+
st.subheader("3D Building Model")
|
| 574 |
+
fig = ComponentVisualization.create_3d_building_model(components)
|
| 575 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
# Create a singleton instance
|
| 579 |
+
component_visualization = ComponentVisualization()
|
| 580 |
+
|
| 581 |
+
# Example usage
|
| 582 |
+
if __name__ == "__main__":
|
| 583 |
+
import streamlit as st
|
| 584 |
+
from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
|
| 585 |
+
|
| 586 |
+
# Create sample building components
|
| 587 |
+
walls = [
|
| 588 |
+
Wall(
|
| 589 |
+
id="wall1",
|
| 590 |
+
name="North Wall",
|
| 591 |
+
component_type=ComponentType.WALL,
|
| 592 |
+
u_value=0.5,
|
| 593 |
+
area=20.0,
|
| 594 |
+
orientation=Orientation.NORTH,
|
| 595 |
+
wall_type="Brick",
|
| 596 |
+
wall_group="B"
|
| 597 |
+
),
|
| 598 |
+
Wall(
|
| 599 |
+
id="wall2",
|
| 600 |
+
name="South Wall",
|
| 601 |
+
component_type=ComponentType.WALL,
|
| 602 |
+
u_value=0.5,
|
| 603 |
+
area=20.0,
|
| 604 |
+
orientation=Orientation.SOUTH,
|
| 605 |
+
wall_type="Brick",
|
| 606 |
+
wall_group="B"
|
| 607 |
+
),
|
| 608 |
+
Wall(
|
| 609 |
+
id="wall3",
|
| 610 |
+
name="East Wall",
|
| 611 |
+
component_type=ComponentType.WALL,
|
| 612 |
+
u_value=0.5,
|
| 613 |
+
area=15.0,
|
| 614 |
+
orientation=Orientation.EAST,
|
| 615 |
+
wall_type="Brick",
|
| 616 |
+
wall_group="B"
|
| 617 |
+
),
|
| 618 |
+
Wall(
|
| 619 |
+
id="wall4",
|
| 620 |
+
name="West Wall",
|
| 621 |
+
component_type=ComponentType.WALL,
|
| 622 |
+
u_value=0.5,
|
| 623 |
+
area=15.0,
|
| 624 |
+
orientation=Orientation.WEST,
|
| 625 |
+
wall_type="Brick",
|
| 626 |
+
wall_group="B"
|
| 627 |
+
)
|
| 628 |
+
]
|
| 629 |
+
|
| 630 |
+
roofs = [
|
| 631 |
+
Roof(
|
| 632 |
+
id="roof1",
|
| 633 |
+
name="Flat Roof",
|
| 634 |
+
component_type=ComponentType.ROOF,
|
| 635 |
+
u_value=0.3,
|
| 636 |
+
area=100.0,
|
| 637 |
+
orientation=Orientation.HORIZONTAL,
|
| 638 |
+
roof_type="Concrete",
|
| 639 |
+
roof_group="C"
|
| 640 |
+
)
|
| 641 |
+
]
|
| 642 |
+
|
| 643 |
+
floors = [
|
| 644 |
+
Floor(
|
| 645 |
+
id="floor1",
|
| 646 |
+
name="Ground Floor",
|
| 647 |
+
component_type=ComponentType.FLOOR,
|
| 648 |
+
u_value=0.4,
|
| 649 |
+
area=100.0,
|
| 650 |
+
floor_type="Concrete"
|
| 651 |
+
)
|
| 652 |
+
]
|
| 653 |
+
|
| 654 |
+
windows = [
|
| 655 |
+
Window(
|
| 656 |
+
id="window1",
|
| 657 |
+
name="North Window 1",
|
| 658 |
+
component_type=ComponentType.WINDOW,
|
| 659 |
+
u_value=2.8,
|
| 660 |
+
area=4.0,
|
| 661 |
+
orientation=Orientation.NORTH,
|
| 662 |
+
shgc=0.7,
|
| 663 |
+
vt=0.8,
|
| 664 |
+
window_type="Double Glazed",
|
| 665 |
+
glazing_layers=2,
|
| 666 |
+
gas_fill="Air",
|
| 667 |
+
low_e_coating=False
|
| 668 |
+
),
|
| 669 |
+
Window(
|
| 670 |
+
id="window2",
|
| 671 |
+
name="South Window 1",
|
| 672 |
+
component_type=ComponentType.WINDOW,
|
| 673 |
+
u_value=2.8,
|
| 674 |
+
area=6.0,
|
| 675 |
+
orientation=Orientation.SOUTH,
|
| 676 |
+
shgc=0.7,
|
| 677 |
+
vt=0.8,
|
| 678 |
+
window_type="Double Glazed",
|
| 679 |
+
glazing_layers=2,
|
| 680 |
+
gas_fill="Air",
|
| 681 |
+
low_e_coating=False
|
| 682 |
+
),
|
| 683 |
+
Window(
|
| 684 |
+
id="window3",
|
| 685 |
+
name="East Window 1",
|
| 686 |
+
component_type=ComponentType.WINDOW,
|
| 687 |
+
u_value=2.8,
|
| 688 |
+
area=3.0,
|
| 689 |
+
orientation=Orientation.EAST,
|
| 690 |
+
shgc=0.7,
|
| 691 |
+
vt=0.8,
|
| 692 |
+
window_type="Double Glazed",
|
| 693 |
+
glazing_layers=2,
|
| 694 |
+
gas_fill="Air",
|
| 695 |
+
low_e_coating=False
|
| 696 |
+
)
|
| 697 |
+
]
|
| 698 |
+
|
| 699 |
+
doors = [
|
| 700 |
+
Door(
|
| 701 |
+
id="door1",
|
| 702 |
+
name="Front Door",
|
| 703 |
+
component_type=ComponentType.DOOR,
|
| 704 |
+
u_value=2.0,
|
| 705 |
+
area=2.0,
|
| 706 |
+
orientation=Orientation.SOUTH,
|
| 707 |
+
door_type="Solid Wood"
|
| 708 |
+
)
|
| 709 |
+
]
|
| 710 |
+
|
| 711 |
+
# Create components dictionary
|
| 712 |
+
components = {
|
| 713 |
+
"walls": walls,
|
| 714 |
+
"roofs": roofs,
|
| 715 |
+
"floors": floors,
|
| 716 |
+
"windows": windows,
|
| 717 |
+
"doors": doors
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
# Display component visualization
|
| 721 |
+
component_visualization.display_component_visualization(components)
|
utils/cooling_load.py
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cooling load calculation module for HVAC Load Calculator.
|
| 3 |
+
This module implements the CLTD/CLF method for calculating cooling loads.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import math
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import os
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
from enum import Enum
|
| 13 |
+
|
| 14 |
+
# Import data models and utilities
|
| 15 |
+
from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
|
| 16 |
+
from data.ashrae_tables import ashrae_tables
|
| 17 |
+
from utils.psychrometrics import Psychrometrics
|
| 18 |
+
from utils.heat_transfer import HeatTransferCalculations
|
| 19 |
+
|
| 20 |
+
# Define paths
|
| 21 |
+
DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CoolingLoadCalculator:
|
| 25 |
+
"""Class for calculating cooling loads using the CLTD/CLF method."""
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
"""Initialize cooling load calculator."""
|
| 29 |
+
self.heat_transfer = HeatTransferCalculations()
|
| 30 |
+
self.psychrometrics = Psychrometrics()
|
| 31 |
+
self.ashrae_tables = ashrae_tables
|
| 32 |
+
|
| 33 |
+
def calculate_wall_cooling_load(self, wall: Wall, outdoor_temp: float, indoor_temp: float,
|
| 34 |
+
month: str, hour: int, latitude: str = "40N",
|
| 35 |
+
color: str = "Dark") -> float:
|
| 36 |
+
"""
|
| 37 |
+
Calculate cooling load through a wall using the CLTD method.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
wall: Wall object
|
| 41 |
+
outdoor_temp: Outdoor temperature in °C
|
| 42 |
+
indoor_temp: Indoor temperature in °C
|
| 43 |
+
month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
|
| 44 |
+
hour: Hour of the day (0-23)
|
| 45 |
+
latitude: Latitude (24N, 32N, 40N, 48N, 56N)
|
| 46 |
+
color: Surface color (Dark, Medium, Light)
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Cooling load in W
|
| 50 |
+
"""
|
| 51 |
+
# Get wall properties
|
| 52 |
+
u_value = wall.u_value
|
| 53 |
+
area = wall.area
|
| 54 |
+
orientation = wall.orientation.value
|
| 55 |
+
wall_group = wall.wall_group
|
| 56 |
+
|
| 57 |
+
# Calculate corrected CLTD
|
| 58 |
+
cltd = self.ashrae_tables.calculate_corrected_cltd_wall(
|
| 59 |
+
wall_group=wall_group,
|
| 60 |
+
orientation=orientation,
|
| 61 |
+
hour=hour,
|
| 62 |
+
color=color,
|
| 63 |
+
month=month,
|
| 64 |
+
latitude=latitude,
|
| 65 |
+
indoor_temp=indoor_temp,
|
| 66 |
+
outdoor_temp=outdoor_temp
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Calculate cooling load
|
| 70 |
+
cooling_load = u_value * area * cltd
|
| 71 |
+
|
| 72 |
+
return cooling_load
|
| 73 |
+
|
| 74 |
+
def calculate_roof_cooling_load(self, roof: Roof, outdoor_temp: float, indoor_temp: float,
|
| 75 |
+
month: str, hour: int, latitude: str = "40N",
|
| 76 |
+
color: str = "Dark") -> float:
|
| 77 |
+
"""
|
| 78 |
+
Calculate cooling load through a roof using the CLTD method.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
roof: Roof object
|
| 82 |
+
outdoor_temp: Outdoor temperature in °C
|
| 83 |
+
indoor_temp: Indoor temperature in °C
|
| 84 |
+
month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
|
| 85 |
+
hour: Hour of the day (0-23)
|
| 86 |
+
latitude: Latitude (24N, 32N, 40N, 48N, 56N)
|
| 87 |
+
color: Surface color (Dark, Medium, Light)
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
Cooling load in W
|
| 91 |
+
"""
|
| 92 |
+
# Get roof properties
|
| 93 |
+
u_value = roof.u_value
|
| 94 |
+
area = roof.area
|
| 95 |
+
roof_group = roof.roof_group
|
| 96 |
+
|
| 97 |
+
# Calculate corrected CLTD
|
| 98 |
+
cltd = self.ashrae_tables.calculate_corrected_cltd_roof(
|
| 99 |
+
roof_group=roof_group,
|
| 100 |
+
hour=hour,
|
| 101 |
+
color=color,
|
| 102 |
+
month=month,
|
| 103 |
+
latitude=latitude,
|
| 104 |
+
indoor_temp=indoor_temp,
|
| 105 |
+
outdoor_temp=outdoor_temp
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Calculate cooling load
|
| 109 |
+
cooling_load = u_value * area * cltd
|
| 110 |
+
|
| 111 |
+
return cooling_load
|
| 112 |
+
|
| 113 |
+
def calculate_window_cooling_load(self, window: Window, outdoor_temp: float, indoor_temp: float,
|
| 114 |
+
month: str, hour: int, latitude: str = "40N_JUL",
|
| 115 |
+
shading_coefficient: float = 1.0) -> Dict[str, float]:
|
| 116 |
+
"""
|
| 117 |
+
Calculate cooling load through a window using the CLTD/SCL method.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
window: Window object
|
| 121 |
+
outdoor_temp: Outdoor temperature in °C
|
| 122 |
+
indoor_temp: Indoor temperature in °C
|
| 123 |
+
month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
|
| 124 |
+
hour: Hour of the day (0-23)
|
| 125 |
+
latitude: Latitude and month key (default: "40N_JUL")
|
| 126 |
+
shading_coefficient: Shading coefficient (0-1)
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
Dictionary with conduction, solar, and total cooling loads in W
|
| 130 |
+
"""
|
| 131 |
+
# Get window properties
|
| 132 |
+
u_value = window.u_value
|
| 133 |
+
area = window.area
|
| 134 |
+
orientation = window.orientation.value
|
| 135 |
+
shgc = window.shgc
|
| 136 |
+
|
| 137 |
+
# Calculate conduction cooling load
|
| 138 |
+
delta_t = outdoor_temp - indoor_temp
|
| 139 |
+
conduction_load = u_value * area * delta_t
|
| 140 |
+
|
| 141 |
+
# Calculate solar cooling load
|
| 142 |
+
scl = self.ashrae_tables.get_scl(orientation, hour, latitude)
|
| 143 |
+
solar_load = area * shgc * shading_coefficient * scl
|
| 144 |
+
|
| 145 |
+
# Calculate total cooling load
|
| 146 |
+
total_load = conduction_load + solar_load
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
"conduction": conduction_load,
|
| 150 |
+
"solar": solar_load,
|
| 151 |
+
"total": total_load
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
def calculate_door_cooling_load(self, door: Door, outdoor_temp: float, indoor_temp: float) -> float:
|
| 155 |
+
"""
|
| 156 |
+
Calculate cooling load through a door using simple conduction.
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
door: Door object
|
| 160 |
+
outdoor_temp: Outdoor temperature in °C
|
| 161 |
+
indoor_temp: Indoor temperature in °C
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
Cooling load in W
|
| 165 |
+
"""
|
| 166 |
+
# Get door properties
|
| 167 |
+
u_value = door.u_value
|
| 168 |
+
area = door.area
|
| 169 |
+
|
| 170 |
+
# Calculate cooling load
|
| 171 |
+
delta_t = outdoor_temp - indoor_temp
|
| 172 |
+
cooling_load = u_value * area * delta_t
|
| 173 |
+
|
| 174 |
+
return cooling_load
|
| 175 |
+
|
| 176 |
+
def calculate_floor_cooling_load(self, floor: Floor, ground_temp: float, indoor_temp: float) -> float:
|
| 177 |
+
"""
|
| 178 |
+
Calculate cooling load through a floor.
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
floor: Floor object
|
| 182 |
+
ground_temp: Ground or adjacent space temperature in °C
|
| 183 |
+
indoor_temp: Indoor temperature in °C
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
Cooling load in W
|
| 187 |
+
"""
|
| 188 |
+
# Get floor properties
|
| 189 |
+
u_value = floor.u_value
|
| 190 |
+
area = floor.area
|
| 191 |
+
|
| 192 |
+
# Calculate cooling load
|
| 193 |
+
delta_t = ground_temp - indoor_temp
|
| 194 |
+
cooling_load = u_value * area * delta_t
|
| 195 |
+
|
| 196 |
+
# Return positive value for heat gain, zero for heat loss
|
| 197 |
+
return max(0, cooling_load)
|
| 198 |
+
|
| 199 |
+
def calculate_infiltration_cooling_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
|
| 200 |
+
outdoor_rh: float, indoor_rh: float) -> Dict[str, float]:
|
| 201 |
+
"""
|
| 202 |
+
Calculate sensible and latent cooling loads due to infiltration.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
flow_rate: Infiltration flow rate in m³/s
|
| 206 |
+
outdoor_temp: Outdoor temperature in °C
|
| 207 |
+
indoor_temp: Indoor temperature in °C
|
| 208 |
+
outdoor_rh: Outdoor relative humidity in %
|
| 209 |
+
indoor_rh: Indoor relative humidity in %
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
Dictionary with sensible, latent, and total cooling loads in W
|
| 213 |
+
"""
|
| 214 |
+
# Calculate sensible cooling load
|
| 215 |
+
sensible_load = self.heat_transfer.infiltration_heat_transfer(
|
| 216 |
+
flow_rate=flow_rate,
|
| 217 |
+
delta_t=outdoor_temp - indoor_temp
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Calculate humidity ratios
|
| 221 |
+
w_outdoor = self.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh)
|
| 222 |
+
w_indoor = self.psychrometrics.humidity_ratio(indoor_temp, indoor_rh)
|
| 223 |
+
|
| 224 |
+
# Calculate latent cooling load
|
| 225 |
+
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
|
| 226 |
+
flow_rate=flow_rate,
|
| 227 |
+
delta_w=w_outdoor - w_indoor
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# Calculate total cooling load
|
| 231 |
+
total_load = sensible_load + latent_load
|
| 232 |
+
|
| 233 |
+
return {
|
| 234 |
+
"sensible": sensible_load,
|
| 235 |
+
"latent": latent_load,
|
| 236 |
+
"total": total_load
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
def calculate_ventilation_cooling_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
|
| 240 |
+
outdoor_rh: float, indoor_rh: float) -> Dict[str, float]:
|
| 241 |
+
"""
|
| 242 |
+
Calculate sensible and latent cooling loads due to ventilation.
|
| 243 |
+
|
| 244 |
+
Args:
|
| 245 |
+
flow_rate: Ventilation flow rate in m³/s
|
| 246 |
+
outdoor_temp: Outdoor temperature in °C
|
| 247 |
+
indoor_temp: Indoor temperature in °C
|
| 248 |
+
outdoor_rh: Outdoor relative humidity in %
|
| 249 |
+
indoor_rh: Indoor relative humidity in %
|
| 250 |
+
|
| 251 |
+
Returns:
|
| 252 |
+
Dictionary with sensible, latent, and total cooling loads in W
|
| 253 |
+
"""
|
| 254 |
+
# Ventilation load calculation is the same as infiltration
|
| 255 |
+
return self.calculate_infiltration_cooling_load(
|
| 256 |
+
flow_rate=flow_rate,
|
| 257 |
+
outdoor_temp=outdoor_temp,
|
| 258 |
+
indoor_temp=indoor_temp,
|
| 259 |
+
outdoor_rh=outdoor_rh,
|
| 260 |
+
indoor_rh=indoor_rh
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
def calculate_people_cooling_load(self, num_people: int, activity_level: str,
|
| 264 |
+
hours_occupancy: str, hour: int) -> Dict[str, float]:
|
| 265 |
+
"""
|
| 266 |
+
Calculate sensible and latent cooling loads due to people.
|
| 267 |
+
|
| 268 |
+
Args:
|
| 269 |
+
num_people: Number of people
|
| 270 |
+
activity_level: Activity level (Seated/Resting, Light work, Medium work, Heavy work)
|
| 271 |
+
hours_occupancy: Hours of occupancy (8h, 10h, 12h, 14h, 16h, 18h, 24h)
|
| 272 |
+
hour: Hour of the day (0-23)
|
| 273 |
+
|
| 274 |
+
Returns:
|
| 275 |
+
Dictionary with sensible, latent, and total cooling loads in W
|
| 276 |
+
"""
|
| 277 |
+
# Define heat gains for different activity levels
|
| 278 |
+
activity_gains = {
|
| 279 |
+
"Seated/Resting": {"sensible": 70, "latent": 45},
|
| 280 |
+
"Light work": {"sensible": 75, "latent": 55},
|
| 281 |
+
"Medium work": {"sensible": 85, "latent": 80},
|
| 282 |
+
"Heavy work": {"sensible": 95, "latent": 145}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
# Get heat gains for the specified activity level
|
| 286 |
+
if activity_level not in activity_gains:
|
| 287 |
+
raise ValueError(f"Invalid activity level: {activity_level}")
|
| 288 |
+
|
| 289 |
+
sensible_gain = activity_gains[activity_level]["sensible"]
|
| 290 |
+
latent_gain = activity_gains[activity_level]["latent"]
|
| 291 |
+
|
| 292 |
+
# Get CLF for the specified hour and occupancy
|
| 293 |
+
clf = self.ashrae_tables.get_clf_people(hour, hours_occupancy)
|
| 294 |
+
|
| 295 |
+
# Calculate cooling loads
|
| 296 |
+
sensible_load = num_people * sensible_gain * clf
|
| 297 |
+
latent_load = num_people * latent_gain # Latent load is not affected by CLF
|
| 298 |
+
total_load = sensible_load + latent_load
|
| 299 |
+
|
| 300 |
+
return {
|
| 301 |
+
"sensible": sensible_load,
|
| 302 |
+
"latent": latent_load,
|
| 303 |
+
"total": total_load
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
def calculate_lights_cooling_load(self, power: float, use_factor: float,
|
| 307 |
+
special_allowance: float, hours_operation: str,
|
| 308 |
+
hour: int) -> float:
|
| 309 |
+
"""
|
| 310 |
+
Calculate cooling load due to lights.
|
| 311 |
+
|
| 312 |
+
Args:
|
| 313 |
+
power: Installed lighting power in W
|
| 314 |
+
use_factor: Usage factor (0-1)
|
| 315 |
+
special_allowance: Special allowance factor for fixtures (0-1)
|
| 316 |
+
hours_operation: Hours of operation (8h, 10h, 12h, 14h, 16h, 18h, 24h)
|
| 317 |
+
hour: Hour of the day (0-23)
|
| 318 |
+
|
| 319 |
+
Returns:
|
| 320 |
+
Cooling load in W
|
| 321 |
+
"""
|
| 322 |
+
# Get CLF for the specified hour and operation
|
| 323 |
+
clf = self.ashrae_tables.get_clf_lights(hour, hours_operation)
|
| 324 |
+
|
| 325 |
+
# Calculate cooling load
|
| 326 |
+
cooling_load = power * use_factor * (1 + special_allowance) * clf
|
| 327 |
+
|
| 328 |
+
return cooling_load
|
| 329 |
+
|
| 330 |
+
def calculate_equipment_cooling_load(self, power: float, use_factor: float,
|
| 331 |
+
radiation_factor: float, hours_operation: str,
|
| 332 |
+
hour: int) -> Dict[str, float]:
|
| 333 |
+
"""
|
| 334 |
+
Calculate sensible and latent cooling loads due to equipment.
|
| 335 |
+
|
| 336 |
+
Args:
|
| 337 |
+
power: Equipment power in W
|
| 338 |
+
use_factor: Usage factor (0-1)
|
| 339 |
+
radiation_factor: Radiation factor (0-1)
|
| 340 |
+
hours_operation: Hours of operation (8h, 10h, 12h, 14h, 16h, 18h, 24h)
|
| 341 |
+
hour: Hour of the day (0-23)
|
| 342 |
+
|
| 343 |
+
Returns:
|
| 344 |
+
Dictionary with sensible, latent, and total cooling loads in W
|
| 345 |
+
"""
|
| 346 |
+
# Get CLF for the specified hour and operation
|
| 347 |
+
clf = self.ashrae_tables.get_clf_equipment(hour, hours_operation)
|
| 348 |
+
|
| 349 |
+
# Calculate sensible cooling load
|
| 350 |
+
sensible_load = power * use_factor * radiation_factor * clf
|
| 351 |
+
|
| 352 |
+
# Calculate latent cooling load (if any)
|
| 353 |
+
latent_load = power * use_factor * (1 - radiation_factor)
|
| 354 |
+
|
| 355 |
+
# Calculate total cooling load
|
| 356 |
+
total_load = sensible_load + latent_load
|
| 357 |
+
|
| 358 |
+
return {
|
| 359 |
+
"sensible": sensible_load,
|
| 360 |
+
"latent": latent_load,
|
| 361 |
+
"total": total_load
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
def calculate_hourly_cooling_loads(self, building_components: Dict[str, List[Any]],
|
| 365 |
+
outdoor_conditions: Dict[str, Any],
|
| 366 |
+
indoor_conditions: Dict[str, Any],
|
| 367 |
+
internal_loads: Dict[str, Any]) -> Dict[int, Dict[str, float]]:
|
| 368 |
+
"""
|
| 369 |
+
Calculate hourly cooling loads for a building.
|
| 370 |
+
|
| 371 |
+
Args:
|
| 372 |
+
building_components: Dictionary with lists of building components
|
| 373 |
+
outdoor_conditions: Dictionary with outdoor conditions
|
| 374 |
+
indoor_conditions: Dictionary with indoor conditions
|
| 375 |
+
internal_loads: Dictionary with internal loads
|
| 376 |
+
|
| 377 |
+
Returns:
|
| 378 |
+
Dictionary with hourly cooling loads
|
| 379 |
+
"""
|
| 380 |
+
# Extract building components
|
| 381 |
+
walls = building_components.get("walls", [])
|
| 382 |
+
roofs = building_components.get("roofs", [])
|
| 383 |
+
floors = building_components.get("floors", [])
|
| 384 |
+
windows = building_components.get("windows", [])
|
| 385 |
+
doors = building_components.get("doors", [])
|
| 386 |
+
|
| 387 |
+
# Extract outdoor conditions
|
| 388 |
+
outdoor_temp = outdoor_conditions.get("temperature", 35.0)
|
| 389 |
+
outdoor_rh = outdoor_conditions.get("relative_humidity", 50.0)
|
| 390 |
+
ground_temp = outdoor_conditions.get("ground_temperature", 20.0)
|
| 391 |
+
month = outdoor_conditions.get("month", "Jul")
|
| 392 |
+
latitude = outdoor_conditions.get("latitude", "40N")
|
| 393 |
+
|
| 394 |
+
# Extract indoor conditions
|
| 395 |
+
indoor_temp = indoor_conditions.get("temperature", 24.0)
|
| 396 |
+
indoor_rh = indoor_conditions.get("relative_humidity", 50.0)
|
| 397 |
+
|
| 398 |
+
# Extract internal loads
|
| 399 |
+
people = internal_loads.get("people", {})
|
| 400 |
+
lights = internal_loads.get("lights", {})
|
| 401 |
+
equipment = internal_loads.get("equipment", {})
|
| 402 |
+
infiltration = internal_loads.get("infiltration", {})
|
| 403 |
+
ventilation = internal_loads.get("ventilation", {})
|
| 404 |
+
|
| 405 |
+
# Initialize hourly cooling loads
|
| 406 |
+
hourly_loads = {}
|
| 407 |
+
|
| 408 |
+
# Calculate cooling loads for each hour
|
| 409 |
+
for hour in range(24):
|
| 410 |
+
# Initialize loads for this hour
|
| 411 |
+
loads = {
|
| 412 |
+
"walls": 0,
|
| 413 |
+
"roofs": 0,
|
| 414 |
+
"floors": 0,
|
| 415 |
+
"windows_conduction": 0,
|
| 416 |
+
"windows_solar": 0,
|
| 417 |
+
"doors": 0,
|
| 418 |
+
"infiltration_sensible": 0,
|
| 419 |
+
"infiltration_latent": 0,
|
| 420 |
+
"ventilation_sensible": 0,
|
| 421 |
+
"ventilation_latent": 0,
|
| 422 |
+
"people_sensible": 0,
|
| 423 |
+
"people_latent": 0,
|
| 424 |
+
"lights": 0,
|
| 425 |
+
"equipment_sensible": 0,
|
| 426 |
+
"equipment_latent": 0,
|
| 427 |
+
"total_sensible": 0,
|
| 428 |
+
"total_latent": 0,
|
| 429 |
+
"total": 0
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
# Calculate wall loads
|
| 433 |
+
for wall in walls:
|
| 434 |
+
wall_load = self.calculate_wall_cooling_load(
|
| 435 |
+
wall=wall,
|
| 436 |
+
outdoor_temp=outdoor_temp,
|
| 437 |
+
indoor_temp=indoor_temp,
|
| 438 |
+
month=month,
|
| 439 |
+
hour=hour,
|
| 440 |
+
latitude=latitude,
|
| 441 |
+
color=wall.color if hasattr(wall, "color") else "Dark"
|
| 442 |
+
)
|
| 443 |
+
loads["walls"] += wall_load
|
| 444 |
+
|
| 445 |
+
# Calculate roof loads
|
| 446 |
+
for roof in roofs:
|
| 447 |
+
roof_load = self.calculate_roof_cooling_load(
|
| 448 |
+
roof=roof,
|
| 449 |
+
outdoor_temp=outdoor_temp,
|
| 450 |
+
indoor_temp=indoor_temp,
|
| 451 |
+
month=month,
|
| 452 |
+
hour=hour,
|
| 453 |
+
latitude=latitude,
|
| 454 |
+
color=roof.color if hasattr(roof, "color") else "Dark"
|
| 455 |
+
)
|
| 456 |
+
loads["roofs"] += roof_load
|
| 457 |
+
|
| 458 |
+
# Calculate floor loads
|
| 459 |
+
for floor in floors:
|
| 460 |
+
floor_load = self.calculate_floor_cooling_load(
|
| 461 |
+
floor=floor,
|
| 462 |
+
ground_temp=ground_temp,
|
| 463 |
+
indoor_temp=indoor_temp
|
| 464 |
+
)
|
| 465 |
+
loads["floors"] += floor_load
|
| 466 |
+
|
| 467 |
+
# Calculate window loads
|
| 468 |
+
for window in windows:
|
| 469 |
+
window_loads = self.calculate_window_cooling_load(
|
| 470 |
+
window=window,
|
| 471 |
+
outdoor_temp=outdoor_temp,
|
| 472 |
+
indoor_temp=indoor_temp,
|
| 473 |
+
month=month,
|
| 474 |
+
hour=hour,
|
| 475 |
+
latitude=f"{latitude}_{month.upper()}",
|
| 476 |
+
shading_coefficient=window.shading_coefficient if hasattr(window, "shading_coefficient") else 1.0
|
| 477 |
+
)
|
| 478 |
+
loads["windows_conduction"] += window_loads["conduction"]
|
| 479 |
+
loads["windows_solar"] += window_loads["solar"]
|
| 480 |
+
|
| 481 |
+
# Calculate door loads
|
| 482 |
+
for door in doors:
|
| 483 |
+
door_load = self.calculate_door_cooling_load(
|
| 484 |
+
door=door,
|
| 485 |
+
outdoor_temp=outdoor_temp,
|
| 486 |
+
indoor_temp=indoor_temp
|
| 487 |
+
)
|
| 488 |
+
loads["doors"] += door_load
|
| 489 |
+
|
| 490 |
+
# Calculate infiltration loads
|
| 491 |
+
if infiltration:
|
| 492 |
+
flow_rate = infiltration.get("flow_rate", 0.0)
|
| 493 |
+
infiltration_loads = self.calculate_infiltration_cooling_load(
|
| 494 |
+
flow_rate=flow_rate,
|
| 495 |
+
outdoor_temp=outdoor_temp,
|
| 496 |
+
indoor_temp=indoor_temp,
|
| 497 |
+
outdoor_rh=outdoor_rh,
|
| 498 |
+
indoor_rh=indoor_rh
|
| 499 |
+
)
|
| 500 |
+
loads["infiltration_sensible"] = infiltration_loads["sensible"]
|
| 501 |
+
loads["infiltration_latent"] = infiltration_loads["latent"]
|
| 502 |
+
|
| 503 |
+
# Calculate ventilation loads
|
| 504 |
+
if ventilation:
|
| 505 |
+
flow_rate = ventilation.get("flow_rate", 0.0)
|
| 506 |
+
ventilation_loads = self.calculate_ventilation_cooling_load(
|
| 507 |
+
flow_rate=flow_rate,
|
| 508 |
+
outdoor_temp=outdoor_temp,
|
| 509 |
+
indoor_temp=indoor_temp,
|
| 510 |
+
outdoor_rh=outdoor_rh,
|
| 511 |
+
indoor_rh=indoor_rh
|
| 512 |
+
)
|
| 513 |
+
loads["ventilation_sensible"] = ventilation_loads["sensible"]
|
| 514 |
+
loads["ventilation_latent"] = ventilation_loads["latent"]
|
| 515 |
+
|
| 516 |
+
# Calculate people loads
|
| 517 |
+
if people:
|
| 518 |
+
num_people = people.get("number", 0)
|
| 519 |
+
activity_level = people.get("activity_level", "Seated/Resting")
|
| 520 |
+
hours_occupancy = people.get("hours_occupancy", "8h")
|
| 521 |
+
|
| 522 |
+
people_loads = self.calculate_people_cooling_load(
|
| 523 |
+
num_people=num_people,
|
| 524 |
+
activity_level=activity_level,
|
| 525 |
+
hours_occupancy=hours_occupancy,
|
| 526 |
+
hour=hour
|
| 527 |
+
)
|
| 528 |
+
loads["people_sensible"] = people_loads["sensible"]
|
| 529 |
+
loads["people_latent"] = people_loads["latent"]
|
| 530 |
+
|
| 531 |
+
# Calculate lights loads
|
| 532 |
+
if lights:
|
| 533 |
+
power = lights.get("power", 0.0)
|
| 534 |
+
use_factor = lights.get("use_factor", 1.0)
|
| 535 |
+
special_allowance = lights.get("special_allowance", 0.0)
|
| 536 |
+
hours_operation = lights.get("hours_operation", "8h")
|
| 537 |
+
|
| 538 |
+
lights_load = self.calculate_lights_cooling_load(
|
| 539 |
+
power=power,
|
| 540 |
+
use_factor=use_factor,
|
| 541 |
+
special_allowance=special_allowance,
|
| 542 |
+
hours_operation=hours_operation,
|
| 543 |
+
hour=hour
|
| 544 |
+
)
|
| 545 |
+
loads["lights"] = lights_load
|
| 546 |
+
|
| 547 |
+
# Calculate equipment loads
|
| 548 |
+
if equipment:
|
| 549 |
+
power = equipment.get("power", 0.0)
|
| 550 |
+
use_factor = equipment.get("use_factor", 1.0)
|
| 551 |
+
radiation_factor = equipment.get("radiation_factor", 0.7)
|
| 552 |
+
hours_operation = equipment.get("hours_operation", "8h")
|
| 553 |
+
|
| 554 |
+
equipment_loads = self.calculate_equipment_cooling_load(
|
| 555 |
+
power=power,
|
| 556 |
+
use_factor=use_factor,
|
| 557 |
+
radiation_factor=radiation_factor,
|
| 558 |
+
hours_operation=hours_operation,
|
| 559 |
+
hour=hour
|
| 560 |
+
)
|
| 561 |
+
loads["equipment_sensible"] = equipment_loads["sensible"]
|
| 562 |
+
loads["equipment_latent"] = equipment_loads["latent"]
|
| 563 |
+
|
| 564 |
+
# Calculate total loads
|
| 565 |
+
loads["total_sensible"] = (
|
| 566 |
+
loads["walls"] + loads["roofs"] + loads["floors"] +
|
| 567 |
+
loads["windows_conduction"] + loads["windows_solar"] +
|
| 568 |
+
loads["doors"] + loads["infiltration_sensible"] +
|
| 569 |
+
loads["ventilation_sensible"] + loads["people_sensible"] +
|
| 570 |
+
loads["lights"] + loads["equipment_sensible"]
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
loads["total_latent"] = (
|
| 574 |
+
loads["infiltration_latent"] + loads["ventilation_latent"] +
|
| 575 |
+
loads["people_latent"] + loads["equipment_latent"]
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
loads["total"] = loads["total_sensible"] + loads["total_latent"]
|
| 579 |
+
|
| 580 |
+
# Store loads for this hour
|
| 581 |
+
hourly_loads[hour] = loads
|
| 582 |
+
|
| 583 |
+
return hourly_loads
|
| 584 |
+
|
| 585 |
+
def calculate_design_cooling_load(self, hourly_loads: Dict[int, Dict[str, float]]) -> Dict[str, float]:
|
| 586 |
+
"""
|
| 587 |
+
Calculate design cooling load based on hourly loads.
|
| 588 |
+
|
| 589 |
+
Args:
|
| 590 |
+
hourly_loads: Dictionary with hourly cooling loads
|
| 591 |
+
|
| 592 |
+
Returns:
|
| 593 |
+
Dictionary with design cooling loads
|
| 594 |
+
"""
|
| 595 |
+
# Find hour with maximum total load
|
| 596 |
+
max_hour = max(hourly_loads.keys(), key=lambda h: hourly_loads[h]["total"])
|
| 597 |
+
|
| 598 |
+
# Get loads for the design hour
|
| 599 |
+
design_loads = hourly_loads[max_hour].copy()
|
| 600 |
+
|
| 601 |
+
# Add design hour information
|
| 602 |
+
design_loads["design_hour"] = max_hour
|
| 603 |
+
|
| 604 |
+
return design_loads
|
| 605 |
+
|
| 606 |
+
def calculate_cooling_load_summary(self, design_loads: Dict[str, float]) -> Dict[str, float]:
|
| 607 |
+
"""
|
| 608 |
+
Calculate cooling load summary.
|
| 609 |
+
|
| 610 |
+
Args:
|
| 611 |
+
design_loads: Dictionary with design cooling loads
|
| 612 |
+
|
| 613 |
+
Returns:
|
| 614 |
+
Dictionary with cooling load summary
|
| 615 |
+
"""
|
| 616 |
+
# Calculate envelope loads
|
| 617 |
+
envelope_loads = (
|
| 618 |
+
design_loads["walls"] + design_loads["roofs"] + design_loads["floors"] +
|
| 619 |
+
design_loads["windows_conduction"] + design_loads["windows_solar"] +
|
| 620 |
+
design_loads["doors"]
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
# Calculate ventilation and infiltration loads
|
| 624 |
+
ventilation_loads = design_loads["ventilation_sensible"] + design_loads["ventilation_latent"]
|
| 625 |
+
infiltration_loads = design_loads["infiltration_sensible"] + design_loads["infiltration_latent"]
|
| 626 |
+
|
| 627 |
+
# Calculate internal loads
|
| 628 |
+
internal_loads = (
|
| 629 |
+
design_loads["people_sensible"] + design_loads["people_latent"] +
|
| 630 |
+
design_loads["lights"] + design_loads["equipment_sensible"] + design_loads["equipment_latent"]
|
| 631 |
+
)
|
| 632 |
+
|
| 633 |
+
# Calculate sensible heat ratio
|
| 634 |
+
shr = design_loads["total_sensible"] / design_loads["total"] if design_loads["total"] > 0 else 1.0
|
| 635 |
+
|
| 636 |
+
# Create summary
|
| 637 |
+
summary = {
|
| 638 |
+
"envelope_loads": envelope_loads,
|
| 639 |
+
"ventilation_loads": ventilation_loads,
|
| 640 |
+
"infiltration_loads": infiltration_loads,
|
| 641 |
+
"internal_loads": internal_loads,
|
| 642 |
+
"total_sensible": design_loads["total_sensible"],
|
| 643 |
+
"total_latent": design_loads["total_latent"],
|
| 644 |
+
"total": design_loads["total"],
|
| 645 |
+
"sensible_heat_ratio": shr,
|
| 646 |
+
"design_hour": design_loads["design_hour"]
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
return summary
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
# Create a singleton instance
|
| 653 |
+
cooling_load_calculator = CoolingLoadCalculator()
|
| 654 |
+
|
| 655 |
+
# Example usage
|
| 656 |
+
if __name__ == "__main__":
|
| 657 |
+
# Create sample building components
|
| 658 |
+
from data.building_components import Wall, Roof, Window, Door, Orientation, ComponentType
|
| 659 |
+
|
| 660 |
+
# Create a sample wall
|
| 661 |
+
wall = Wall(
|
| 662 |
+
id="wall1",
|
| 663 |
+
name="Exterior Wall",
|
| 664 |
+
component_type=ComponentType.WALL,
|
| 665 |
+
u_value=0.5,
|
| 666 |
+
area=20.0,
|
| 667 |
+
orientation=Orientation.SOUTH,
|
| 668 |
+
wall_type="Brick",
|
| 669 |
+
wall_group="B"
|
| 670 |
+
)
|
| 671 |
+
|
| 672 |
+
# Create a sample roof
|
| 673 |
+
roof = Roof(
|
| 674 |
+
id="roof1",
|
| 675 |
+
name="Flat Roof",
|
| 676 |
+
component_type=ComponentType.ROOF,
|
| 677 |
+
u_value=0.3,
|
| 678 |
+
area=50.0,
|
| 679 |
+
orientation=Orientation.HORIZONTAL,
|
| 680 |
+
roof_type="Concrete",
|
| 681 |
+
roof_group="C"
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
# Create a sample window
|
| 685 |
+
window = Window(
|
| 686 |
+
id="window1",
|
| 687 |
+
name="South Window",
|
| 688 |
+
component_type=ComponentType.WINDOW,
|
| 689 |
+
u_value=2.8,
|
| 690 |
+
area=5.0,
|
| 691 |
+
orientation=Orientation.SOUTH,
|
| 692 |
+
shgc=0.7,
|
| 693 |
+
vt=0.8,
|
| 694 |
+
window_type="Double Glazed",
|
| 695 |
+
glazing_layers=2,
|
| 696 |
+
gas_fill="Air",
|
| 697 |
+
low_e_coating=False
|
| 698 |
+
)
|
| 699 |
+
|
| 700 |
+
# Define building components
|
| 701 |
+
building_components = {
|
| 702 |
+
"walls": [wall],
|
| 703 |
+
"roofs": [roof],
|
| 704 |
+
"windows": [window],
|
| 705 |
+
"doors": [],
|
| 706 |
+
"floors": []
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
# Define conditions
|
| 710 |
+
outdoor_conditions = {
|
| 711 |
+
"temperature": 35.0,
|
| 712 |
+
"relative_humidity": 50.0,
|
| 713 |
+
"ground_temperature": 20.0,
|
| 714 |
+
"month": "Jul",
|
| 715 |
+
"latitude": "40N"
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
indoor_conditions = {
|
| 719 |
+
"temperature": 24.0,
|
| 720 |
+
"relative_humidity": 50.0
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
# Define internal loads
|
| 724 |
+
internal_loads = {
|
| 725 |
+
"people": {
|
| 726 |
+
"number": 3,
|
| 727 |
+
"activity_level": "Seated/Resting",
|
| 728 |
+
"hours_occupancy": "8h"
|
| 729 |
+
},
|
| 730 |
+
"lights": {
|
| 731 |
+
"power": 500.0,
|
| 732 |
+
"use_factor": 0.9,
|
| 733 |
+
"special_allowance": 0.1,
|
| 734 |
+
"hours_operation": "8h"
|
| 735 |
+
},
|
| 736 |
+
"equipment": {
|
| 737 |
+
"power": 1000.0,
|
| 738 |
+
"use_factor": 0.7,
|
| 739 |
+
"radiation_factor": 0.7,
|
| 740 |
+
"hours_operation": "8h"
|
| 741 |
+
},
|
| 742 |
+
"infiltration": {
|
| 743 |
+
"flow_rate": 0.05
|
| 744 |
+
},
|
| 745 |
+
"ventilation": {
|
| 746 |
+
"flow_rate": 0.1
|
| 747 |
+
}
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
# Calculate hourly cooling loads
|
| 751 |
+
hourly_loads = cooling_load_calculator.calculate_hourly_cooling_loads(
|
| 752 |
+
building_components=building_components,
|
| 753 |
+
outdoor_conditions=outdoor_conditions,
|
| 754 |
+
indoor_conditions=indoor_conditions,
|
| 755 |
+
internal_loads=internal_loads
|
| 756 |
+
)
|
| 757 |
+
|
| 758 |
+
# Calculate design cooling load
|
| 759 |
+
design_loads = cooling_load_calculator.calculate_design_cooling_load(hourly_loads)
|
| 760 |
+
|
| 761 |
+
# Calculate cooling load summary
|
| 762 |
+
summary = cooling_load_calculator.calculate_cooling_load_summary(design_loads)
|
| 763 |
+
|
| 764 |
+
# Print results
|
| 765 |
+
print("Cooling Load Summary:")
|
| 766 |
+
print(f"Envelope Loads: {summary['envelope_loads']:.2f} W")
|
| 767 |
+
print(f"Ventilation Loads: {summary['ventilation_loads']:.2f} W")
|
| 768 |
+
print(f"Infiltration Loads: {summary['infiltration_loads']:.2f} W")
|
| 769 |
+
print(f"Internal Loads: {summary['internal_loads']:.2f} W")
|
| 770 |
+
print(f"Total Sensible: {summary['total_sensible']:.2f} W")
|
| 771 |
+
print(f"Total Latent: {summary['total_latent']:.2f} W")
|
| 772 |
+
print(f"Total: {summary['total']:.2f} W")
|
| 773 |
+
print(f"Sensible Heat Ratio: {summary['sensible_heat_ratio']:.2f}")
|
| 774 |
+
print(f"Design Hour: {summary['design_hour']}")
|
utils/heat_transfer.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared calculation functions module for HVAC Load Calculator.
|
| 3 |
+
This module implements common heat transfer calculations used in both cooling and heating load calculations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import math
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
# Import data models and utilities
|
| 13 |
+
from data.building_components import Wall, Roof, Floor, Window, Door, Orientation
|
| 14 |
+
from utils.psychrometrics import Psychrometrics
|
| 15 |
+
|
| 16 |
+
# Define constants
|
| 17 |
+
STEFAN_BOLTZMANN_CONSTANT = 5.67e-8 # W/(m²·K⁴)
|
| 18 |
+
SOLAR_CONSTANT = 1367 # W/m²
|
| 19 |
+
EARTH_TILT_ANGLE = 23.45 # degrees
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class HeatTransferCalculations:
|
| 23 |
+
"""Class for shared heat transfer calculations."""
|
| 24 |
+
|
| 25 |
+
@staticmethod
|
| 26 |
+
def conduction_heat_transfer(u_value: float, area: float, delta_t: float) -> float:
|
| 27 |
+
"""
|
| 28 |
+
Calculate conduction heat transfer through a building component.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
u_value: U-value of the component in W/(m²·K)
|
| 32 |
+
area: Area of the component in m²
|
| 33 |
+
delta_t: Temperature difference across the component in K (or °C)
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
Heat transfer rate in W
|
| 37 |
+
"""
|
| 38 |
+
return u_value * area * delta_t
|
| 39 |
+
|
| 40 |
+
@staticmethod
|
| 41 |
+
def convection_heat_transfer(h_c: float, area: float, delta_t: float) -> float:
|
| 42 |
+
"""
|
| 43 |
+
Calculate convection heat transfer.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
h_c: Convection heat transfer coefficient in W/(m²·K)
|
| 47 |
+
area: Surface area in m²
|
| 48 |
+
delta_t: Temperature difference between surface and fluid in K (or °C)
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
Heat transfer rate in W
|
| 52 |
+
"""
|
| 53 |
+
return h_c * area * delta_t
|
| 54 |
+
|
| 55 |
+
@staticmethod
|
| 56 |
+
def radiation_heat_transfer(emissivity: float, area: float, t_surface: float, t_surroundings: float) -> float:
|
| 57 |
+
"""
|
| 58 |
+
Calculate radiation heat transfer.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
emissivity: Surface emissivity (0-1)
|
| 62 |
+
area: Surface area in m²
|
| 63 |
+
t_surface: Surface temperature in K
|
| 64 |
+
t_surroundings: Surroundings temperature in K
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
Heat transfer rate in W
|
| 68 |
+
"""
|
| 69 |
+
return emissivity * STEFAN_BOLTZMANN_CONSTANT * area * (t_surface**4 - t_surroundings**4)
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def infiltration_heat_transfer(flow_rate: float, delta_t: float, density: float = 1.2, specific_heat: float = 1006) -> float:
|
| 73 |
+
"""
|
| 74 |
+
Calculate sensible heat transfer due to infiltration or ventilation.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
flow_rate: Volumetric flow rate in m³/s
|
| 78 |
+
delta_t: Temperature difference between indoor and outdoor air in K (or °C)
|
| 79 |
+
density: Air density in kg/m³ (default: 1.2 kg/m³)
|
| 80 |
+
specific_heat: Specific heat capacity of air in J/(kg·K) (default: 1006 J/(kg·K))
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
Heat transfer rate in W
|
| 84 |
+
"""
|
| 85 |
+
return flow_rate * density * specific_heat * delta_t
|
| 86 |
+
|
| 87 |
+
@staticmethod
|
| 88 |
+
def infiltration_latent_heat_transfer(flow_rate: float, delta_w: float, density: float = 1.2, latent_heat: float = 2501000) -> float:
|
| 89 |
+
"""
|
| 90 |
+
Calculate latent heat transfer due to infiltration or ventilation.
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
flow_rate: Volumetric flow rate in m³/s
|
| 94 |
+
delta_w: Humidity ratio difference between indoor and outdoor air in kg/kg
|
| 95 |
+
density: Air density in kg/m³ (default: 1.2 kg/m³)
|
| 96 |
+
latent_heat: Latent heat of vaporization in J/kg (default: 2501000 J/kg)
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
Heat transfer rate in W
|
| 100 |
+
"""
|
| 101 |
+
return flow_rate * density * latent_heat * delta_w
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def air_exchange_rate_to_flow_rate(ach: float, volume: float) -> float:
|
| 105 |
+
"""
|
| 106 |
+
Convert air changes per hour to volumetric flow rate.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
ach: Air changes per hour (1/h)
|
| 110 |
+
volume: Room or building volume in m³
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
Volumetric flow rate in m³/s
|
| 114 |
+
"""
|
| 115 |
+
return ach * volume / 3600
|
| 116 |
+
|
| 117 |
+
@staticmethod
|
| 118 |
+
def flow_rate_to_air_exchange_rate(flow_rate: float, volume: float) -> float:
|
| 119 |
+
"""
|
| 120 |
+
Convert volumetric flow rate to air changes per hour.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
flow_rate: Volumetric flow rate in m³/s
|
| 124 |
+
volume: Room or building volume in m³
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
Air changes per hour (1/h)
|
| 128 |
+
"""
|
| 129 |
+
return flow_rate * 3600 / volume
|
| 130 |
+
|
| 131 |
+
@staticmethod
|
| 132 |
+
def crack_method_infiltration(crack_length: float, coefficient: float, pressure_difference: float, exponent: float = 0.65) -> float:
|
| 133 |
+
"""
|
| 134 |
+
Calculate infiltration using the crack method.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
crack_length: Length of cracks in m
|
| 138 |
+
coefficient: Flow coefficient in m³/(s·m·Pa^n)
|
| 139 |
+
pressure_difference: Pressure difference in Pa
|
| 140 |
+
exponent: Flow exponent (default: 0.65)
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
Infiltration flow rate in m³/s
|
| 144 |
+
"""
|
| 145 |
+
return coefficient * crack_length * pressure_difference**exponent
|
| 146 |
+
|
| 147 |
+
@staticmethod
|
| 148 |
+
def wind_pressure_difference(wind_speed: float, wind_coefficient: float, density: float = 1.2) -> float:
|
| 149 |
+
"""
|
| 150 |
+
Calculate pressure difference due to wind.
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
wind_speed: Wind speed in m/s
|
| 154 |
+
wind_coefficient: Wind pressure coefficient (dimensionless)
|
| 155 |
+
density: Air density in kg/m³ (default: 1.2 kg/m³)
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
Pressure difference in Pa
|
| 159 |
+
"""
|
| 160 |
+
return 0.5 * density * wind_speed**2 * wind_coefficient
|
| 161 |
+
|
| 162 |
+
@staticmethod
|
| 163 |
+
def stack_pressure_difference(height: float, indoor_temp: float, outdoor_temp: float,
|
| 164 |
+
neutral_plane_height: float = None, gravity: float = 9.81) -> float:
|
| 165 |
+
"""
|
| 166 |
+
Calculate pressure difference due to stack effect.
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
height: Height from reference level in m
|
| 170 |
+
indoor_temp: Indoor temperature in K
|
| 171 |
+
outdoor_temp: Outdoor temperature in K
|
| 172 |
+
neutral_plane_height: Height of neutral pressure plane in m (default: half of height)
|
| 173 |
+
gravity: Acceleration due to gravity in m/s² (default: 9.81 m/s²)
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Pressure difference in Pa
|
| 177 |
+
"""
|
| 178 |
+
if neutral_plane_height is None:
|
| 179 |
+
neutral_plane_height = height / 2
|
| 180 |
+
|
| 181 |
+
# Calculate pressure difference
|
| 182 |
+
return gravity * (height - neutral_plane_height) * (outdoor_temp - indoor_temp) / outdoor_temp
|
| 183 |
+
|
| 184 |
+
@staticmethod
|
| 185 |
+
def combined_pressure_difference(wind_pd: float, stack_pd: float) -> float:
|
| 186 |
+
"""
|
| 187 |
+
Calculate combined pressure difference from wind and stack effects.
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
wind_pd: Pressure difference due to wind in Pa
|
| 191 |
+
stack_pd: Pressure difference due to stack effect in Pa
|
| 192 |
+
|
| 193 |
+
Returns:
|
| 194 |
+
Combined pressure difference in Pa
|
| 195 |
+
"""
|
| 196 |
+
# Simple quadrature combination
|
| 197 |
+
return math.sqrt(wind_pd**2 + stack_pd**2)
|
| 198 |
+
|
| 199 |
+
@staticmethod
|
| 200 |
+
def solar_declination(day_of_year: int) -> float:
|
| 201 |
+
"""
|
| 202 |
+
Calculate solar declination angle.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
day_of_year: Day of the year (1-365)
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
Solar declination angle in degrees
|
| 209 |
+
"""
|
| 210 |
+
return EARTH_TILT_ANGLE * math.sin(2 * math.pi * (day_of_year - 81) / 365)
|
| 211 |
+
|
| 212 |
+
@staticmethod
|
| 213 |
+
def solar_hour_angle(solar_time: float) -> float:
|
| 214 |
+
"""
|
| 215 |
+
Calculate solar hour angle.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
solar_time: Solar time in hours (0-24)
|
| 219 |
+
|
| 220 |
+
Returns:
|
| 221 |
+
Solar hour angle in degrees
|
| 222 |
+
"""
|
| 223 |
+
return 15 * (solar_time - 12)
|
| 224 |
+
|
| 225 |
+
@staticmethod
|
| 226 |
+
def solar_altitude(latitude: float, declination: float, hour_angle: float) -> float:
|
| 227 |
+
"""
|
| 228 |
+
Calculate solar altitude angle.
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
latitude: Latitude in degrees
|
| 232 |
+
declination: Solar declination angle in degrees
|
| 233 |
+
hour_angle: Solar hour angle in degrees
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
Solar altitude angle in degrees
|
| 237 |
+
"""
|
| 238 |
+
# Convert angles to radians
|
| 239 |
+
lat_rad = math.radians(latitude)
|
| 240 |
+
decl_rad = math.radians(declination)
|
| 241 |
+
hour_rad = math.radians(hour_angle)
|
| 242 |
+
|
| 243 |
+
# Calculate solar altitude
|
| 244 |
+
sin_altitude = (math.sin(lat_rad) * math.sin(decl_rad) +
|
| 245 |
+
math.cos(lat_rad) * math.cos(decl_rad) * math.cos(hour_rad))
|
| 246 |
+
|
| 247 |
+
return math.degrees(math.asin(sin_altitude))
|
| 248 |
+
|
| 249 |
+
@staticmethod
|
| 250 |
+
def solar_azimuth(latitude: float, declination: float, hour_angle: float, altitude: float) -> float:
|
| 251 |
+
"""
|
| 252 |
+
Calculate solar azimuth angle.
|
| 253 |
+
|
| 254 |
+
Args:
|
| 255 |
+
latitude: Latitude in degrees
|
| 256 |
+
declination: Solar declination angle in degrees
|
| 257 |
+
hour_angle: Solar hour angle in degrees
|
| 258 |
+
altitude: Solar altitude angle in degrees
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
Solar azimuth angle in degrees (0° = South, positive westward)
|
| 262 |
+
"""
|
| 263 |
+
# Convert angles to radians
|
| 264 |
+
lat_rad = math.radians(latitude)
|
| 265 |
+
decl_rad = math.radians(declination)
|
| 266 |
+
hour_rad = math.radians(hour_angle)
|
| 267 |
+
alt_rad = math.radians(altitude)
|
| 268 |
+
|
| 269 |
+
# Calculate solar azimuth
|
| 270 |
+
cos_azimuth = ((math.sin(decl_rad) * math.cos(lat_rad) -
|
| 271 |
+
math.cos(decl_rad) * math.sin(lat_rad) * math.cos(hour_rad)) /
|
| 272 |
+
math.cos(alt_rad))
|
| 273 |
+
|
| 274 |
+
# Constrain to [-1, 1] to avoid domain errors
|
| 275 |
+
cos_azimuth = max(-1, min(1, cos_azimuth))
|
| 276 |
+
|
| 277 |
+
# Calculate azimuth angle
|
| 278 |
+
azimuth = math.degrees(math.acos(cos_azimuth))
|
| 279 |
+
|
| 280 |
+
# Adjust for morning hours (negative hour angle)
|
| 281 |
+
if hour_angle < 0:
|
| 282 |
+
azimuth = -azimuth
|
| 283 |
+
|
| 284 |
+
return azimuth
|
| 285 |
+
|
| 286 |
+
@staticmethod
|
| 287 |
+
def incident_angle(surface_tilt: float, surface_azimuth: float,
|
| 288 |
+
solar_altitude: float, solar_azimuth: float) -> float:
|
| 289 |
+
"""
|
| 290 |
+
Calculate angle of incidence on a surface.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
surface_tilt: Surface tilt angle from horizontal in degrees (0° = horizontal, 90° = vertical)
|
| 294 |
+
surface_azimuth: Surface azimuth angle in degrees (0° = South, positive westward)
|
| 295 |
+
solar_altitude: Solar altitude angle in degrees
|
| 296 |
+
solar_azimuth: Solar azimuth angle in degrees
|
| 297 |
+
|
| 298 |
+
Returns:
|
| 299 |
+
Incident angle in degrees
|
| 300 |
+
"""
|
| 301 |
+
# Convert angles to radians
|
| 302 |
+
surf_tilt_rad = math.radians(surface_tilt)
|
| 303 |
+
surf_azim_rad = math.radians(surface_azimuth)
|
| 304 |
+
solar_alt_rad = math.radians(solar_altitude)
|
| 305 |
+
solar_azim_rad = math.radians(solar_azimuth)
|
| 306 |
+
|
| 307 |
+
# Calculate incident angle
|
| 308 |
+
cos_incident = (math.sin(solar_alt_rad) * math.cos(surf_tilt_rad) +
|
| 309 |
+
math.cos(solar_alt_rad) * math.sin(surf_tilt_rad) *
|
| 310 |
+
math.cos(solar_azim_rad - surf_azim_rad))
|
| 311 |
+
|
| 312 |
+
# Constrain to [-1, 1] to avoid domain errors
|
| 313 |
+
cos_incident = max(-1, min(1, cos_incident))
|
| 314 |
+
|
| 315 |
+
return math.degrees(math.acos(cos_incident))
|
| 316 |
+
|
| 317 |
+
@staticmethod
|
| 318 |
+
def direct_normal_irradiance(altitude: float, atmospheric_clearness: float = 1.0) -> float:
|
| 319 |
+
"""
|
| 320 |
+
Calculate direct normal irradiance.
|
| 321 |
+
|
| 322 |
+
Args:
|
| 323 |
+
altitude: Solar altitude angle in degrees
|
| 324 |
+
atmospheric_clearness: Atmospheric clearness factor (0-1)
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
Direct normal irradiance in W/m²
|
| 328 |
+
"""
|
| 329 |
+
if altitude <= 0:
|
| 330 |
+
return 0
|
| 331 |
+
|
| 332 |
+
# Simple model based on air mass
|
| 333 |
+
air_mass = 1 / math.sin(math.radians(altitude))
|
| 334 |
+
|
| 335 |
+
# Limit air mass to reasonable values
|
| 336 |
+
air_mass = min(air_mass, 38)
|
| 337 |
+
|
| 338 |
+
# Calculate direct normal irradiance
|
| 339 |
+
dni = SOLAR_CONSTANT * atmospheric_clearness**air_mass
|
| 340 |
+
|
| 341 |
+
return dni
|
| 342 |
+
|
| 343 |
+
@staticmethod
|
| 344 |
+
def diffuse_horizontal_irradiance(dni: float, altitude: float, clearness: float = 0.2) -> float:
|
| 345 |
+
"""
|
| 346 |
+
Calculate diffuse horizontal irradiance.
|
| 347 |
+
|
| 348 |
+
Args:
|
| 349 |
+
dni: Direct normal irradiance in W/m²
|
| 350 |
+
altitude: Solar altitude angle in degrees
|
| 351 |
+
clearness: Sky clearness factor (0-1)
|
| 352 |
+
|
| 353 |
+
Returns:
|
| 354 |
+
Diffuse horizontal irradiance in W/m²
|
| 355 |
+
"""
|
| 356 |
+
if altitude <= 0:
|
| 357 |
+
return 0
|
| 358 |
+
|
| 359 |
+
# Simple model for diffuse irradiance
|
| 360 |
+
return dni * clearness * math.sin(math.radians(altitude))
|
| 361 |
+
|
| 362 |
+
@staticmethod
|
| 363 |
+
def global_horizontal_irradiance(dni: float, dhi: float, altitude: float) -> float:
|
| 364 |
+
"""
|
| 365 |
+
Calculate global horizontal irradiance.
|
| 366 |
+
|
| 367 |
+
Args:
|
| 368 |
+
dni: Direct normal irradiance in W/m²
|
| 369 |
+
dhi: Diffuse horizontal irradiance in W/m²
|
| 370 |
+
altitude: Solar altitude angle in degrees
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
Global horizontal irradiance in W/m²
|
| 374 |
+
"""
|
| 375 |
+
if altitude <= 0:
|
| 376 |
+
return 0
|
| 377 |
+
|
| 378 |
+
# Calculate direct horizontal component
|
| 379 |
+
direct_horizontal = dni * math.sin(math.radians(altitude))
|
| 380 |
+
|
| 381 |
+
# Calculate global horizontal irradiance
|
| 382 |
+
return direct_horizontal + dhi
|
| 383 |
+
|
| 384 |
+
@staticmethod
|
| 385 |
+
def irradiance_on_surface(dni: float, dhi: float, incident_angle: float,
|
| 386 |
+
surface_tilt: float, ground_reflectance: float = 0.2) -> float:
|
| 387 |
+
"""
|
| 388 |
+
Calculate total irradiance on a surface.
|
| 389 |
+
|
| 390 |
+
Args:
|
| 391 |
+
dni: Direct normal irradiance in W/m²
|
| 392 |
+
dhi: Diffuse horizontal irradiance in W/m²
|
| 393 |
+
incident_angle: Incident angle in degrees
|
| 394 |
+
surface_tilt: Surface tilt angle from horizontal in degrees
|
| 395 |
+
ground_reflectance: Ground reflectance (albedo) (0-1)
|
| 396 |
+
|
| 397 |
+
Returns:
|
| 398 |
+
Total irradiance on the surface in W/m²
|
| 399 |
+
"""
|
| 400 |
+
# Convert angles to radians
|
| 401 |
+
incident_rad = math.radians(incident_angle)
|
| 402 |
+
tilt_rad = math.radians(surface_tilt)
|
| 403 |
+
|
| 404 |
+
# Calculate direct component
|
| 405 |
+
if incident_angle < 90:
|
| 406 |
+
direct = dni * math.cos(incident_rad)
|
| 407 |
+
else:
|
| 408 |
+
direct = 0
|
| 409 |
+
|
| 410 |
+
# Calculate diffuse component (simple isotropic model)
|
| 411 |
+
diffuse = dhi * (1 + math.cos(tilt_rad)) / 2
|
| 412 |
+
|
| 413 |
+
# Calculate ground-reflected component
|
| 414 |
+
reflected = (dni * math.sin(math.radians(incident_angle)) + dhi) * ground_reflectance * (1 - math.cos(tilt_rad)) / 2
|
| 415 |
+
|
| 416 |
+
# Calculate total irradiance
|
| 417 |
+
return direct + diffuse + reflected
|
| 418 |
+
|
| 419 |
+
@staticmethod
|
| 420 |
+
def solar_heat_gain(irradiance: float, area: float, shgc: float,
|
| 421 |
+
shading_coefficient: float = 1.0, frame_factor: float = 0.85) -> float:
|
| 422 |
+
"""
|
| 423 |
+
Calculate solar heat gain through a window.
|
| 424 |
+
|
| 425 |
+
Args:
|
| 426 |
+
irradiance: Total irradiance on the window in W/m²
|
| 427 |
+
area: Window area in m²
|
| 428 |
+
shgc: Solar Heat Gain Coefficient (0-1)
|
| 429 |
+
shading_coefficient: External shading coefficient (0-1)
|
| 430 |
+
frame_factor: Ratio of glazing area to total window area (0-1)
|
| 431 |
+
|
| 432 |
+
Returns:
|
| 433 |
+
Solar heat gain in W
|
| 434 |
+
"""
|
| 435 |
+
return irradiance * area * shgc * shading_coefficient * frame_factor
|
| 436 |
+
|
| 437 |
+
@staticmethod
|
| 438 |
+
def internal_gains(occupants: int, lights_power: float, equipment_power: float,
|
| 439 |
+
occupant_sensible_gain: float = 70, occupant_latent_gain: float = 45) -> Dict[str, float]:
|
| 440 |
+
"""
|
| 441 |
+
Calculate internal heat gains.
|
| 442 |
+
|
| 443 |
+
Args:
|
| 444 |
+
occupants: Number of occupants
|
| 445 |
+
lights_power: Lighting power in W
|
| 446 |
+
equipment_power: Equipment power in W
|
| 447 |
+
occupant_sensible_gain: Sensible heat gain per occupant in W (default: 70 W)
|
| 448 |
+
occupant_latent_gain: Latent heat gain per occupant in W (default: 45 W)
|
| 449 |
+
|
| 450 |
+
Returns:
|
| 451 |
+
Dictionary with sensible, latent, and total heat gains in W
|
| 452 |
+
"""
|
| 453 |
+
# Calculate occupant gains
|
| 454 |
+
occupant_sensible = occupants * occupant_sensible_gain
|
| 455 |
+
occupant_latent = occupants * occupant_latent_gain
|
| 456 |
+
|
| 457 |
+
# Calculate total sensible and latent gains
|
| 458 |
+
sensible_gain = occupant_sensible + lights_power + equipment_power
|
| 459 |
+
latent_gain = occupant_latent
|
| 460 |
+
|
| 461 |
+
return {
|
| 462 |
+
"sensible": sensible_gain,
|
| 463 |
+
"latent": latent_gain,
|
| 464 |
+
"total": sensible_gain + latent_gain
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
@staticmethod
|
| 468 |
+
def thermal_mass_effect(mass: float, specific_heat: float, delta_t: float) -> float:
|
| 469 |
+
"""
|
| 470 |
+
Calculate heat storage in thermal mass.
|
| 471 |
+
|
| 472 |
+
Args:
|
| 473 |
+
mass: Mass of the material in kg
|
| 474 |
+
specific_heat: Specific heat capacity in J/(kg·K)
|
| 475 |
+
delta_t: Temperature change in K (or °C)
|
| 476 |
+
|
| 477 |
+
Returns:
|
| 478 |
+
Heat stored in J
|
| 479 |
+
"""
|
| 480 |
+
return mass * specific_heat * delta_t
|
| 481 |
+
|
| 482 |
+
@staticmethod
|
| 483 |
+
def thermal_lag_factor(thermal_mass: float, time_constant: float, time_step: float) -> float:
|
| 484 |
+
"""
|
| 485 |
+
Calculate thermal lag factor for dynamic heat transfer.
|
| 486 |
+
|
| 487 |
+
Args:
|
| 488 |
+
thermal_mass: Thermal mass in J/K
|
| 489 |
+
time_constant: Time constant in hours
|
| 490 |
+
time_step: Time step in hours
|
| 491 |
+
|
| 492 |
+
Returns:
|
| 493 |
+
Thermal lag factor (0-1)
|
| 494 |
+
"""
|
| 495 |
+
return 1 - math.exp(-time_step / time_constant)
|
| 496 |
+
|
| 497 |
+
@staticmethod
|
| 498 |
+
def temperature_swing(heat_gain: float, thermal_mass: float) -> float:
|
| 499 |
+
"""
|
| 500 |
+
Calculate temperature swing due to heat gain and thermal mass.
|
| 501 |
+
|
| 502 |
+
Args:
|
| 503 |
+
heat_gain: Heat gain in J
|
| 504 |
+
thermal_mass: Thermal mass in J/K
|
| 505 |
+
|
| 506 |
+
Returns:
|
| 507 |
+
Temperature swing in K (or °C)
|
| 508 |
+
"""
|
| 509 |
+
return heat_gain / thermal_mass
|
| 510 |
+
|
| 511 |
+
@staticmethod
|
| 512 |
+
def sol_air_temperature(outdoor_temp: float, solar_irradiance: float,
|
| 513 |
+
surface_absorptivity: float, surface_resistance: float) -> float:
|
| 514 |
+
"""
|
| 515 |
+
Calculate sol-air temperature.
|
| 516 |
+
|
| 517 |
+
Args:
|
| 518 |
+
outdoor_temp: Outdoor air temperature in °C
|
| 519 |
+
solar_irradiance: Solar irradiance on the surface in W/m²
|
| 520 |
+
surface_absorptivity: Surface solar absorptivity (0-1)
|
| 521 |
+
surface_resistance: Surface heat transfer resistance in m²·K/W
|
| 522 |
+
|
| 523 |
+
Returns:
|
| 524 |
+
Sol-air temperature in °C
|
| 525 |
+
"""
|
| 526 |
+
return outdoor_temp + solar_irradiance * surface_absorptivity * surface_resistance
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
# Create a singleton instance
|
| 530 |
+
heat_transfer = HeatTransferCalculations()
|
| 531 |
+
|
| 532 |
+
# Example usage
|
| 533 |
+
if __name__ == "__main__":
|
| 534 |
+
# Calculate conduction heat transfer
|
| 535 |
+
q_cond = heat_transfer.conduction_heat_transfer(u_value=0.5, area=10, delta_t=20)
|
| 536 |
+
print(f"Conduction heat transfer: {q_cond:.2f} W")
|
| 537 |
+
|
| 538 |
+
# Calculate infiltration heat transfer
|
| 539 |
+
q_inf = heat_transfer.infiltration_heat_transfer(flow_rate=0.1, delta_t=20)
|
| 540 |
+
print(f"Infiltration heat transfer: {q_inf:.2f} W")
|
| 541 |
+
|
| 542 |
+
# Calculate solar heat gain
|
| 543 |
+
q_solar = heat_transfer.solar_heat_gain(irradiance=500, area=5, shgc=0.7)
|
| 544 |
+
print(f"Solar heat gain: {q_solar:.2f} W")
|
| 545 |
+
|
| 546 |
+
# Calculate internal gains
|
| 547 |
+
gains = heat_transfer.internal_gains(occupants=3, lights_power=200, equipment_power=500)
|
| 548 |
+
print(f"Internal gains - Sensible: {gains['sensible']:.2f} W, Latent: {gains['latent']:.2f} W, Total: {gains['total']:.2f} W")
|
utils/heating_load.py
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Heating load calculation module for HVAC Load Calculator.
|
| 3 |
+
This module implements steady-state methods for calculating heating loads.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import math
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import os
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
from enum import Enum
|
| 13 |
+
|
| 14 |
+
# Import data models and utilities
|
| 15 |
+
from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
|
| 16 |
+
from utils.psychrometrics import Psychrometrics
|
| 17 |
+
from utils.heat_transfer import HeatTransferCalculations
|
| 18 |
+
|
| 19 |
+
# Define paths
|
| 20 |
+
DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class HeatingLoadCalculator:
|
| 24 |
+
"""Class for calculating heating loads using steady-state methods."""
|
| 25 |
+
|
| 26 |
+
def __init__(self):
|
| 27 |
+
"""Initialize heating load calculator."""
|
| 28 |
+
self.heat_transfer = HeatTransferCalculations()
|
| 29 |
+
self.psychrometrics = Psychrometrics()
|
| 30 |
+
|
| 31 |
+
def calculate_wall_heating_load(self, wall: Wall, outdoor_temp: float, indoor_temp: float) -> float:
|
| 32 |
+
"""
|
| 33 |
+
Calculate heating load through a wall using steady-state conduction.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
wall: Wall object
|
| 37 |
+
outdoor_temp: Outdoor temperature in °C
|
| 38 |
+
indoor_temp: Indoor temperature in °C
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
Heating load in W
|
| 42 |
+
"""
|
| 43 |
+
# Get wall properties
|
| 44 |
+
u_value = wall.u_value
|
| 45 |
+
area = wall.area
|
| 46 |
+
|
| 47 |
+
# Calculate heating load
|
| 48 |
+
delta_t = indoor_temp - outdoor_temp
|
| 49 |
+
heating_load = u_value * area * delta_t
|
| 50 |
+
|
| 51 |
+
return heating_load
|
| 52 |
+
|
| 53 |
+
def calculate_roof_heating_load(self, roof: Roof, outdoor_temp: float, indoor_temp: float) -> float:
|
| 54 |
+
"""
|
| 55 |
+
Calculate heating load through a roof using steady-state conduction.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
roof: Roof object
|
| 59 |
+
outdoor_temp: Outdoor temperature in °C
|
| 60 |
+
indoor_temp: Indoor temperature in °C
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Heating load in W
|
| 64 |
+
"""
|
| 65 |
+
# Get roof properties
|
| 66 |
+
u_value = roof.u_value
|
| 67 |
+
area = roof.area
|
| 68 |
+
|
| 69 |
+
# Calculate heating load
|
| 70 |
+
delta_t = indoor_temp - outdoor_temp
|
| 71 |
+
heating_load = u_value * area * delta_t
|
| 72 |
+
|
| 73 |
+
return heating_load
|
| 74 |
+
|
| 75 |
+
def calculate_floor_heating_load(self, floor: Floor, ground_temp: float, indoor_temp: float) -> float:
|
| 76 |
+
"""
|
| 77 |
+
Calculate heating load through a floor.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
floor: Floor object
|
| 81 |
+
ground_temp: Ground or adjacent space temperature in °C
|
| 82 |
+
indoor_temp: Indoor temperature in °C
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Heating load in W
|
| 86 |
+
"""
|
| 87 |
+
# Get floor properties
|
| 88 |
+
u_value = floor.u_value
|
| 89 |
+
area = floor.area
|
| 90 |
+
|
| 91 |
+
# Calculate heating load
|
| 92 |
+
delta_t = indoor_temp - ground_temp
|
| 93 |
+
heating_load = u_value * area * delta_t
|
| 94 |
+
|
| 95 |
+
return heating_load
|
| 96 |
+
|
| 97 |
+
def calculate_window_heating_load(self, window: Window, outdoor_temp: float, indoor_temp: float) -> float:
|
| 98 |
+
"""
|
| 99 |
+
Calculate heating load through a window using steady-state conduction.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
window: Window object
|
| 103 |
+
outdoor_temp: Outdoor temperature in °C
|
| 104 |
+
indoor_temp: Indoor temperature in °C
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Heating load in W
|
| 108 |
+
"""
|
| 109 |
+
# Get window properties
|
| 110 |
+
u_value = window.u_value
|
| 111 |
+
area = window.area
|
| 112 |
+
|
| 113 |
+
# Calculate heating load
|
| 114 |
+
delta_t = indoor_temp - outdoor_temp
|
| 115 |
+
heating_load = u_value * area * delta_t
|
| 116 |
+
|
| 117 |
+
return heating_load
|
| 118 |
+
|
| 119 |
+
def calculate_door_heating_load(self, door: Door, outdoor_temp: float, indoor_temp: float) -> float:
|
| 120 |
+
"""
|
| 121 |
+
Calculate heating load through a door using steady-state conduction.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
door: Door object
|
| 125 |
+
outdoor_temp: Outdoor temperature in °C
|
| 126 |
+
indoor_temp: Indoor temperature in °C
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
Heating load in W
|
| 130 |
+
"""
|
| 131 |
+
# Get door properties
|
| 132 |
+
u_value = door.u_value
|
| 133 |
+
area = door.area
|
| 134 |
+
|
| 135 |
+
# Calculate heating load
|
| 136 |
+
delta_t = indoor_temp - outdoor_temp
|
| 137 |
+
heating_load = u_value * area * delta_t
|
| 138 |
+
|
| 139 |
+
return heating_load
|
| 140 |
+
|
| 141 |
+
def calculate_infiltration_heating_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
|
| 142 |
+
outdoor_rh: float, indoor_rh: float) -> Dict[str, float]:
|
| 143 |
+
"""
|
| 144 |
+
Calculate sensible and latent heating loads due to infiltration.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
flow_rate: Infiltration flow rate in m³/s
|
| 148 |
+
outdoor_temp: Outdoor temperature in °C
|
| 149 |
+
indoor_temp: Indoor temperature in °C
|
| 150 |
+
outdoor_rh: Outdoor relative humidity in %
|
| 151 |
+
indoor_rh: Indoor relative humidity in %
|
| 152 |
+
|
| 153 |
+
Returns:
|
| 154 |
+
Dictionary with sensible, latent, and total heating loads in W
|
| 155 |
+
"""
|
| 156 |
+
# Calculate sensible heating load
|
| 157 |
+
sensible_load = self.heat_transfer.infiltration_heat_transfer(
|
| 158 |
+
flow_rate=flow_rate,
|
| 159 |
+
delta_t=indoor_temp - outdoor_temp
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Calculate humidity ratios
|
| 163 |
+
w_outdoor = self.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh)
|
| 164 |
+
w_indoor = self.psychrometrics.humidity_ratio(indoor_temp, indoor_rh)
|
| 165 |
+
|
| 166 |
+
# Calculate latent heating load (only if indoor humidity is higher than outdoor)
|
| 167 |
+
delta_w = w_indoor - w_outdoor
|
| 168 |
+
if delta_w > 0:
|
| 169 |
+
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
|
| 170 |
+
flow_rate=flow_rate,
|
| 171 |
+
delta_w=delta_w
|
| 172 |
+
)
|
| 173 |
+
else:
|
| 174 |
+
latent_load = 0
|
| 175 |
+
|
| 176 |
+
# Calculate total heating load
|
| 177 |
+
total_load = sensible_load + latent_load
|
| 178 |
+
|
| 179 |
+
return {
|
| 180 |
+
"sensible": sensible_load,
|
| 181 |
+
"latent": latent_load,
|
| 182 |
+
"total": total_load
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
def calculate_ventilation_heating_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
|
| 186 |
+
outdoor_rh: float, indoor_rh: float) -> Dict[str, float]:
|
| 187 |
+
"""
|
| 188 |
+
Calculate sensible and latent heating loads due to ventilation.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
flow_rate: Ventilation flow rate in m³/s
|
| 192 |
+
outdoor_temp: Outdoor temperature in °C
|
| 193 |
+
indoor_temp: Indoor temperature in °C
|
| 194 |
+
outdoor_rh: Outdoor relative humidity in %
|
| 195 |
+
indoor_rh: Indoor relative humidity in %
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
Dictionary with sensible, latent, and total heating loads in W
|
| 199 |
+
"""
|
| 200 |
+
# Ventilation load calculation is the same as infiltration
|
| 201 |
+
return self.calculate_infiltration_heating_load(
|
| 202 |
+
flow_rate=flow_rate,
|
| 203 |
+
outdoor_temp=outdoor_temp,
|
| 204 |
+
indoor_temp=indoor_temp,
|
| 205 |
+
outdoor_rh=outdoor_rh,
|
| 206 |
+
indoor_rh=indoor_rh
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
def calculate_internal_gains_offset(self, people_load: float, lights_load: float,
|
| 210 |
+
equipment_load: float, usage_factor: float = 0.7) -> float:
|
| 211 |
+
"""
|
| 212 |
+
Calculate internal gains offset for heating load.
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
people_load: Heat gain from people in W
|
| 216 |
+
lights_load: Heat gain from lights in W
|
| 217 |
+
equipment_load: Heat gain from equipment in W
|
| 218 |
+
usage_factor: Usage factor for internal gains (0-1)
|
| 219 |
+
|
| 220 |
+
Returns:
|
| 221 |
+
Internal gains offset in W
|
| 222 |
+
"""
|
| 223 |
+
# Calculate total internal gains
|
| 224 |
+
total_gains = people_load + lights_load + equipment_load
|
| 225 |
+
|
| 226 |
+
# Apply usage factor
|
| 227 |
+
offset = total_gains * usage_factor
|
| 228 |
+
|
| 229 |
+
return offset
|
| 230 |
+
|
| 231 |
+
def calculate_design_heating_load(self, building_components: Dict[str, List[Any]],
|
| 232 |
+
outdoor_conditions: Dict[str, Any],
|
| 233 |
+
indoor_conditions: Dict[str, Any],
|
| 234 |
+
internal_loads: Dict[str, Any],
|
| 235 |
+
safety_factor: float = 1.15) -> Dict[str, float]:
|
| 236 |
+
"""
|
| 237 |
+
Calculate design heating load for a building.
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
building_components: Dictionary with lists of building components
|
| 241 |
+
outdoor_conditions: Dictionary with outdoor conditions
|
| 242 |
+
indoor_conditions: Dictionary with indoor conditions
|
| 243 |
+
internal_loads: Dictionary with internal loads
|
| 244 |
+
safety_factor: Safety factor for heating load (default: 1.15)
|
| 245 |
+
|
| 246 |
+
Returns:
|
| 247 |
+
Dictionary with design heating loads
|
| 248 |
+
"""
|
| 249 |
+
# Extract building components
|
| 250 |
+
walls = building_components.get("walls", [])
|
| 251 |
+
roofs = building_components.get("roofs", [])
|
| 252 |
+
floors = building_components.get("floors", [])
|
| 253 |
+
windows = building_components.get("windows", [])
|
| 254 |
+
doors = building_components.get("doors", [])
|
| 255 |
+
|
| 256 |
+
# Extract outdoor conditions
|
| 257 |
+
outdoor_temp = outdoor_conditions.get("design_temperature", -10.0)
|
| 258 |
+
outdoor_rh = outdoor_conditions.get("design_relative_humidity", 80.0)
|
| 259 |
+
ground_temp = outdoor_conditions.get("ground_temperature", 10.0)
|
| 260 |
+
|
| 261 |
+
# Extract indoor conditions
|
| 262 |
+
indoor_temp = indoor_conditions.get("temperature", 21.0)
|
| 263 |
+
indoor_rh = indoor_conditions.get("relative_humidity", 40.0)
|
| 264 |
+
|
| 265 |
+
# Extract internal loads
|
| 266 |
+
people = internal_loads.get("people", {})
|
| 267 |
+
lights = internal_loads.get("lights", {})
|
| 268 |
+
equipment = internal_loads.get("equipment", {})
|
| 269 |
+
infiltration = internal_loads.get("infiltration", {})
|
| 270 |
+
ventilation = internal_loads.get("ventilation", {})
|
| 271 |
+
|
| 272 |
+
# Initialize loads
|
| 273 |
+
loads = {
|
| 274 |
+
"walls": 0,
|
| 275 |
+
"roofs": 0,
|
| 276 |
+
"floors": 0,
|
| 277 |
+
"windows": 0,
|
| 278 |
+
"doors": 0,
|
| 279 |
+
"infiltration_sensible": 0,
|
| 280 |
+
"infiltration_latent": 0,
|
| 281 |
+
"ventilation_sensible": 0,
|
| 282 |
+
"ventilation_latent": 0,
|
| 283 |
+
"internal_gains_offset": 0,
|
| 284 |
+
"subtotal": 0,
|
| 285 |
+
"safety_factor": safety_factor,
|
| 286 |
+
"total": 0
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
# Calculate wall loads
|
| 290 |
+
for wall in walls:
|
| 291 |
+
wall_load = self.calculate_wall_heating_load(
|
| 292 |
+
wall=wall,
|
| 293 |
+
outdoor_temp=outdoor_temp,
|
| 294 |
+
indoor_temp=indoor_temp
|
| 295 |
+
)
|
| 296 |
+
loads["walls"] += wall_load
|
| 297 |
+
|
| 298 |
+
# Calculate roof loads
|
| 299 |
+
for roof in roofs:
|
| 300 |
+
roof_load = self.calculate_roof_heating_load(
|
| 301 |
+
roof=roof,
|
| 302 |
+
outdoor_temp=outdoor_temp,
|
| 303 |
+
indoor_temp=indoor_temp
|
| 304 |
+
)
|
| 305 |
+
loads["roofs"] += roof_load
|
| 306 |
+
|
| 307 |
+
# Calculate floor loads
|
| 308 |
+
for floor in floors:
|
| 309 |
+
floor_load = self.calculate_floor_heating_load(
|
| 310 |
+
floor=floor,
|
| 311 |
+
ground_temp=ground_temp,
|
| 312 |
+
indoor_temp=indoor_temp
|
| 313 |
+
)
|
| 314 |
+
loads["floors"] += floor_load
|
| 315 |
+
|
| 316 |
+
# Calculate window loads
|
| 317 |
+
for window in windows:
|
| 318 |
+
window_load = self.calculate_window_heating_load(
|
| 319 |
+
window=window,
|
| 320 |
+
outdoor_temp=outdoor_temp,
|
| 321 |
+
indoor_temp=indoor_temp
|
| 322 |
+
)
|
| 323 |
+
loads["windows"] += window_load
|
| 324 |
+
|
| 325 |
+
# Calculate door loads
|
| 326 |
+
for door in doors:
|
| 327 |
+
door_load = self.calculate_door_heating_load(
|
| 328 |
+
door=door,
|
| 329 |
+
outdoor_temp=outdoor_temp,
|
| 330 |
+
indoor_temp=indoor_temp
|
| 331 |
+
)
|
| 332 |
+
loads["doors"] += door_load
|
| 333 |
+
|
| 334 |
+
# Calculate infiltration loads
|
| 335 |
+
if infiltration:
|
| 336 |
+
flow_rate = infiltration.get("flow_rate", 0.0)
|
| 337 |
+
infiltration_loads = self.calculate_infiltration_heating_load(
|
| 338 |
+
flow_rate=flow_rate,
|
| 339 |
+
outdoor_temp=outdoor_temp,
|
| 340 |
+
indoor_temp=indoor_temp,
|
| 341 |
+
outdoor_rh=outdoor_rh,
|
| 342 |
+
indoor_rh=indoor_rh
|
| 343 |
+
)
|
| 344 |
+
loads["infiltration_sensible"] = infiltration_loads["sensible"]
|
| 345 |
+
loads["infiltration_latent"] = infiltration_loads["latent"]
|
| 346 |
+
|
| 347 |
+
# Calculate ventilation loads
|
| 348 |
+
if ventilation:
|
| 349 |
+
flow_rate = ventilation.get("flow_rate", 0.0)
|
| 350 |
+
ventilation_loads = self.calculate_ventilation_heating_load(
|
| 351 |
+
flow_rate=flow_rate,
|
| 352 |
+
outdoor_temp=outdoor_temp,
|
| 353 |
+
indoor_temp=indoor_temp,
|
| 354 |
+
outdoor_rh=outdoor_rh,
|
| 355 |
+
indoor_rh=indoor_rh
|
| 356 |
+
)
|
| 357 |
+
loads["ventilation_sensible"] = ventilation_loads["sensible"]
|
| 358 |
+
loads["ventilation_latent"] = ventilation_loads["latent"]
|
| 359 |
+
|
| 360 |
+
# Calculate internal gains offset
|
| 361 |
+
people_load = people.get("number", 0) * people.get("sensible_gain", 70)
|
| 362 |
+
lights_load = lights.get("power", 0) * lights.get("use_factor", 1.0)
|
| 363 |
+
equipment_load = equipment.get("power", 0) * equipment.get("use_factor", 1.0)
|
| 364 |
+
|
| 365 |
+
loads["internal_gains_offset"] = self.calculate_internal_gains_offset(
|
| 366 |
+
people_load=people_load,
|
| 367 |
+
lights_load=lights_load,
|
| 368 |
+
equipment_load=equipment_load,
|
| 369 |
+
usage_factor=internal_loads.get("usage_factor", 0.7)
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
# Calculate subtotal
|
| 373 |
+
loads["subtotal"] = (
|
| 374 |
+
loads["walls"] + loads["roofs"] + loads["floors"] +
|
| 375 |
+
loads["windows"] + loads["doors"] +
|
| 376 |
+
loads["infiltration_sensible"] + loads["infiltration_latent"] +
|
| 377 |
+
loads["ventilation_sensible"] + loads["ventilation_latent"] -
|
| 378 |
+
loads["internal_gains_offset"]
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
# Apply safety factor
|
| 382 |
+
loads["total"] = loads["subtotal"] * safety_factor
|
| 383 |
+
|
| 384 |
+
return loads
|
| 385 |
+
|
| 386 |
+
def calculate_heating_load_summary(self, design_loads: Dict[str, float]) -> Dict[str, float]:
|
| 387 |
+
"""
|
| 388 |
+
Calculate heating load summary.
|
| 389 |
+
|
| 390 |
+
Args:
|
| 391 |
+
design_loads: Dictionary with design heating loads
|
| 392 |
+
|
| 393 |
+
Returns:
|
| 394 |
+
Dictionary with heating load summary
|
| 395 |
+
"""
|
| 396 |
+
# Calculate envelope loads
|
| 397 |
+
envelope_loads = (
|
| 398 |
+
design_loads["walls"] + design_loads["roofs"] + design_loads["floors"] +
|
| 399 |
+
design_loads["windows"] + design_loads["doors"]
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
# Calculate ventilation and infiltration loads
|
| 403 |
+
ventilation_loads = design_loads["ventilation_sensible"] + design_loads["ventilation_latent"]
|
| 404 |
+
infiltration_loads = design_loads["infiltration_sensible"] + design_loads["infiltration_latent"]
|
| 405 |
+
|
| 406 |
+
# Create summary
|
| 407 |
+
summary = {
|
| 408 |
+
"envelope_loads": envelope_loads,
|
| 409 |
+
"ventilation_loads": ventilation_loads,
|
| 410 |
+
"infiltration_loads": infiltration_loads,
|
| 411 |
+
"internal_gains_offset": design_loads["internal_gains_offset"],
|
| 412 |
+
"subtotal": design_loads["subtotal"],
|
| 413 |
+
"safety_factor": design_loads["safety_factor"],
|
| 414 |
+
"total": design_loads["total"]
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
return summary
|
| 418 |
+
|
| 419 |
+
def calculate_monthly_heating_loads(self, design_loads: Dict[str, float],
|
| 420 |
+
monthly_temps: Dict[str, float],
|
| 421 |
+
design_temp: float, indoor_temp: float) -> Dict[str, float]:
|
| 422 |
+
"""
|
| 423 |
+
Calculate monthly heating loads based on design load and monthly temperatures.
|
| 424 |
+
|
| 425 |
+
Args:
|
| 426 |
+
design_loads: Dictionary with design heating loads
|
| 427 |
+
monthly_temps: Dictionary with monthly average temperatures
|
| 428 |
+
design_temp: Design outdoor temperature in °C
|
| 429 |
+
indoor_temp: Indoor temperature in °C
|
| 430 |
+
|
| 431 |
+
Returns:
|
| 432 |
+
Dictionary with monthly heating loads
|
| 433 |
+
"""
|
| 434 |
+
# Calculate design temperature difference
|
| 435 |
+
design_delta_t = indoor_temp - design_temp
|
| 436 |
+
|
| 437 |
+
# Calculate monthly loads
|
| 438 |
+
monthly_loads = {}
|
| 439 |
+
|
| 440 |
+
for month, temp in monthly_temps.items():
|
| 441 |
+
# Calculate temperature difference for this month
|
| 442 |
+
delta_t = indoor_temp - temp
|
| 443 |
+
|
| 444 |
+
# Skip months where outdoor temperature is higher than indoor
|
| 445 |
+
if delta_t <= 0:
|
| 446 |
+
monthly_loads[month] = 0
|
| 447 |
+
continue
|
| 448 |
+
|
| 449 |
+
# Calculate load ratio based on temperature difference
|
| 450 |
+
load_ratio = delta_t / design_delta_t
|
| 451 |
+
|
| 452 |
+
# Calculate monthly load
|
| 453 |
+
monthly_loads[month] = design_loads["total"] * load_ratio
|
| 454 |
+
|
| 455 |
+
return monthly_loads
|
| 456 |
+
|
| 457 |
+
def calculate_heating_degree_days(self, monthly_temps: Dict[str, float],
|
| 458 |
+
base_temp: float = 18.0) -> Dict[str, float]:
|
| 459 |
+
"""
|
| 460 |
+
Calculate heating degree days for each month.
|
| 461 |
+
|
| 462 |
+
Args:
|
| 463 |
+
monthly_temps: Dictionary with monthly average temperatures
|
| 464 |
+
base_temp: Base temperature for degree days in °C (default: 18°C)
|
| 465 |
+
|
| 466 |
+
Returns:
|
| 467 |
+
Dictionary with monthly heating degree days
|
| 468 |
+
"""
|
| 469 |
+
# Calculate monthly heating degree days
|
| 470 |
+
monthly_hdds = {}
|
| 471 |
+
|
| 472 |
+
for month, temp in monthly_temps.items():
|
| 473 |
+
# Calculate degree days
|
| 474 |
+
days_in_month = 30 # Approximate
|
| 475 |
+
if month in ["Apr", "Jun", "Sep", "Nov"]:
|
| 476 |
+
days_in_month = 30
|
| 477 |
+
elif month == "Feb":
|
| 478 |
+
days_in_month = 28 # Ignore leap years for simplicity
|
| 479 |
+
else:
|
| 480 |
+
days_in_month = 31
|
| 481 |
+
|
| 482 |
+
# Calculate daily degree days
|
| 483 |
+
daily_hdd = max(0, base_temp - temp)
|
| 484 |
+
|
| 485 |
+
# Calculate monthly degree days
|
| 486 |
+
monthly_hdds[month] = daily_hdd * days_in_month
|
| 487 |
+
|
| 488 |
+
return monthly_hdds
|
| 489 |
+
|
| 490 |
+
def calculate_annual_heating_energy(self, monthly_loads: Dict[str, float],
|
| 491 |
+
heating_system_efficiency: float = 0.8) -> Dict[str, float]:
|
| 492 |
+
"""
|
| 493 |
+
Calculate annual heating energy consumption.
|
| 494 |
+
|
| 495 |
+
Args:
|
| 496 |
+
monthly_loads: Dictionary with monthly heating loads in W
|
| 497 |
+
heating_system_efficiency: Heating system efficiency (0-1)
|
| 498 |
+
|
| 499 |
+
Returns:
|
| 500 |
+
Dictionary with monthly and annual heating energy in kWh
|
| 501 |
+
"""
|
| 502 |
+
# Calculate monthly energy consumption
|
| 503 |
+
monthly_energy = {}
|
| 504 |
+
annual_energy = 0
|
| 505 |
+
|
| 506 |
+
for month, load in monthly_loads.items():
|
| 507 |
+
# Calculate hours in month
|
| 508 |
+
hours_in_month = 24 * 30 # Approximate
|
| 509 |
+
if month in ["Apr", "Jun", "Sep", "Nov"]:
|
| 510 |
+
hours_in_month = 24 * 30
|
| 511 |
+
elif month == "Feb":
|
| 512 |
+
hours_in_month = 24 * 28 # Ignore leap years for simplicity
|
| 513 |
+
else:
|
| 514 |
+
hours_in_month = 24 * 31
|
| 515 |
+
|
| 516 |
+
# Calculate energy in kWh
|
| 517 |
+
energy = load * hours_in_month / 1000 / heating_system_efficiency
|
| 518 |
+
|
| 519 |
+
# Store monthly energy
|
| 520 |
+
monthly_energy[month] = energy
|
| 521 |
+
|
| 522 |
+
# Add to annual total
|
| 523 |
+
annual_energy += energy
|
| 524 |
+
|
| 525 |
+
# Add annual total to results
|
| 526 |
+
monthly_energy["annual"] = annual_energy
|
| 527 |
+
|
| 528 |
+
return monthly_energy
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
# Create a singleton instance
|
| 532 |
+
heating_load_calculator = HeatingLoadCalculator()
|
| 533 |
+
|
| 534 |
+
# Example usage
|
| 535 |
+
if __name__ == "__main__":
|
| 536 |
+
# Create sample building components
|
| 537 |
+
from data.building_components import Wall, Roof, Window, Door, Orientation, ComponentType
|
| 538 |
+
|
| 539 |
+
# Create a sample wall
|
| 540 |
+
wall = Wall(
|
| 541 |
+
id="wall1",
|
| 542 |
+
name="Exterior Wall",
|
| 543 |
+
component_type=ComponentType.WALL,
|
| 544 |
+
u_value=0.5,
|
| 545 |
+
area=20.0,
|
| 546 |
+
orientation=Orientation.NORTH,
|
| 547 |
+
wall_type="Brick",
|
| 548 |
+
wall_group="B"
|
| 549 |
+
)
|
| 550 |
+
|
| 551 |
+
# Create a sample roof
|
| 552 |
+
roof = Roof(
|
| 553 |
+
id="roof1",
|
| 554 |
+
name="Flat Roof",
|
| 555 |
+
component_type=ComponentType.ROOF,
|
| 556 |
+
u_value=0.3,
|
| 557 |
+
area=50.0,
|
| 558 |
+
orientation=Orientation.HORIZONTAL,
|
| 559 |
+
roof_type="Concrete",
|
| 560 |
+
roof_group="C"
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
# Create a sample window
|
| 564 |
+
window = Window(
|
| 565 |
+
id="window1",
|
| 566 |
+
name="North Window",
|
| 567 |
+
component_type=ComponentType.WINDOW,
|
| 568 |
+
u_value=2.8,
|
| 569 |
+
area=5.0,
|
| 570 |
+
orientation=Orientation.NORTH,
|
| 571 |
+
shgc=0.7,
|
| 572 |
+
vt=0.8,
|
| 573 |
+
window_type="Double Glazed",
|
| 574 |
+
glazing_layers=2,
|
| 575 |
+
gas_fill="Air",
|
| 576 |
+
low_e_coating=False
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Define building components
|
| 580 |
+
building_components = {
|
| 581 |
+
"walls": [wall],
|
| 582 |
+
"roofs": [roof],
|
| 583 |
+
"windows": [window],
|
| 584 |
+
"doors": [],
|
| 585 |
+
"floors": []
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
# Define conditions
|
| 589 |
+
outdoor_conditions = {
|
| 590 |
+
"design_temperature": -10.0,
|
| 591 |
+
"design_relative_humidity": 80.0,
|
| 592 |
+
"ground_temperature": 10.0
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
indoor_conditions = {
|
| 596 |
+
"temperature": 21.0,
|
| 597 |
+
"relative_humidity": 40.0
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
# Define internal loads
|
| 601 |
+
internal_loads = {
|
| 602 |
+
"people": {
|
| 603 |
+
"number": 3,
|
| 604 |
+
"sensible_gain": 70
|
| 605 |
+
},
|
| 606 |
+
"lights": {
|
| 607 |
+
"power": 500.0,
|
| 608 |
+
"use_factor": 0.9
|
| 609 |
+
},
|
| 610 |
+
"equipment": {
|
| 611 |
+
"power": 1000.0,
|
| 612 |
+
"use_factor": 0.7
|
| 613 |
+
},
|
| 614 |
+
"infiltration": {
|
| 615 |
+
"flow_rate": 0.05
|
| 616 |
+
},
|
| 617 |
+
"ventilation": {
|
| 618 |
+
"flow_rate": 0.1
|
| 619 |
+
},
|
| 620 |
+
"usage_factor": 0.7
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
# Calculate design heating load
|
| 624 |
+
design_loads = heating_load_calculator.calculate_design_heating_load(
|
| 625 |
+
building_components=building_components,
|
| 626 |
+
outdoor_conditions=outdoor_conditions,
|
| 627 |
+
indoor_conditions=indoor_conditions,
|
| 628 |
+
internal_loads=internal_loads
|
| 629 |
+
)
|
| 630 |
+
|
| 631 |
+
# Calculate heating load summary
|
| 632 |
+
summary = heating_load_calculator.calculate_heating_load_summary(design_loads)
|
| 633 |
+
|
| 634 |
+
# Define monthly temperatures
|
| 635 |
+
monthly_temps = {
|
| 636 |
+
"Jan": -5.0,
|
| 637 |
+
"Feb": -3.0,
|
| 638 |
+
"Mar": 2.0,
|
| 639 |
+
"Apr": 8.0,
|
| 640 |
+
"May": 14.0,
|
| 641 |
+
"Jun": 18.0,
|
| 642 |
+
"Jul": 21.0,
|
| 643 |
+
"Aug": 20.0,
|
| 644 |
+
"Sep": 16.0,
|
| 645 |
+
"Oct": 10.0,
|
| 646 |
+
"Nov": 4.0,
|
| 647 |
+
"Dec": -2.0
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
# Calculate monthly heating loads
|
| 651 |
+
monthly_loads = heating_load_calculator.calculate_monthly_heating_loads(
|
| 652 |
+
design_loads=design_loads,
|
| 653 |
+
monthly_temps=monthly_temps,
|
| 654 |
+
design_temp=outdoor_conditions["design_temperature"],
|
| 655 |
+
indoor_temp=indoor_conditions["temperature"]
|
| 656 |
+
)
|
| 657 |
+
|
| 658 |
+
# Calculate heating degree days
|
| 659 |
+
hdds = heating_load_calculator.calculate_heating_degree_days(monthly_temps)
|
| 660 |
+
|
| 661 |
+
# Calculate annual heating energy
|
| 662 |
+
energy = heating_load_calculator.calculate_annual_heating_energy(monthly_loads)
|
| 663 |
+
|
| 664 |
+
# Print results
|
| 665 |
+
print("Heating Load Summary:")
|
| 666 |
+
print(f"Envelope Loads: {summary['envelope_loads']:.2f} W")
|
| 667 |
+
print(f"Ventilation Loads: {summary['ventilation_loads']:.2f} W")
|
| 668 |
+
print(f"Infiltration Loads: {summary['infiltration_loads']:.2f} W")
|
| 669 |
+
print(f"Internal Gains Offset: {summary['internal_gains_offset']:.2f} W")
|
| 670 |
+
print(f"Subtotal: {summary['subtotal']:.2f} W")
|
| 671 |
+
print(f"Safety Factor: {summary['safety_factor']:.2f}")
|
| 672 |
+
print(f"Total: {summary['total']:.2f} W")
|
| 673 |
+
|
| 674 |
+
print("\nMonthly Heating Loads:")
|
| 675 |
+
for month, load in monthly_loads.items():
|
| 676 |
+
print(f"{month}: {load:.2f} W")
|
| 677 |
+
|
| 678 |
+
print("\nHeating Degree Days:")
|
| 679 |
+
for month, hdd in hdds.items():
|
| 680 |
+
print(f"{month}: {hdd:.2f} HDD")
|
| 681 |
+
|
| 682 |
+
print("\nAnnual Heating Energy:")
|
| 683 |
+
print(f"Total: {energy['annual']:.2f} kWh")
|
utils/psychrometric_visualization.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Psychrometric visualization module for HVAC Load Calculator.
|
| 3 |
+
This module provides visualization tools for psychrometric processes.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
import plotly.express as px
|
| 11 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 12 |
+
import math
|
| 13 |
+
|
| 14 |
+
# Import psychrometrics module
|
| 15 |
+
from utils.psychrometrics import Psychrometrics
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PsychrometricVisualization:
|
| 19 |
+
"""Class for psychrometric visualization."""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
"""Initialize psychrometric visualization."""
|
| 23 |
+
self.psychrometrics = Psychrometrics()
|
| 24 |
+
|
| 25 |
+
# Define temperature and humidity ratio ranges for chart
|
| 26 |
+
self.temp_min = -10
|
| 27 |
+
self.temp_max = 50
|
| 28 |
+
self.w_min = 0
|
| 29 |
+
self.w_max = 0.030
|
| 30 |
+
|
| 31 |
+
# Define standard atmospheric pressure
|
| 32 |
+
self.pressure = 101325 # Pa
|
| 33 |
+
|
| 34 |
+
def create_psychrometric_chart(self, points: Optional[List[Dict[str, Any]]] = None,
|
| 35 |
+
processes: Optional[List[Dict[str, Any]]] = None,
|
| 36 |
+
comfort_zone: Optional[Dict[str, Any]] = None) -> go.Figure:
|
| 37 |
+
"""
|
| 38 |
+
Create an interactive psychrometric chart.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
points: List of points to plot on the chart
|
| 42 |
+
processes: List of processes to plot on the chart
|
| 43 |
+
comfort_zone: Dictionary with comfort zone parameters
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Plotly figure with psychrometric chart
|
| 47 |
+
"""
|
| 48 |
+
# Create figure
|
| 49 |
+
fig = go.Figure()
|
| 50 |
+
|
| 51 |
+
# Generate temperature and humidity ratio grids
|
| 52 |
+
temp_range = np.linspace(self.temp_min, self.temp_max, 100)
|
| 53 |
+
w_range = np.linspace(self.w_min, self.w_max, 100)
|
| 54 |
+
|
| 55 |
+
# Generate saturation curve
|
| 56 |
+
sat_temps = np.linspace(self.temp_min, self.temp_max, 100)
|
| 57 |
+
sat_w = [self.psychrometrics.humidity_ratio(t, 100, self.pressure) for t in sat_temps]
|
| 58 |
+
|
| 59 |
+
# Plot saturation curve
|
| 60 |
+
fig.add_trace(go.Scatter(
|
| 61 |
+
x=sat_temps,
|
| 62 |
+
y=sat_w,
|
| 63 |
+
mode="lines",
|
| 64 |
+
line=dict(color="blue", width=2),
|
| 65 |
+
name="Saturation Curve"
|
| 66 |
+
))
|
| 67 |
+
|
| 68 |
+
# Generate constant RH curves
|
| 69 |
+
rh_values = [10, 20, 30, 40, 50, 60, 70, 80, 90]
|
| 70 |
+
|
| 71 |
+
for rh in rh_values:
|
| 72 |
+
rh_temps = np.linspace(self.temp_min, self.temp_max, 50)
|
| 73 |
+
rh_w = [self.psychrometrics.humidity_ratio(t, rh, self.pressure) for t in rh_temps]
|
| 74 |
+
|
| 75 |
+
# Filter out values above saturation
|
| 76 |
+
valid_points = [(t, w) for t, w in zip(rh_temps, rh_w) if w <= self.psychrometrics.humidity_ratio(t, 100, self.pressure)]
|
| 77 |
+
|
| 78 |
+
if valid_points:
|
| 79 |
+
valid_temps, valid_w = zip(*valid_points)
|
| 80 |
+
|
| 81 |
+
fig.add_trace(go.Scatter(
|
| 82 |
+
x=valid_temps,
|
| 83 |
+
y=valid_w,
|
| 84 |
+
mode="lines",
|
| 85 |
+
line=dict(color="rgba(0, 0, 255, 0.3)", width=1, dash="dot"),
|
| 86 |
+
name=f"{rh}% RH",
|
| 87 |
+
hoverinfo="name"
|
| 88 |
+
))
|
| 89 |
+
|
| 90 |
+
# Generate constant wet-bulb temperature lines
|
| 91 |
+
wb_values = np.arange(0, 35, 5)
|
| 92 |
+
|
| 93 |
+
for wb in wb_values:
|
| 94 |
+
wb_temps = np.linspace(wb, self.temp_max, 50)
|
| 95 |
+
wb_points = []
|
| 96 |
+
|
| 97 |
+
for t in wb_temps:
|
| 98 |
+
# Binary search to find humidity ratio for this wet-bulb temperature
|
| 99 |
+
w_low = 0
|
| 100 |
+
w_high = self.psychrometrics.humidity_ratio(t, 100, self.pressure)
|
| 101 |
+
|
| 102 |
+
for _ in range(10): # 10 iterations should be enough for good precision
|
| 103 |
+
w_mid = (w_low + w_high) / 2
|
| 104 |
+
rh = self.psychrometrics.relative_humidity(t, w_mid, self.pressure)
|
| 105 |
+
t_wb_calc = self.psychrometrics.wet_bulb_temperature(t, rh, self.pressure)
|
| 106 |
+
|
| 107 |
+
if abs(t_wb_calc - wb) < 0.1:
|
| 108 |
+
wb_points.append((t, w_mid))
|
| 109 |
+
break
|
| 110 |
+
elif t_wb_calc < wb:
|
| 111 |
+
w_low = w_mid
|
| 112 |
+
else:
|
| 113 |
+
w_high = w_mid
|
| 114 |
+
|
| 115 |
+
if wb_points:
|
| 116 |
+
wb_temps, wb_w = zip(*wb_points)
|
| 117 |
+
|
| 118 |
+
fig.add_trace(go.Scatter(
|
| 119 |
+
x=wb_temps,
|
| 120 |
+
y=wb_w,
|
| 121 |
+
mode="lines",
|
| 122 |
+
line=dict(color="rgba(0, 128, 0, 0.3)", width=1, dash="dash"),
|
| 123 |
+
name=f"{wb}°C WB",
|
| 124 |
+
hoverinfo="name"
|
| 125 |
+
))
|
| 126 |
+
|
| 127 |
+
# Generate constant enthalpy lines
|
| 128 |
+
h_values = np.arange(0, 100, 10) * 1000 # kJ/kg to J/kg
|
| 129 |
+
|
| 130 |
+
for h in h_values:
|
| 131 |
+
h_temps = np.linspace(self.temp_min, self.temp_max, 50)
|
| 132 |
+
h_points = []
|
| 133 |
+
|
| 134 |
+
for t in h_temps:
|
| 135 |
+
# Calculate humidity ratio for this enthalpy
|
| 136 |
+
w = self.psychrometrics.find_humidity_ratio_for_enthalpy(t, h)
|
| 137 |
+
|
| 138 |
+
if 0 <= w <= self.psychrometrics.humidity_ratio(t, 100, self.pressure):
|
| 139 |
+
h_points.append((t, w))
|
| 140 |
+
|
| 141 |
+
if h_points:
|
| 142 |
+
h_temps, h_w = zip(*h_points)
|
| 143 |
+
|
| 144 |
+
fig.add_trace(go.Scatter(
|
| 145 |
+
x=h_temps,
|
| 146 |
+
y=h_w,
|
| 147 |
+
mode="lines",
|
| 148 |
+
line=dict(color="rgba(255, 0, 0, 0.3)", width=1, dash="dashdot"),
|
| 149 |
+
name=f"{h/1000:.0f} kJ/kg",
|
| 150 |
+
hoverinfo="name"
|
| 151 |
+
))
|
| 152 |
+
|
| 153 |
+
# Generate constant specific volume lines
|
| 154 |
+
v_values = [0.8, 0.85, 0.9, 0.95, 1.0, 1.05]
|
| 155 |
+
|
| 156 |
+
for v in v_values:
|
| 157 |
+
v_temps = np.linspace(self.temp_min, self.temp_max, 50)
|
| 158 |
+
v_points = []
|
| 159 |
+
|
| 160 |
+
for t in h_temps:
|
| 161 |
+
# Binary search to find humidity ratio for this specific volume
|
| 162 |
+
w_low = 0
|
| 163 |
+
w_high = self.psychrometrics.humidity_ratio(t, 100, self.pressure)
|
| 164 |
+
|
| 165 |
+
for _ in range(10): # 10 iterations should be enough for good precision
|
| 166 |
+
w_mid = (w_low + w_high) / 2
|
| 167 |
+
v_calc = self.psychrometrics.specific_volume(t, w_mid, self.pressure)
|
| 168 |
+
|
| 169 |
+
if abs(v_calc - v) < 0.01:
|
| 170 |
+
v_points.append((t, w_mid))
|
| 171 |
+
break
|
| 172 |
+
elif v_calc < v:
|
| 173 |
+
w_low = w_mid
|
| 174 |
+
else:
|
| 175 |
+
w_high = w_mid
|
| 176 |
+
|
| 177 |
+
if v_points:
|
| 178 |
+
v_temps, v_w = zip(*v_points)
|
| 179 |
+
|
| 180 |
+
fig.add_trace(go.Scatter(
|
| 181 |
+
x=v_temps,
|
| 182 |
+
y=v_w,
|
| 183 |
+
mode="lines",
|
| 184 |
+
line=dict(color="rgba(128, 0, 128, 0.3)", width=1, dash="longdash"),
|
| 185 |
+
name=f"{v:.2f} m³/kg",
|
| 186 |
+
hoverinfo="name"
|
| 187 |
+
))
|
| 188 |
+
|
| 189 |
+
# Add comfort zone if specified
|
| 190 |
+
if comfort_zone:
|
| 191 |
+
temp_min = comfort_zone.get("temp_min", 20)
|
| 192 |
+
temp_max = comfort_zone.get("temp_max", 26)
|
| 193 |
+
rh_min = comfort_zone.get("rh_min", 30)
|
| 194 |
+
rh_max = comfort_zone.get("rh_max", 60)
|
| 195 |
+
|
| 196 |
+
# Calculate humidity ratios at corners
|
| 197 |
+
w_bottom_left = self.psychrometrics.humidity_ratio(temp_min, rh_min, self.pressure)
|
| 198 |
+
w_bottom_right = self.psychrometrics.humidity_ratio(temp_max, rh_min, self.pressure)
|
| 199 |
+
w_top_right = self.psychrometrics.humidity_ratio(temp_max, rh_max, self.pressure)
|
| 200 |
+
w_top_left = self.psychrometrics.humidity_ratio(temp_min, rh_max, self.pressure)
|
| 201 |
+
|
| 202 |
+
# Add comfort zone as a filled polygon
|
| 203 |
+
fig.add_trace(go.Scatter(
|
| 204 |
+
x=[temp_min, temp_max, temp_max, temp_min, temp_min],
|
| 205 |
+
y=[w_bottom_left, w_bottom_right, w_top_right, w_top_left, w_bottom_left],
|
| 206 |
+
fill="toself",
|
| 207 |
+
fillcolor="rgba(0, 255, 0, 0.2)",
|
| 208 |
+
line=dict(color="green", width=2),
|
| 209 |
+
name="Comfort Zone"
|
| 210 |
+
))
|
| 211 |
+
|
| 212 |
+
# Add points if specified
|
| 213 |
+
if points:
|
| 214 |
+
for i, point in enumerate(points):
|
| 215 |
+
temp = point.get("temp", 0)
|
| 216 |
+
rh = point.get("rh", 0)
|
| 217 |
+
w = point.get("w", self.psychrometrics.humidity_ratio(temp, rh, self.pressure))
|
| 218 |
+
name = point.get("name", f"Point {i+1}")
|
| 219 |
+
color = point.get("color", "blue")
|
| 220 |
+
|
| 221 |
+
fig.add_trace(go.Scatter(
|
| 222 |
+
x=[temp],
|
| 223 |
+
y=[w],
|
| 224 |
+
mode="markers+text",
|
| 225 |
+
marker=dict(size=10, color=color),
|
| 226 |
+
text=[name],
|
| 227 |
+
textposition="top center",
|
| 228 |
+
name=name,
|
| 229 |
+
hovertemplate=(
|
| 230 |
+
f"<b>{name}</b><br>" +
|
| 231 |
+
"Temperature: %{x:.1f}°C<br>" +
|
| 232 |
+
"Humidity Ratio: %{y:.5f} kg/kg<br>" +
|
| 233 |
+
f"Relative Humidity: {rh:.1f}%<br>"
|
| 234 |
+
)
|
| 235 |
+
))
|
| 236 |
+
|
| 237 |
+
# Add processes if specified
|
| 238 |
+
if processes:
|
| 239 |
+
for i, process in enumerate(processes):
|
| 240 |
+
start_point = process.get("start", {})
|
| 241 |
+
end_point = process.get("end", {})
|
| 242 |
+
|
| 243 |
+
start_temp = start_point.get("temp", 0)
|
| 244 |
+
start_rh = start_point.get("rh", 0)
|
| 245 |
+
start_w = start_point.get("w", self.psychrometrics.humidity_ratio(start_temp, start_rh, self.pressure))
|
| 246 |
+
|
| 247 |
+
end_temp = end_point.get("temp", 0)
|
| 248 |
+
end_rh = end_point.get("rh", 0)
|
| 249 |
+
end_w = end_point.get("w", self.psychrometrics.humidity_ratio(end_temp, end_rh, self.pressure))
|
| 250 |
+
|
| 251 |
+
name = process.get("name", f"Process {i+1}")
|
| 252 |
+
color = process.get("color", "red")
|
| 253 |
+
|
| 254 |
+
fig.add_trace(go.Scatter(
|
| 255 |
+
x=[start_temp, end_temp],
|
| 256 |
+
y=[start_w, end_w],
|
| 257 |
+
mode="lines+markers",
|
| 258 |
+
line=dict(color=color, width=2, dash="solid"),
|
| 259 |
+
marker=dict(size=8, color=color),
|
| 260 |
+
name=name
|
| 261 |
+
))
|
| 262 |
+
|
| 263 |
+
# Add arrow to indicate direction
|
| 264 |
+
fig.add_annotation(
|
| 265 |
+
x=end_temp,
|
| 266 |
+
y=end_w,
|
| 267 |
+
ax=start_temp,
|
| 268 |
+
ay=start_w,
|
| 269 |
+
xref="x",
|
| 270 |
+
yref="y",
|
| 271 |
+
axref="x",
|
| 272 |
+
ayref="y",
|
| 273 |
+
showarrow=True,
|
| 274 |
+
arrowhead=2,
|
| 275 |
+
arrowsize=1,
|
| 276 |
+
arrowwidth=2,
|
| 277 |
+
arrowcolor=color
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
# Update layout
|
| 281 |
+
fig.update_layout(
|
| 282 |
+
title="Psychrometric Chart",
|
| 283 |
+
xaxis_title="Dry-Bulb Temperature (°C)",
|
| 284 |
+
yaxis_title="Humidity Ratio (kg/kg)",
|
| 285 |
+
xaxis=dict(
|
| 286 |
+
range=[self.temp_min, self.temp_max],
|
| 287 |
+
gridcolor="rgba(0, 0, 0, 0.1)",
|
| 288 |
+
showgrid=True
|
| 289 |
+
),
|
| 290 |
+
yaxis=dict(
|
| 291 |
+
range=[self.w_min, self.w_max],
|
| 292 |
+
gridcolor="rgba(0, 0, 0, 0.1)",
|
| 293 |
+
showgrid=True
|
| 294 |
+
),
|
| 295 |
+
height=700,
|
| 296 |
+
margin=dict(l=50, r=50, b=50, t=50),
|
| 297 |
+
legend=dict(
|
| 298 |
+
orientation="h",
|
| 299 |
+
yanchor="bottom",
|
| 300 |
+
y=1.02,
|
| 301 |
+
xanchor="right",
|
| 302 |
+
x=1
|
| 303 |
+
),
|
| 304 |
+
hovermode="closest"
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
return fig
|
| 308 |
+
|
| 309 |
+
def create_process_visualization(self, process: Dict[str, Any]) -> go.Figure:
|
| 310 |
+
"""
|
| 311 |
+
Create a visualization of a psychrometric process.
|
| 312 |
+
|
| 313 |
+
Args:
|
| 314 |
+
process: Dictionary with process parameters
|
| 315 |
+
|
| 316 |
+
Returns:
|
| 317 |
+
Plotly figure with process visualization
|
| 318 |
+
"""
|
| 319 |
+
# Extract process parameters
|
| 320 |
+
start_point = process.get("start", {})
|
| 321 |
+
end_point = process.get("end", {})
|
| 322 |
+
|
| 323 |
+
start_temp = start_point.get("temp", 0)
|
| 324 |
+
start_rh = start_point.get("rh", 0)
|
| 325 |
+
|
| 326 |
+
end_temp = end_point.get("temp", 0)
|
| 327 |
+
end_rh = end_point.get("rh", 0)
|
| 328 |
+
|
| 329 |
+
# Calculate psychrometric properties
|
| 330 |
+
start_props = self.psychrometrics.moist_air_properties(start_temp, start_rh, self.pressure)
|
| 331 |
+
end_props = self.psychrometrics.moist_air_properties(end_temp, end_rh, self.pressure)
|
| 332 |
+
|
| 333 |
+
# Calculate process changes
|
| 334 |
+
delta_t = end_temp - start_temp
|
| 335 |
+
delta_w = end_props["humidity_ratio"] - start_props["humidity_ratio"]
|
| 336 |
+
delta_h = end_props["enthalpy"] - start_props["enthalpy"]
|
| 337 |
+
|
| 338 |
+
# Determine process type
|
| 339 |
+
process_type = "Unknown"
|
| 340 |
+
if abs(delta_w) < 0.0001: # Sensible heating/cooling
|
| 341 |
+
if delta_t > 0:
|
| 342 |
+
process_type = "Sensible Heating"
|
| 343 |
+
else:
|
| 344 |
+
process_type = "Sensible Cooling"
|
| 345 |
+
elif abs(delta_t) < 0.1: # Humidification/Dehumidification
|
| 346 |
+
if delta_w > 0:
|
| 347 |
+
process_type = "Humidification"
|
| 348 |
+
else:
|
| 349 |
+
process_type = "Dehumidification"
|
| 350 |
+
elif delta_t > 0 and delta_w > 0:
|
| 351 |
+
process_type = "Heating and Humidification"
|
| 352 |
+
elif delta_t < 0 and delta_w < 0:
|
| 353 |
+
process_type = "Cooling and Dehumidification"
|
| 354 |
+
elif delta_t > 0 and delta_w < 0:
|
| 355 |
+
process_type = "Heating and Dehumidification"
|
| 356 |
+
elif delta_t < 0 and delta_w > 0:
|
| 357 |
+
process_type = "Cooling and Humidification"
|
| 358 |
+
|
| 359 |
+
# Create figure
|
| 360 |
+
fig = go.Figure()
|
| 361 |
+
|
| 362 |
+
# Add process to psychrometric chart
|
| 363 |
+
chart_fig = self.create_psychrometric_chart(
|
| 364 |
+
points=[
|
| 365 |
+
{"temp": start_temp, "rh": start_rh, "name": "Start", "color": "blue"},
|
| 366 |
+
{"temp": end_temp, "rh": end_rh, "name": "End", "color": "red"}
|
| 367 |
+
],
|
| 368 |
+
processes=[
|
| 369 |
+
{"start": {"temp": start_temp, "rh": start_rh},
|
| 370 |
+
"end": {"temp": end_temp, "rh": end_rh},
|
| 371 |
+
"name": process_type,
|
| 372 |
+
"color": "green"}
|
| 373 |
+
]
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
# Create process diagram
|
| 377 |
+
# Create data for process parameters
|
| 378 |
+
params = [
|
| 379 |
+
"Dry-Bulb Temperature (°C)",
|
| 380 |
+
"Relative Humidity (%)",
|
| 381 |
+
"Humidity Ratio (g/kg)",
|
| 382 |
+
"Enthalpy (kJ/kg)",
|
| 383 |
+
"Wet-Bulb Temperature (°C)",
|
| 384 |
+
"Dew Point Temperature (°C)",
|
| 385 |
+
"Specific Volume (m³/kg)"
|
| 386 |
+
]
|
| 387 |
+
|
| 388 |
+
start_values = [
|
| 389 |
+
start_props["dry_bulb_temperature"],
|
| 390 |
+
start_props["relative_humidity"],
|
| 391 |
+
start_props["humidity_ratio"] * 1000, # Convert to g/kg
|
| 392 |
+
start_props["enthalpy"] / 1000, # Convert to kJ/kg
|
| 393 |
+
start_props["wet_bulb_temperature"],
|
| 394 |
+
start_props["dew_point_temperature"],
|
| 395 |
+
start_props["specific_volume"]
|
| 396 |
+
]
|
| 397 |
+
|
| 398 |
+
end_values = [
|
| 399 |
+
end_props["dry_bulb_temperature"],
|
| 400 |
+
end_props["relative_humidity"],
|
| 401 |
+
end_props["humidity_ratio"] * 1000, # Convert to g/kg
|
| 402 |
+
end_props["enthalpy"] / 1000, # Convert to kJ/kg
|
| 403 |
+
end_props["wet_bulb_temperature"],
|
| 404 |
+
end_props["dew_point_temperature"],
|
| 405 |
+
end_props["specific_volume"]
|
| 406 |
+
]
|
| 407 |
+
|
| 408 |
+
delta_values = [end - start for start, end in zip(start_values, end_values)]
|
| 409 |
+
|
| 410 |
+
# Create table
|
| 411 |
+
table_fig = go.Figure(data=[go.Table(
|
| 412 |
+
header=dict(
|
| 413 |
+
values=["Parameter", "Start", "End", "Change"],
|
| 414 |
+
fill_color="paleturquoise",
|
| 415 |
+
align="left",
|
| 416 |
+
font=dict(size=12)
|
| 417 |
+
),
|
| 418 |
+
cells=dict(
|
| 419 |
+
values=[
|
| 420 |
+
params,
|
| 421 |
+
[f"{val:.2f}" for val in start_values],
|
| 422 |
+
[f"{val:.2f}" for val in end_values],
|
| 423 |
+
[f"{val:.2f}" for val in delta_values]
|
| 424 |
+
],
|
| 425 |
+
fill_color="lavender",
|
| 426 |
+
align="left",
|
| 427 |
+
font=dict(size=11)
|
| 428 |
+
)
|
| 429 |
+
)])
|
| 430 |
+
|
| 431 |
+
table_fig.update_layout(
|
| 432 |
+
title=f"Process Parameters: {process_type}",
|
| 433 |
+
height=300,
|
| 434 |
+
margin=dict(l=0, r=0, b=0, t=30)
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
return chart_fig, table_fig
|
| 438 |
+
|
| 439 |
+
def display_psychrometric_visualization(self) -> None:
|
| 440 |
+
"""
|
| 441 |
+
Display psychrometric visualization in Streamlit.
|
| 442 |
+
"""
|
| 443 |
+
st.header("Psychrometric Visualization")
|
| 444 |
+
|
| 445 |
+
# Create tabs for different visualizations
|
| 446 |
+
tab1, tab2, tab3 = st.tabs([
|
| 447 |
+
"Interactive Psychrometric Chart",
|
| 448 |
+
"Process Visualization",
|
| 449 |
+
"Comfort Zone Analysis"
|
| 450 |
+
])
|
| 451 |
+
|
| 452 |
+
with tab1:
|
| 453 |
+
st.subheader("Interactive Psychrometric Chart")
|
| 454 |
+
|
| 455 |
+
# Add controls for points
|
| 456 |
+
st.write("Add points to the chart:")
|
| 457 |
+
|
| 458 |
+
col1, col2, col3 = st.columns(3)
|
| 459 |
+
|
| 460 |
+
with col1:
|
| 461 |
+
point1_temp = st.number_input("Point 1 Temperature (°C)", -10.0, 50.0, 20.0, key="point1_temp")
|
| 462 |
+
point1_rh = st.number_input("Point 1 RH (%)", 0.0, 100.0, 50.0, key="point1_rh")
|
| 463 |
+
|
| 464 |
+
with col2:
|
| 465 |
+
point2_temp = st.number_input("Point 2 Temperature (°C)", -10.0, 50.0, 30.0, key="point2_temp")
|
| 466 |
+
point2_rh = st.number_input("Point 2 RH (%)", 0.0, 100.0, 40.0, key="point2_rh")
|
| 467 |
+
|
| 468 |
+
with col3:
|
| 469 |
+
show_process = st.checkbox("Show Process Line", True, key="show_process")
|
| 470 |
+
process_name = st.text_input("Process Name", "Cooling Process", key="process_name")
|
| 471 |
+
|
| 472 |
+
# Create points
|
| 473 |
+
points = [
|
| 474 |
+
{"temp": point1_temp, "rh": point1_rh, "name": "Point 1", "color": "blue"},
|
| 475 |
+
{"temp": point2_temp, "rh": point2_rh, "name": "Point 2", "color": "red"}
|
| 476 |
+
]
|
| 477 |
+
|
| 478 |
+
# Create process if enabled
|
| 479 |
+
processes = []
|
| 480 |
+
if show_process:
|
| 481 |
+
processes.append({
|
| 482 |
+
"start": {"temp": point1_temp, "rh": point1_rh},
|
| 483 |
+
"end": {"temp": point2_temp, "rh": point2_rh},
|
| 484 |
+
"name": process_name,
|
| 485 |
+
"color": "green"
|
| 486 |
+
})
|
| 487 |
+
|
| 488 |
+
# Create and display chart
|
| 489 |
+
fig = self.create_psychrometric_chart(points=points, processes=processes)
|
| 490 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 491 |
+
|
| 492 |
+
# Display point properties
|
| 493 |
+
col1, col2 = st.columns(2)
|
| 494 |
+
|
| 495 |
+
with col1:
|
| 496 |
+
st.subheader("Point 1 Properties")
|
| 497 |
+
props1 = self.psychrometrics.moist_air_properties(point1_temp, point1_rh, self.pressure)
|
| 498 |
+
st.write(f"Dry-Bulb Temperature: {props1['dry_bulb_temperature']:.2f} °C")
|
| 499 |
+
st.write(f"Relative Humidity: {props1['relative_humidity']:.2f} %")
|
| 500 |
+
st.write(f"Humidity Ratio: {props1['humidity_ratio']*1000:.2f} g/kg")
|
| 501 |
+
st.write(f"Enthalpy: {props1['enthalpy']/1000:.2f} kJ/kg")
|
| 502 |
+
st.write(f"Wet-Bulb Temperature: {props1['wet_bulb_temperature']:.2f} °C")
|
| 503 |
+
st.write(f"Dew Point Temperature: {props1['dew_point_temperature']:.2f} °C")
|
| 504 |
+
|
| 505 |
+
with col2:
|
| 506 |
+
st.subheader("Point 2 Properties")
|
| 507 |
+
props2 = self.psychrometrics.moist_air_properties(point2_temp, point2_rh, self.pressure)
|
| 508 |
+
st.write(f"Dry-Bulb Temperature: {props2['dry_bulb_temperature']:.2f} °C")
|
| 509 |
+
st.write(f"Relative Humidity: {props2['relative_humidity']:.2f} %")
|
| 510 |
+
st.write(f"Humidity Ratio: {props2['humidity_ratio']*1000:.2f} g/kg")
|
| 511 |
+
st.write(f"Enthalpy: {props2['enthalpy']/1000:.2f} kJ/kg")
|
| 512 |
+
st.write(f"Wet-Bulb Temperature: {props2['wet_bulb_temperature']:.2f} °C")
|
| 513 |
+
st.write(f"Dew Point Temperature: {props2['dew_point_temperature']:.2f} °C")
|
| 514 |
+
|
| 515 |
+
with tab2:
|
| 516 |
+
st.subheader("Process Visualization")
|
| 517 |
+
|
| 518 |
+
# Add controls for process
|
| 519 |
+
st.write("Define a psychrometric process:")
|
| 520 |
+
|
| 521 |
+
col1, col2 = st.columns(2)
|
| 522 |
+
|
| 523 |
+
with col1:
|
| 524 |
+
st.write("Starting Point")
|
| 525 |
+
start_temp = st.number_input("Temperature (°C)", -10.0, 50.0, 24.0, key="start_temp")
|
| 526 |
+
start_rh = st.number_input("RH (%)", 0.0, 100.0, 50.0, key="start_rh")
|
| 527 |
+
|
| 528 |
+
with col2:
|
| 529 |
+
st.write("Ending Point")
|
| 530 |
+
end_temp = st.number_input("Temperature (°C)", -10.0, 50.0, 14.0, key="end_temp")
|
| 531 |
+
end_rh = st.number_input("RH (%)", 0.0, 100.0, 90.0, key="end_rh")
|
| 532 |
+
|
| 533 |
+
# Create process
|
| 534 |
+
process = {
|
| 535 |
+
"start": {"temp": start_temp, "rh": start_rh},
|
| 536 |
+
"end": {"temp": end_temp, "rh": end_rh}
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
# Create and display process visualization
|
| 540 |
+
chart_fig, table_fig = self.create_process_visualization(process)
|
| 541 |
+
|
| 542 |
+
st.plotly_chart(chart_fig, use_container_width=True)
|
| 543 |
+
st.plotly_chart(table_fig, use_container_width=True)
|
| 544 |
+
|
| 545 |
+
# Calculate process energy requirements
|
| 546 |
+
start_props = self.psychrometrics.moist_air_properties(start_temp, start_rh, self.pressure)
|
| 547 |
+
end_props = self.psychrometrics.moist_air_properties(end_temp, end_rh, self.pressure)
|
| 548 |
+
|
| 549 |
+
delta_h = end_props["enthalpy"] - start_props["enthalpy"] # J/kg
|
| 550 |
+
|
| 551 |
+
st.subheader("Energy Calculations")
|
| 552 |
+
|
| 553 |
+
air_flow = st.number_input("Air Flow Rate (m³/s)", 0.1, 100.0, 1.0, key="air_flow")
|
| 554 |
+
|
| 555 |
+
# Calculate mass flow rate
|
| 556 |
+
density = start_props["density"] # kg/m³
|
| 557 |
+
mass_flow = air_flow * density # kg/s
|
| 558 |
+
|
| 559 |
+
# Calculate energy rate
|
| 560 |
+
energy_rate = mass_flow * delta_h # W
|
| 561 |
+
|
| 562 |
+
st.write(f"Air Density: {density:.2f} kg/m³")
|
| 563 |
+
st.write(f"Mass Flow Rate: {mass_flow:.2f} kg/s")
|
| 564 |
+
st.write(f"Enthalpy Change: {delta_h/1000:.2f} kJ/kg")
|
| 565 |
+
st.write(f"Energy Rate: {energy_rate/1000:.2f} kW")
|
| 566 |
+
|
| 567 |
+
with tab3:
|
| 568 |
+
st.subheader("Comfort Zone Analysis")
|
| 569 |
+
|
| 570 |
+
# Add controls for comfort zone
|
| 571 |
+
st.write("Define comfort zone parameters:")
|
| 572 |
+
|
| 573 |
+
col1, col2 = st.columns(2)
|
| 574 |
+
|
| 575 |
+
with col1:
|
| 576 |
+
temp_min = st.number_input("Minimum Temperature (°C)", 10.0, 30.0, 20.0, key="temp_min")
|
| 577 |
+
temp_max = st.number_input("Maximum Temperature (°C)", 10.0, 30.0, 26.0, key="temp_max")
|
| 578 |
+
|
| 579 |
+
with col2:
|
| 580 |
+
rh_min = st.number_input("Minimum RH (%)", 0.0, 100.0, 30.0, key="rh_min")
|
| 581 |
+
rh_max = st.number_input("Maximum RH (%)", 0.0, 100.0, 60.0, key="rh_max")
|
| 582 |
+
|
| 583 |
+
# Create comfort zone
|
| 584 |
+
comfort_zone = {
|
| 585 |
+
"temp_min": temp_min,
|
| 586 |
+
"temp_max": temp_max,
|
| 587 |
+
"rh_min": rh_min,
|
| 588 |
+
"rh_max": rh_max
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
# Add point to check if it's in comfort zone
|
| 592 |
+
st.write("Check if a point is within the comfort zone:")
|
| 593 |
+
|
| 594 |
+
col1, col2 = st.columns(2)
|
| 595 |
+
|
| 596 |
+
with col1:
|
| 597 |
+
check_temp = st.number_input("Temperature (°C)", -10.0, 50.0, 22.0, key="check_temp")
|
| 598 |
+
check_rh = st.number_input("RH (%)", 0.0, 100.0, 45.0, key="check_rh")
|
| 599 |
+
|
| 600 |
+
# Check if point is in comfort zone
|
| 601 |
+
in_comfort_zone = (
|
| 602 |
+
temp_min <= check_temp <= temp_max and
|
| 603 |
+
rh_min <= check_rh <= rh_max
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
+
with col2:
|
| 607 |
+
if in_comfort_zone:
|
| 608 |
+
st.success("✅ Point is within the comfort zone")
|
| 609 |
+
else:
|
| 610 |
+
st.error("❌ Point is outside the comfort zone")
|
| 611 |
+
|
| 612 |
+
# Calculate properties
|
| 613 |
+
check_props = self.psychrometrics.moist_air_properties(check_temp, check_rh, self.pressure)
|
| 614 |
+
st.write(f"Humidity Ratio: {check_props['humidity_ratio']*1000:.2f} g/kg")
|
| 615 |
+
st.write(f"Enthalpy: {check_props['enthalpy']/1000:.2f} kJ/kg")
|
| 616 |
+
st.write(f"Wet-Bulb Temperature: {check_props['wet_bulb_temperature']:.2f} °C")
|
| 617 |
+
|
| 618 |
+
# Create and display chart with comfort zone
|
| 619 |
+
fig = self.create_psychrometric_chart(
|
| 620 |
+
points=[{"temp": check_temp, "rh": check_rh, "name": "Test Point", "color": "purple"}],
|
| 621 |
+
comfort_zone=comfort_zone
|
| 622 |
+
)
|
| 623 |
+
|
| 624 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
# Create a singleton instance
|
| 628 |
+
psychrometric_visualization = PsychrometricVisualization()
|
| 629 |
+
|
| 630 |
+
# Example usage
|
| 631 |
+
if __name__ == "__main__":
|
| 632 |
+
import streamlit as st
|
| 633 |
+
|
| 634 |
+
# Display psychrometric visualization
|
| 635 |
+
psychrometric_visualization.display_psychrometric_visualization()
|
utils/psychrometrics.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Psychrometric module for HVAC Load Calculator.
|
| 3 |
+
This module implements psychrometric calculations for air properties.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import math
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
# Constants
|
| 11 |
+
ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure in Pa
|
| 12 |
+
WATER_MOLECULAR_WEIGHT = 18.01534 # kg/kmol
|
| 13 |
+
DRY_AIR_MOLECULAR_WEIGHT = 28.9645 # kg/kmol
|
| 14 |
+
UNIVERSAL_GAS_CONSTANT = 8314.462618 # J/(kmol·K)
|
| 15 |
+
GAS_CONSTANT_DRY_AIR = UNIVERSAL_GAS_CONSTANT / DRY_AIR_MOLECULAR_WEIGHT # J/(kg·K)
|
| 16 |
+
GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Psychrometrics:
|
| 20 |
+
"""Class for psychrometric calculations."""
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
def saturation_pressure(t_db: float) -> float:
|
| 24 |
+
"""
|
| 25 |
+
Calculate saturation pressure of water vapor.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
t_db: Dry-bulb temperature in °C
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Saturation pressure in Pa
|
| 32 |
+
"""
|
| 33 |
+
# Convert temperature to Kelvin
|
| 34 |
+
t_k = t_db + 273.15
|
| 35 |
+
|
| 36 |
+
# ASHRAE Fundamentals 2017 Chapter 1, Equation 5 & 6
|
| 37 |
+
if t_db >= 0:
|
| 38 |
+
# Equation 5 for temperatures above freezing
|
| 39 |
+
c1 = -5.8002206e3
|
| 40 |
+
c2 = 1.3914993
|
| 41 |
+
c3 = -4.8640239e-2
|
| 42 |
+
c4 = 4.1764768e-5
|
| 43 |
+
c5 = -1.4452093e-8
|
| 44 |
+
c6 = 6.5459673
|
| 45 |
+
else:
|
| 46 |
+
# Equation 6 for temperatures below freezing
|
| 47 |
+
c1 = -5.6745359e3
|
| 48 |
+
c2 = 6.3925247
|
| 49 |
+
c3 = -9.6778430e-3
|
| 50 |
+
c4 = 6.2215701e-7
|
| 51 |
+
c5 = 2.0747825e-9
|
| 52 |
+
c6 = -9.4840240e-13
|
| 53 |
+
c7 = 4.1635019
|
| 54 |
+
|
| 55 |
+
# Calculate natural log of saturation pressure in Pa
|
| 56 |
+
if t_db >= 0:
|
| 57 |
+
ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * math.log(t_k)
|
| 58 |
+
else:
|
| 59 |
+
ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * t_k**4 + c7 * math.log(t_k)
|
| 60 |
+
|
| 61 |
+
# Convert from natural log to actual pressure in Pa
|
| 62 |
+
p_ws = math.exp(ln_p_ws)
|
| 63 |
+
|
| 64 |
+
return p_ws
|
| 65 |
+
|
| 66 |
+
@staticmethod
|
| 67 |
+
def humidity_ratio(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 68 |
+
"""
|
| 69 |
+
Calculate humidity ratio (mass of water vapor per unit mass of dry air).
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
t_db: Dry-bulb temperature in °C
|
| 73 |
+
rh: Relative humidity (0-100)
|
| 74 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
Humidity ratio in kg water vapor / kg dry air
|
| 78 |
+
"""
|
| 79 |
+
# Convert relative humidity to decimal
|
| 80 |
+
rh_decimal = rh / 100.0
|
| 81 |
+
|
| 82 |
+
# Calculate saturation pressure
|
| 83 |
+
p_ws = Psychrometrics.saturation_pressure(t_db)
|
| 84 |
+
|
| 85 |
+
# Calculate partial pressure of water vapor
|
| 86 |
+
p_w = rh_decimal * p_ws
|
| 87 |
+
|
| 88 |
+
# Calculate humidity ratio
|
| 89 |
+
# ASHRAE Fundamentals 2017 Chapter 1, Equation 20
|
| 90 |
+
w = 0.621945 * p_w / (p_atm - p_w)
|
| 91 |
+
|
| 92 |
+
return w
|
| 93 |
+
|
| 94 |
+
@staticmethod
|
| 95 |
+
def relative_humidity(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 96 |
+
"""
|
| 97 |
+
Calculate relative humidity from humidity ratio.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
t_db: Dry-bulb temperature in °C
|
| 101 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 102 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Relative humidity (0-100)
|
| 106 |
+
"""
|
| 107 |
+
# Calculate saturation pressure
|
| 108 |
+
p_ws = Psychrometrics.saturation_pressure(t_db)
|
| 109 |
+
|
| 110 |
+
# Calculate partial pressure of water vapor
|
| 111 |
+
# Rearranged from ASHRAE Fundamentals 2017 Chapter 1, Equation 20
|
| 112 |
+
p_w = p_atm * w / (0.621945 + w)
|
| 113 |
+
|
| 114 |
+
# Calculate relative humidity
|
| 115 |
+
rh = 100.0 * p_w / p_ws
|
| 116 |
+
|
| 117 |
+
return rh
|
| 118 |
+
|
| 119 |
+
@staticmethod
|
| 120 |
+
def wet_bulb_temperature(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 121 |
+
"""
|
| 122 |
+
Calculate wet-bulb temperature using iterative method.
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
t_db: Dry-bulb temperature in °C
|
| 126 |
+
rh: Relative humidity (0-100)
|
| 127 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Wet-bulb temperature in °C
|
| 131 |
+
"""
|
| 132 |
+
# Calculate humidity ratio at given conditions
|
| 133 |
+
w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
|
| 134 |
+
|
| 135 |
+
# Initial guess for wet-bulb temperature
|
| 136 |
+
t_wb = t_db
|
| 137 |
+
|
| 138 |
+
# Iterative solution
|
| 139 |
+
max_iterations = 100
|
| 140 |
+
tolerance = 0.001 # °C
|
| 141 |
+
|
| 142 |
+
for i in range(max_iterations):
|
| 143 |
+
# Calculate saturation pressure at wet-bulb temperature
|
| 144 |
+
p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
|
| 145 |
+
|
| 146 |
+
# Calculate saturation humidity ratio at wet-bulb temperature
|
| 147 |
+
w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
|
| 148 |
+
|
| 149 |
+
# Calculate humidity ratio from wet-bulb temperature
|
| 150 |
+
# ASHRAE Fundamentals 2017 Chapter 1, Equation 35
|
| 151 |
+
h_fg = 2501000 + 1840 * t_wb # Latent heat of vaporization at t_wb in J/kg
|
| 152 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 153 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 154 |
+
|
| 155 |
+
w_calc = ((h_fg - c_pw * (t_db - t_wb)) * w_s_wb - c_pa * (t_db - t_wb)) / (h_fg + c_pw * t_db - c_pw * t_wb)
|
| 156 |
+
|
| 157 |
+
# Check convergence
|
| 158 |
+
if abs(w - w_calc) < tolerance:
|
| 159 |
+
break
|
| 160 |
+
|
| 161 |
+
# Adjust wet-bulb temperature
|
| 162 |
+
if w_calc > w:
|
| 163 |
+
t_wb -= 0.1
|
| 164 |
+
else:
|
| 165 |
+
t_wb += 0.1
|
| 166 |
+
|
| 167 |
+
return t_wb
|
| 168 |
+
|
| 169 |
+
@staticmethod
|
| 170 |
+
def dew_point_temperature(t_db: float, rh: float) -> float:
|
| 171 |
+
"""
|
| 172 |
+
Calculate dew point temperature.
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
t_db: Dry-bulb temperature in °C
|
| 176 |
+
rh: Relative humidity (0-100)
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Dew point temperature in °C
|
| 180 |
+
"""
|
| 181 |
+
# Convert relative humidity to decimal
|
| 182 |
+
rh_decimal = rh / 100.0
|
| 183 |
+
|
| 184 |
+
# Calculate saturation pressure
|
| 185 |
+
p_ws = Psychrometrics.saturation_pressure(t_db)
|
| 186 |
+
|
| 187 |
+
# Calculate partial pressure of water vapor
|
| 188 |
+
p_w = rh_decimal * p_ws
|
| 189 |
+
|
| 190 |
+
# Calculate dew point temperature
|
| 191 |
+
# ASHRAE Fundamentals 2017 Chapter 1, Equation 39 and 40
|
| 192 |
+
alpha = math.log(p_w / 1000.0) # Convert to kPa for the formula
|
| 193 |
+
|
| 194 |
+
if t_db >= 0:
|
| 195 |
+
# For temperatures above freezing
|
| 196 |
+
c14 = 6.54
|
| 197 |
+
c15 = 14.526
|
| 198 |
+
c16 = 0.7389
|
| 199 |
+
c17 = 0.09486
|
| 200 |
+
c18 = 0.4569
|
| 201 |
+
|
| 202 |
+
t_dp = c14 + c15 * alpha + c16 * alpha**2 + c17 * alpha**3 + c18 * p_w**(0.1984)
|
| 203 |
+
else:
|
| 204 |
+
# For temperatures below freezing
|
| 205 |
+
c14 = 6.09
|
| 206 |
+
c15 = 12.608
|
| 207 |
+
c16 = 0.4959
|
| 208 |
+
|
| 209 |
+
t_dp = c14 + c15 * alpha + c16 * alpha**2
|
| 210 |
+
|
| 211 |
+
return t_dp
|
| 212 |
+
|
| 213 |
+
@staticmethod
|
| 214 |
+
def enthalpy(t_db: float, w: float) -> float:
|
| 215 |
+
"""
|
| 216 |
+
Calculate specific enthalpy of moist air.
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
t_db: Dry-bulb temperature in °C
|
| 220 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
Specific enthalpy in J/kg dry air
|
| 224 |
+
"""
|
| 225 |
+
# ASHRAE Fundamentals 2017 Chapter 1, Equation 30
|
| 226 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 227 |
+
h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
|
| 228 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 229 |
+
|
| 230 |
+
h = c_pa * t_db + w * (h_fg + c_pw * t_db)
|
| 231 |
+
|
| 232 |
+
return h
|
| 233 |
+
|
| 234 |
+
@staticmethod
|
| 235 |
+
def specific_volume(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 236 |
+
"""
|
| 237 |
+
Calculate specific volume of moist air.
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
t_db: Dry-bulb temperature in °C
|
| 241 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 242 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 243 |
+
|
| 244 |
+
Returns:
|
| 245 |
+
Specific volume in m³/kg dry air
|
| 246 |
+
"""
|
| 247 |
+
# Convert temperature to Kelvin
|
| 248 |
+
t_k = t_db + 273.15
|
| 249 |
+
|
| 250 |
+
# ASHRAE Fundamentals 2017 Chapter 1, Equation 28
|
| 251 |
+
r_da = GAS_CONSTANT_DRY_AIR # Gas constant for dry air in J/(kg·K)
|
| 252 |
+
|
| 253 |
+
v = r_da * t_k * (1 + 1.607858 * w) / p_atm
|
| 254 |
+
|
| 255 |
+
return v
|
| 256 |
+
|
| 257 |
+
@staticmethod
|
| 258 |
+
def density(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 259 |
+
"""
|
| 260 |
+
Calculate density of moist air.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
t_db: Dry-bulb temperature in °C
|
| 264 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 265 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 266 |
+
|
| 267 |
+
Returns:
|
| 268 |
+
Density in kg/m³
|
| 269 |
+
"""
|
| 270 |
+
# Calculate specific volume
|
| 271 |
+
v = Psychrometrics.specific_volume(t_db, w, p_atm)
|
| 272 |
+
|
| 273 |
+
# Density is the reciprocal of specific volume
|
| 274 |
+
rho = (1 + w) / v
|
| 275 |
+
|
| 276 |
+
return rho
|
| 277 |
+
|
| 278 |
+
@staticmethod
|
| 279 |
+
def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
|
| 280 |
+
"""
|
| 281 |
+
Calculate all psychrometric properties of moist air.
|
| 282 |
+
|
| 283 |
+
Args:
|
| 284 |
+
t_db: Dry-bulb temperature in °C
|
| 285 |
+
rh: Relative humidity (0-100)
|
| 286 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 287 |
+
|
| 288 |
+
Returns:
|
| 289 |
+
Dictionary with all psychrometric properties
|
| 290 |
+
"""
|
| 291 |
+
# Calculate humidity ratio
|
| 292 |
+
w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
|
| 293 |
+
|
| 294 |
+
# Calculate wet-bulb temperature
|
| 295 |
+
t_wb = Psychrometrics.wet_bulb_temperature(t_db, rh, p_atm)
|
| 296 |
+
|
| 297 |
+
# Calculate dew point temperature
|
| 298 |
+
t_dp = Psychrometrics.dew_point_temperature(t_db, rh)
|
| 299 |
+
|
| 300 |
+
# Calculate enthalpy
|
| 301 |
+
h = Psychrometrics.enthalpy(t_db, w)
|
| 302 |
+
|
| 303 |
+
# Calculate specific volume
|
| 304 |
+
v = Psychrometrics.specific_volume(t_db, w, p_atm)
|
| 305 |
+
|
| 306 |
+
# Calculate density
|
| 307 |
+
rho = Psychrometrics.density(t_db, w, p_atm)
|
| 308 |
+
|
| 309 |
+
# Calculate saturation pressure
|
| 310 |
+
p_ws = Psychrometrics.saturation_pressure(t_db)
|
| 311 |
+
|
| 312 |
+
# Calculate partial pressure of water vapor
|
| 313 |
+
p_w = rh / 100.0 * p_ws
|
| 314 |
+
|
| 315 |
+
# Return all properties
|
| 316 |
+
return {
|
| 317 |
+
"dry_bulb_temperature": t_db,
|
| 318 |
+
"wet_bulb_temperature": t_wb,
|
| 319 |
+
"dew_point_temperature": t_dp,
|
| 320 |
+
"relative_humidity": rh,
|
| 321 |
+
"humidity_ratio": w,
|
| 322 |
+
"enthalpy": h,
|
| 323 |
+
"specific_volume": v,
|
| 324 |
+
"density": rho,
|
| 325 |
+
"saturation_pressure": p_ws,
|
| 326 |
+
"partial_pressure": p_w,
|
| 327 |
+
"atmospheric_pressure": p_atm
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
@staticmethod
|
| 331 |
+
def find_humidity_ratio_for_enthalpy(t_db: float, h: float) -> float:
|
| 332 |
+
"""
|
| 333 |
+
Find humidity ratio for a given dry-bulb temperature and enthalpy.
|
| 334 |
+
|
| 335 |
+
Args:
|
| 336 |
+
t_db: Dry-bulb temperature in °C
|
| 337 |
+
h: Specific enthalpy in J/kg dry air
|
| 338 |
+
|
| 339 |
+
Returns:
|
| 340 |
+
Humidity ratio in kg water vapor / kg dry air
|
| 341 |
+
"""
|
| 342 |
+
# Rearrange ASHRAE Fundamentals 2017 Chapter 1, Equation 30
|
| 343 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 344 |
+
h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
|
| 345 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 346 |
+
|
| 347 |
+
w = (h - c_pa * t_db) / (h_fg + c_pw * t_db)
|
| 348 |
+
|
| 349 |
+
return max(0, w) # Ensure non-negative value
|
| 350 |
+
|
| 351 |
+
@staticmethod
|
| 352 |
+
def find_temperature_for_enthalpy(w: float, h: float) -> float:
|
| 353 |
+
"""
|
| 354 |
+
Find dry-bulb temperature for a given humidity ratio and enthalpy.
|
| 355 |
+
|
| 356 |
+
Args:
|
| 357 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 358 |
+
h: Specific enthalpy in J/kg dry air
|
| 359 |
+
|
| 360 |
+
Returns:
|
| 361 |
+
Dry-bulb temperature in °C
|
| 362 |
+
"""
|
| 363 |
+
# Rearrange ASHRAE Fundamentals 2017 Chapter 1, Equation 30
|
| 364 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 365 |
+
h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
|
| 366 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 367 |
+
|
| 368 |
+
t_db = (h - w * h_fg) / (c_pa + w * c_pw)
|
| 369 |
+
|
| 370 |
+
return t_db
|
| 371 |
+
|
| 372 |
+
@staticmethod
|
| 373 |
+
def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
|
| 374 |
+
"""
|
| 375 |
+
Calculate sensible heat ratio.
|
| 376 |
+
|
| 377 |
+
Args:
|
| 378 |
+
q_sensible: Sensible heat load in W
|
| 379 |
+
q_total: Total heat load in W
|
| 380 |
+
|
| 381 |
+
Returns:
|
| 382 |
+
Sensible heat ratio (0-1)
|
| 383 |
+
"""
|
| 384 |
+
if q_total == 0:
|
| 385 |
+
return 1.0
|
| 386 |
+
|
| 387 |
+
return q_sensible / q_total
|
| 388 |
+
|
| 389 |
+
@staticmethod
|
| 390 |
+
def air_flow_rate_for_load(q_sensible: float, t_supply: float, t_return: float,
|
| 391 |
+
rh_return: float = 50.0, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
|
| 392 |
+
"""
|
| 393 |
+
Calculate required air flow rate for a given sensible load.
|
| 394 |
+
|
| 395 |
+
Args:
|
| 396 |
+
q_sensible: Sensible heat load in W
|
| 397 |
+
t_supply: Supply air temperature in °C
|
| 398 |
+
t_return: Return air temperature in °C
|
| 399 |
+
rh_return: Return air relative humidity in % (default: 50%)
|
| 400 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 401 |
+
|
| 402 |
+
Returns:
|
| 403 |
+
Dictionary with air flow rate in different units
|
| 404 |
+
"""
|
| 405 |
+
# Calculate return air properties
|
| 406 |
+
w_return = Psychrometrics.humidity_ratio(t_return, rh_return, p_atm)
|
| 407 |
+
rho_return = Psychrometrics.density(t_return, w_return, p_atm)
|
| 408 |
+
|
| 409 |
+
# Calculate specific heat of moist air
|
| 410 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 411 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 412 |
+
c_p_moist = c_pa + w_return * c_pw
|
| 413 |
+
|
| 414 |
+
# Calculate mass flow rate
|
| 415 |
+
delta_t = t_return - t_supply
|
| 416 |
+
if delta_t == 0:
|
| 417 |
+
raise ValueError("Supply and return temperatures cannot be equal")
|
| 418 |
+
|
| 419 |
+
m_dot = q_sensible / (c_p_moist * delta_t)
|
| 420 |
+
|
| 421 |
+
# Calculate volumetric flow rate
|
| 422 |
+
v_dot = m_dot / rho_return
|
| 423 |
+
|
| 424 |
+
# Convert to different units
|
| 425 |
+
v_dot_m3_s = v_dot
|
| 426 |
+
v_dot_m3_h = v_dot * 3600
|
| 427 |
+
v_dot_cfm = v_dot * 2118.88
|
| 428 |
+
v_dot_l_s = v_dot * 1000
|
| 429 |
+
|
| 430 |
+
return {
|
| 431 |
+
"mass_flow_rate_kg_s": m_dot,
|
| 432 |
+
"volumetric_flow_rate_m3_s": v_dot_m3_s,
|
| 433 |
+
"volumetric_flow_rate_m3_h": v_dot_m3_h,
|
| 434 |
+
"volumetric_flow_rate_cfm": v_dot_cfm,
|
| 435 |
+
"volumetric_flow_rate_l_s": v_dot_l_s
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
@staticmethod
|
| 439 |
+
def mixing_air_properties(m1: float, t_db1: float, rh1: float,
|
| 440 |
+
m2: float, t_db2: float, rh2: float,
|
| 441 |
+
p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
|
| 442 |
+
"""
|
| 443 |
+
Calculate properties of mixed airstreams.
|
| 444 |
+
|
| 445 |
+
Args:
|
| 446 |
+
m1: Mass flow rate of airstream 1 in kg/s
|
| 447 |
+
t_db1: Dry-bulb temperature of airstream 1 in °C
|
| 448 |
+
rh1: Relative humidity of airstream 1 in %
|
| 449 |
+
m2: Mass flow rate of airstream 2 in kg/s
|
| 450 |
+
t_db2: Dry-bulb temperature of airstream 2 in °C
|
| 451 |
+
rh2: Relative humidity of airstream 2 in %
|
| 452 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 453 |
+
|
| 454 |
+
Returns:
|
| 455 |
+
Dictionary with mixed air properties
|
| 456 |
+
"""
|
| 457 |
+
# Calculate humidity ratios
|
| 458 |
+
w1 = Psychrometrics.humidity_ratio(t_db1, rh1, p_atm)
|
| 459 |
+
w2 = Psychrometrics.humidity_ratio(t_db2, rh2, p_atm)
|
| 460 |
+
|
| 461 |
+
# Calculate enthalpies
|
| 462 |
+
h1 = Psychrometrics.enthalpy(t_db1, w1)
|
| 463 |
+
h2 = Psychrometrics.enthalpy(t_db2, w2)
|
| 464 |
+
|
| 465 |
+
# Calculate mixed air properties
|
| 466 |
+
m_total = m1 + m2
|
| 467 |
+
|
| 468 |
+
if m_total == 0:
|
| 469 |
+
raise ValueError("Total mass flow rate cannot be zero")
|
| 470 |
+
|
| 471 |
+
w_mix = (m1 * w1 + m2 * w2) / m_total
|
| 472 |
+
h_mix = (m1 * h1 + m2 * h2) / m_total
|
| 473 |
+
|
| 474 |
+
# Find dry-bulb temperature for the mixed air
|
| 475 |
+
t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
|
| 476 |
+
|
| 477 |
+
# Calculate relative humidity for the mixed air
|
| 478 |
+
rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
|
| 479 |
+
|
| 480 |
+
# Return mixed air properties
|
| 481 |
+
return Psychrometrics.moist_air_properties(t_db_mix, rh_mix, p_atm)
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
# Create a singleton instance
|
| 485 |
+
psychrometrics = Psychrometrics()
|
| 486 |
+
|
| 487 |
+
# Example usage
|
| 488 |
+
if __name__ == "__main__":
|
| 489 |
+
# Calculate properties of air at 25°C and 50% RH
|
| 490 |
+
properties = psychrometrics.moist_air_properties(25, 50)
|
| 491 |
+
|
| 492 |
+
print("Air Properties at 25°C and 50% RH:")
|
| 493 |
+
print(f"Dry-bulb temperature: {properties['dry_bulb_temperature']:.2f} °C")
|
| 494 |
+
print(f"Wet-bulb temperature: {properties['wet_bulb_temperature']:.2f} °C")
|
| 495 |
+
print(f"Dew point temperature: {properties['dew_point_temperature']:.2f} °C")
|
| 496 |
+
print(f"Relative humidity: {properties['relative_humidity']:.2f} %")
|
| 497 |
+
print(f"Humidity ratio: {properties['humidity_ratio']:.6f} kg/kg")
|
| 498 |
+
print(f"Enthalpy: {properties['enthalpy']/1000:.2f} kJ/kg")
|
| 499 |
+
print(f"Specific volume: {properties['specific_volume']:.4f} m³/kg")
|
| 500 |
+
print(f"Density: {properties['density']:.4f} kg/m³")
|
| 501 |
+
print(f"Saturation pressure: {properties['saturation_pressure']/1000:.2f} kPa")
|
| 502 |
+
print(f"Partial pressure: {properties['partial_pressure']/1000:.2f} kPa")
|
utils/scenario_comparison.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Scenario comparison visualization module for HVAC Load Calculator.
|
| 3 |
+
This module provides visualization tools for comparing different scenarios.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
import plotly.express as px
|
| 11 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 12 |
+
import math
|
| 13 |
+
|
| 14 |
+
# Import calculation modules
|
| 15 |
+
from utils.cooling_load import CoolingLoadCalculator
|
| 16 |
+
from utils.heating_load import HeatingLoadCalculator
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ScenarioComparisonVisualization:
|
| 20 |
+
"""Class for scenario comparison visualization."""
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
def create_scenario_summary_table(scenarios: Dict[str, Dict[str, Any]]) -> pd.DataFrame:
|
| 24 |
+
"""
|
| 25 |
+
Create a summary table of different scenarios.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
scenarios: Dictionary with scenario data
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
DataFrame with scenario summary
|
| 32 |
+
"""
|
| 33 |
+
# Initialize data
|
| 34 |
+
data = []
|
| 35 |
+
|
| 36 |
+
# Process scenarios
|
| 37 |
+
for scenario_name, scenario_data in scenarios.items():
|
| 38 |
+
# Extract cooling and heating loads
|
| 39 |
+
cooling_loads = scenario_data.get("cooling_loads", {})
|
| 40 |
+
heating_loads = scenario_data.get("heating_loads", {})
|
| 41 |
+
|
| 42 |
+
# Create summary row
|
| 43 |
+
row = {
|
| 44 |
+
"Scenario": scenario_name,
|
| 45 |
+
"Cooling Load (W)": cooling_loads.get("total", 0),
|
| 46 |
+
"Sensible Heat Ratio": cooling_loads.get("sensible_heat_ratio", 0),
|
| 47 |
+
"Heating Load (W)": heating_loads.get("total", 0)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
# Add to data
|
| 51 |
+
data.append(row)
|
| 52 |
+
|
| 53 |
+
# Create DataFrame
|
| 54 |
+
df = pd.DataFrame(data)
|
| 55 |
+
|
| 56 |
+
return df
|
| 57 |
+
|
| 58 |
+
@staticmethod
|
| 59 |
+
def create_load_comparison_chart(scenarios: Dict[str, Dict[str, Any]], load_type: str = "cooling") -> go.Figure:
|
| 60 |
+
"""
|
| 61 |
+
Create a bar chart comparing loads across scenarios.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
scenarios: Dictionary with scenario data
|
| 65 |
+
load_type: Type of load to compare ("cooling" or "heating")
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
Plotly figure with load comparison
|
| 69 |
+
"""
|
| 70 |
+
# Initialize data
|
| 71 |
+
scenario_names = []
|
| 72 |
+
total_loads = []
|
| 73 |
+
component_loads = {}
|
| 74 |
+
|
| 75 |
+
# Process scenarios
|
| 76 |
+
for scenario_name, scenario_data in scenarios.items():
|
| 77 |
+
# Extract loads based on load type
|
| 78 |
+
if load_type == "cooling":
|
| 79 |
+
loads = scenario_data.get("cooling_loads", {})
|
| 80 |
+
components = ["walls", "roofs", "floors", "windows_conduction", "windows_solar",
|
| 81 |
+
"doors", "infiltration_sensible", "infiltration_latent",
|
| 82 |
+
"people_sensible", "people_latent", "lights", "equipment_sensible", "equipment_latent"]
|
| 83 |
+
else: # heating
|
| 84 |
+
loads = scenario_data.get("heating_loads", {})
|
| 85 |
+
components = ["walls", "roofs", "floors", "windows", "doors",
|
| 86 |
+
"infiltration_sensible", "infiltration_latent",
|
| 87 |
+
"ventilation_sensible", "ventilation_latent"]
|
| 88 |
+
|
| 89 |
+
# Add scenario name
|
| 90 |
+
scenario_names.append(scenario_name)
|
| 91 |
+
|
| 92 |
+
# Add total load
|
| 93 |
+
total_loads.append(loads.get("total", 0))
|
| 94 |
+
|
| 95 |
+
# Add component loads
|
| 96 |
+
for component in components:
|
| 97 |
+
if component not in component_loads:
|
| 98 |
+
component_loads[component] = []
|
| 99 |
+
|
| 100 |
+
component_loads[component].append(loads.get(component, 0))
|
| 101 |
+
|
| 102 |
+
# Create figure
|
| 103 |
+
fig = go.Figure()
|
| 104 |
+
|
| 105 |
+
# Add total load bars
|
| 106 |
+
fig.add_trace(go.Bar(
|
| 107 |
+
x=scenario_names,
|
| 108 |
+
y=total_loads,
|
| 109 |
+
name="Total Load",
|
| 110 |
+
marker_color="rgba(55, 83, 109, 0.7)",
|
| 111 |
+
opacity=0.7
|
| 112 |
+
))
|
| 113 |
+
|
| 114 |
+
# Add component load bars
|
| 115 |
+
for component, loads in component_loads.items():
|
| 116 |
+
# Skip components with zero loads
|
| 117 |
+
if sum(loads) == 0:
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
# Format component name for display
|
| 121 |
+
display_name = component.replace("_", " ").title()
|
| 122 |
+
|
| 123 |
+
fig.add_trace(go.Bar(
|
| 124 |
+
x=scenario_names,
|
| 125 |
+
y=loads,
|
| 126 |
+
name=display_name,
|
| 127 |
+
visible="legendonly"
|
| 128 |
+
))
|
| 129 |
+
|
| 130 |
+
# Update layout
|
| 131 |
+
title = f"{load_type.title()} Load Comparison"
|
| 132 |
+
y_title = f"{load_type.title()} Load (W)"
|
| 133 |
+
|
| 134 |
+
fig.update_layout(
|
| 135 |
+
title=title,
|
| 136 |
+
xaxis_title="Scenario",
|
| 137 |
+
yaxis_title=y_title,
|
| 138 |
+
barmode="group",
|
| 139 |
+
height=500,
|
| 140 |
+
legend=dict(
|
| 141 |
+
orientation="h",
|
| 142 |
+
yanchor="bottom",
|
| 143 |
+
y=1.02,
|
| 144 |
+
xanchor="right",
|
| 145 |
+
x=1
|
| 146 |
+
)
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
return fig
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def create_percentage_difference_chart(scenarios: Dict[str, Dict[str, Any]],
|
| 153 |
+
baseline_scenario: str,
|
| 154 |
+
load_type: str = "cooling") -> go.Figure:
|
| 155 |
+
"""
|
| 156 |
+
Create a bar chart showing percentage differences from a baseline scenario.
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
scenarios: Dictionary with scenario data
|
| 160 |
+
baseline_scenario: Name of the baseline scenario
|
| 161 |
+
load_type: Type of load to compare ("cooling" or "heating")
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
Plotly figure with percentage difference chart
|
| 165 |
+
"""
|
| 166 |
+
# Check if baseline scenario exists
|
| 167 |
+
if baseline_scenario not in scenarios:
|
| 168 |
+
raise ValueError(f"Baseline scenario '{baseline_scenario}' not found in scenarios")
|
| 169 |
+
|
| 170 |
+
# Get baseline loads
|
| 171 |
+
if load_type == "cooling":
|
| 172 |
+
baseline_loads = scenarios[baseline_scenario].get("cooling_loads", {})
|
| 173 |
+
components = ["walls", "roofs", "floors", "windows_conduction", "windows_solar",
|
| 174 |
+
"doors", "infiltration_sensible", "infiltration_latent",
|
| 175 |
+
"people_sensible", "people_latent", "lights", "equipment_sensible", "equipment_latent"]
|
| 176 |
+
else: # heating
|
| 177 |
+
baseline_loads = scenarios[baseline_scenario].get("heating_loads", {})
|
| 178 |
+
components = ["walls", "roofs", "floors", "windows", "doors",
|
| 179 |
+
"infiltration_sensible", "infiltration_latent",
|
| 180 |
+
"ventilation_sensible", "ventilation_latent"]
|
| 181 |
+
|
| 182 |
+
baseline_total = baseline_loads.get("total", 0)
|
| 183 |
+
|
| 184 |
+
# Initialize data
|
| 185 |
+
scenario_names = []
|
| 186 |
+
percentage_diffs = []
|
| 187 |
+
component_diffs = {}
|
| 188 |
+
|
| 189 |
+
# Process scenarios (excluding baseline)
|
| 190 |
+
for scenario_name, scenario_data in scenarios.items():
|
| 191 |
+
if scenario_name == baseline_scenario:
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
# Extract loads based on load type
|
| 195 |
+
if load_type == "cooling":
|
| 196 |
+
loads = scenario_data.get("cooling_loads", {})
|
| 197 |
+
else: # heating
|
| 198 |
+
loads = scenario_data.get("heating_loads", {})
|
| 199 |
+
|
| 200 |
+
# Add scenario name
|
| 201 |
+
scenario_names.append(scenario_name)
|
| 202 |
+
|
| 203 |
+
# Calculate percentage difference for total load
|
| 204 |
+
scenario_total = loads.get("total", 0)
|
| 205 |
+
if baseline_total != 0:
|
| 206 |
+
percentage_diff = (scenario_total - baseline_total) / baseline_total * 100
|
| 207 |
+
else:
|
| 208 |
+
percentage_diff = 0
|
| 209 |
+
|
| 210 |
+
percentage_diffs.append(percentage_diff)
|
| 211 |
+
|
| 212 |
+
# Calculate percentage differences for components
|
| 213 |
+
for component in components:
|
| 214 |
+
if component not in component_diffs:
|
| 215 |
+
component_diffs[component] = []
|
| 216 |
+
|
| 217 |
+
baseline_component = baseline_loads.get(component, 0)
|
| 218 |
+
scenario_component = loads.get(component, 0)
|
| 219 |
+
|
| 220 |
+
if baseline_component != 0:
|
| 221 |
+
component_diff = (scenario_component - baseline_component) / baseline_component * 100
|
| 222 |
+
else:
|
| 223 |
+
component_diff = 0
|
| 224 |
+
|
| 225 |
+
component_diffs[component].append(component_diff)
|
| 226 |
+
|
| 227 |
+
# Create figure
|
| 228 |
+
fig = go.Figure()
|
| 229 |
+
|
| 230 |
+
# Add total percentage difference bars
|
| 231 |
+
fig.add_trace(go.Bar(
|
| 232 |
+
x=scenario_names,
|
| 233 |
+
y=percentage_diffs,
|
| 234 |
+
name="Total Load",
|
| 235 |
+
marker_color="rgba(55, 83, 109, 0.7)",
|
| 236 |
+
opacity=0.7
|
| 237 |
+
))
|
| 238 |
+
|
| 239 |
+
# Add component percentage difference bars
|
| 240 |
+
for component, diffs in component_diffs.items():
|
| 241 |
+
# Skip components with zero differences
|
| 242 |
+
if sum([abs(diff) for diff in diffs]) == 0:
|
| 243 |
+
continue
|
| 244 |
+
|
| 245 |
+
# Format component name for display
|
| 246 |
+
display_name = component.replace("_", " ").title()
|
| 247 |
+
|
| 248 |
+
fig.add_trace(go.Bar(
|
| 249 |
+
x=scenario_names,
|
| 250 |
+
y=diffs,
|
| 251 |
+
name=display_name,
|
| 252 |
+
visible="legendonly"
|
| 253 |
+
))
|
| 254 |
+
|
| 255 |
+
# Update layout
|
| 256 |
+
title = f"{load_type.title()} Load Percentage Difference from {baseline_scenario}"
|
| 257 |
+
y_title = "Percentage Difference (%)"
|
| 258 |
+
|
| 259 |
+
fig.update_layout(
|
| 260 |
+
title=title,
|
| 261 |
+
xaxis_title="Scenario",
|
| 262 |
+
yaxis_title=y_title,
|
| 263 |
+
barmode="group",
|
| 264 |
+
height=500,
|
| 265 |
+
legend=dict(
|
| 266 |
+
orientation="h",
|
| 267 |
+
yanchor="bottom",
|
| 268 |
+
y=1.02,
|
| 269 |
+
xanchor="right",
|
| 270 |
+
x=1
|
| 271 |
+
)
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# Add zero line
|
| 275 |
+
fig.add_shape(
|
| 276 |
+
type="line",
|
| 277 |
+
x0=-0.5,
|
| 278 |
+
x1=len(scenario_names) - 0.5,
|
| 279 |
+
y0=0,
|
| 280 |
+
y1=0,
|
| 281 |
+
line=dict(
|
| 282 |
+
color="black",
|
| 283 |
+
width=1,
|
| 284 |
+
dash="dash"
|
| 285 |
+
)
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
return fig
|
| 289 |
+
|
| 290 |
+
@staticmethod
|
| 291 |
+
def create_radar_chart(scenarios: Dict[str, Dict[str, Any]], load_type: str = "cooling") -> go.Figure:
|
| 292 |
+
"""
|
| 293 |
+
Create a radar chart comparing key metrics across scenarios.
|
| 294 |
+
|
| 295 |
+
Args:
|
| 296 |
+
scenarios: Dictionary with scenario data
|
| 297 |
+
load_type: Type of load to compare ("cooling" or "heating")
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
Plotly figure with radar chart
|
| 301 |
+
"""
|
| 302 |
+
# Define metrics based on load type
|
| 303 |
+
if load_type == "cooling":
|
| 304 |
+
metrics = [
|
| 305 |
+
"total",
|
| 306 |
+
"total_sensible",
|
| 307 |
+
"total_latent",
|
| 308 |
+
"walls",
|
| 309 |
+
"roofs",
|
| 310 |
+
"windows_conduction",
|
| 311 |
+
"windows_solar",
|
| 312 |
+
"infiltration_sensible",
|
| 313 |
+
"people_sensible",
|
| 314 |
+
"lights",
|
| 315 |
+
"equipment_sensible"
|
| 316 |
+
]
|
| 317 |
+
metric_names = [
|
| 318 |
+
"Total Load",
|
| 319 |
+
"Sensible Load",
|
| 320 |
+
"Latent Load",
|
| 321 |
+
"Walls",
|
| 322 |
+
"Roofs",
|
| 323 |
+
"Windows (Conduction)",
|
| 324 |
+
"Windows (Solar)",
|
| 325 |
+
"Infiltration",
|
| 326 |
+
"People",
|
| 327 |
+
"Lights",
|
| 328 |
+
"Equipment"
|
| 329 |
+
]
|
| 330 |
+
else: # heating
|
| 331 |
+
metrics = [
|
| 332 |
+
"total",
|
| 333 |
+
"walls",
|
| 334 |
+
"roofs",
|
| 335 |
+
"floors",
|
| 336 |
+
"windows",
|
| 337 |
+
"doors",
|
| 338 |
+
"infiltration_sensible",
|
| 339 |
+
"ventilation_sensible"
|
| 340 |
+
]
|
| 341 |
+
metric_names = [
|
| 342 |
+
"Total Load",
|
| 343 |
+
"Walls",
|
| 344 |
+
"Roofs",
|
| 345 |
+
"Floors",
|
| 346 |
+
"Windows",
|
| 347 |
+
"Doors",
|
| 348 |
+
"Infiltration",
|
| 349 |
+
"Ventilation"
|
| 350 |
+
]
|
| 351 |
+
|
| 352 |
+
# Initialize figure
|
| 353 |
+
fig = go.Figure()
|
| 354 |
+
|
| 355 |
+
# Process scenarios
|
| 356 |
+
for scenario_name, scenario_data in scenarios.items():
|
| 357 |
+
# Extract loads based on load type
|
| 358 |
+
if load_type == "cooling":
|
| 359 |
+
loads = scenario_data.get("cooling_loads", {})
|
| 360 |
+
else: # heating
|
| 361 |
+
loads = scenario_data.get("heating_loads", {})
|
| 362 |
+
|
| 363 |
+
# Extract metric values
|
| 364 |
+
values = [loads.get(metric, 0) for metric in metrics]
|
| 365 |
+
|
| 366 |
+
# Add trace
|
| 367 |
+
fig.add_trace(go.Scatterpolar(
|
| 368 |
+
r=values,
|
| 369 |
+
theta=metric_names,
|
| 370 |
+
fill="toself",
|
| 371 |
+
name=scenario_name
|
| 372 |
+
))
|
| 373 |
+
|
| 374 |
+
# Update layout
|
| 375 |
+
title = f"{load_type.title()} Load Comparison (Radar Chart)"
|
| 376 |
+
|
| 377 |
+
fig.update_layout(
|
| 378 |
+
title=title,
|
| 379 |
+
polar=dict(
|
| 380 |
+
radialaxis=dict(
|
| 381 |
+
visible=True,
|
| 382 |
+
range=[0, max([max([scenarios[s].get(f"{load_type}_loads", {}).get(m, 0) for m in metrics]) for s in scenarios]) * 1.1]
|
| 383 |
+
)
|
| 384 |
+
),
|
| 385 |
+
height=600,
|
| 386 |
+
showlegend=True
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
return fig
|
| 390 |
+
|
| 391 |
+
@staticmethod
|
| 392 |
+
def create_parallel_coordinates_chart(scenarios: Dict[str, Dict[str, Any]]) -> go.Figure:
|
| 393 |
+
"""
|
| 394 |
+
Create a parallel coordinates chart comparing scenarios.
|
| 395 |
+
|
| 396 |
+
Args:
|
| 397 |
+
scenarios: Dictionary with scenario data
|
| 398 |
+
|
| 399 |
+
Returns:
|
| 400 |
+
Plotly figure with parallel coordinates chart
|
| 401 |
+
"""
|
| 402 |
+
# Initialize data
|
| 403 |
+
data = []
|
| 404 |
+
|
| 405 |
+
# Process scenarios
|
| 406 |
+
for scenario_name, scenario_data in scenarios.items():
|
| 407 |
+
# Extract cooling and heating loads
|
| 408 |
+
cooling_loads = scenario_data.get("cooling_loads", {})
|
| 409 |
+
heating_loads = scenario_data.get("heating_loads", {})
|
| 410 |
+
|
| 411 |
+
# Create data point
|
| 412 |
+
point = {
|
| 413 |
+
"Scenario": scenario_name,
|
| 414 |
+
"Cooling Load (W)": cooling_loads.get("total", 0),
|
| 415 |
+
"Heating Load (W)": heating_loads.get("total", 0),
|
| 416 |
+
"Sensible Heat Ratio": cooling_loads.get("sensible_heat_ratio", 0),
|
| 417 |
+
"Walls (Cooling)": cooling_loads.get("walls", 0),
|
| 418 |
+
"Windows (Cooling)": cooling_loads.get("windows_conduction", 0) + cooling_loads.get("windows_solar", 0),
|
| 419 |
+
"Internal Gains (Cooling)": cooling_loads.get("people_sensible", 0) + cooling_loads.get("lights", 0) + cooling_loads.get("equipment_sensible", 0),
|
| 420 |
+
"Walls (Heating)": heating_loads.get("walls", 0),
|
| 421 |
+
"Windows (Heating)": heating_loads.get("windows", 0),
|
| 422 |
+
"Infiltration (Heating)": heating_loads.get("infiltration_sensible", 0)
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
# Add to data
|
| 426 |
+
data.append(point)
|
| 427 |
+
|
| 428 |
+
# Create DataFrame
|
| 429 |
+
df = pd.DataFrame(data)
|
| 430 |
+
|
| 431 |
+
# Create figure
|
| 432 |
+
fig = px.parallel_coordinates(
|
| 433 |
+
df,
|
| 434 |
+
color="Cooling Load (W)",
|
| 435 |
+
labels={
|
| 436 |
+
"Scenario": "Scenario",
|
| 437 |
+
"Cooling Load (W)": "Cooling Load (W)",
|
| 438 |
+
"Heating Load (W)": "Heating Load (W)",
|
| 439 |
+
"Sensible Heat Ratio": "Sensible Heat Ratio",
|
| 440 |
+
"Walls (Cooling)": "Walls (Cooling)",
|
| 441 |
+
"Windows (Cooling)": "Windows (Cooling)",
|
| 442 |
+
"Internal Gains (Cooling)": "Internal Gains (Cooling)",
|
| 443 |
+
"Walls (Heating)": "Walls (Heating)",
|
| 444 |
+
"Windows (Heating)": "Windows (Heating)",
|
| 445 |
+
"Infiltration (Heating)": "Infiltration (Heating)"
|
| 446 |
+
},
|
| 447 |
+
color_continuous_scale=px.colors.sequential.Viridis
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
# Update layout
|
| 451 |
+
fig.update_layout(
|
| 452 |
+
title="Scenario Comparison (Parallel Coordinates)",
|
| 453 |
+
height=600
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
return fig
|
| 457 |
+
|
| 458 |
+
@staticmethod
|
| 459 |
+
def display_scenario_comparison(scenarios: Dict[str, Dict[str, Any]]) -> None:
|
| 460 |
+
"""
|
| 461 |
+
Display scenario comparison visualization in Streamlit.
|
| 462 |
+
|
| 463 |
+
Args:
|
| 464 |
+
scenarios: Dictionary with scenario data
|
| 465 |
+
"""
|
| 466 |
+
st.header("Scenario Comparison Visualization")
|
| 467 |
+
|
| 468 |
+
# Check if scenarios exist
|
| 469 |
+
if not scenarios:
|
| 470 |
+
st.warning("No scenarios available for comparison.")
|
| 471 |
+
return
|
| 472 |
+
|
| 473 |
+
# Create tabs for different visualizations
|
| 474 |
+
tab1, tab2, tab3, tab4, tab5 = st.tabs([
|
| 475 |
+
"Scenario Summary",
|
| 476 |
+
"Load Comparison",
|
| 477 |
+
"Percentage Difference",
|
| 478 |
+
"Radar Chart",
|
| 479 |
+
"Parallel Coordinates"
|
| 480 |
+
])
|
| 481 |
+
|
| 482 |
+
with tab1:
|
| 483 |
+
st.subheader("Scenario Summary")
|
| 484 |
+
df = ScenarioComparisonVisualization.create_scenario_summary_table(scenarios)
|
| 485 |
+
st.dataframe(df, use_container_width=True)
|
| 486 |
+
|
| 487 |
+
# Add download button for CSV
|
| 488 |
+
csv = df.to_csv(index=False).encode('utf-8')
|
| 489 |
+
st.download_button(
|
| 490 |
+
label="Download Scenario Summary as CSV",
|
| 491 |
+
data=csv,
|
| 492 |
+
file_name="scenario_summary.csv",
|
| 493 |
+
mime="text/csv"
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
with tab2:
|
| 497 |
+
st.subheader("Load Comparison")
|
| 498 |
+
|
| 499 |
+
# Add load type selector
|
| 500 |
+
load_type = st.radio(
|
| 501 |
+
"Select Load Type",
|
| 502 |
+
["cooling", "heating"],
|
| 503 |
+
horizontal=True,
|
| 504 |
+
key="load_comparison_type"
|
| 505 |
+
)
|
| 506 |
+
|
| 507 |
+
# Create and display chart
|
| 508 |
+
fig = ScenarioComparisonVisualization.create_load_comparison_chart(scenarios, load_type)
|
| 509 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 510 |
+
|
| 511 |
+
with tab3:
|
| 512 |
+
st.subheader("Percentage Difference")
|
| 513 |
+
|
| 514 |
+
# Add baseline scenario selector
|
| 515 |
+
baseline_scenario = st.selectbox(
|
| 516 |
+
"Select Baseline Scenario",
|
| 517 |
+
list(scenarios.keys()),
|
| 518 |
+
key="baseline_scenario"
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
# Add load type selector
|
| 522 |
+
load_type = st.radio(
|
| 523 |
+
"Select Load Type",
|
| 524 |
+
["cooling", "heating"],
|
| 525 |
+
horizontal=True,
|
| 526 |
+
key="percentage_diff_type"
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
# Create and display chart
|
| 530 |
+
try:
|
| 531 |
+
fig = ScenarioComparisonVisualization.create_percentage_difference_chart(
|
| 532 |
+
scenarios, baseline_scenario, load_type
|
| 533 |
+
)
|
| 534 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 535 |
+
except ValueError as e:
|
| 536 |
+
st.error(str(e))
|
| 537 |
+
|
| 538 |
+
with tab4:
|
| 539 |
+
st.subheader("Radar Chart")
|
| 540 |
+
|
| 541 |
+
# Add load type selector
|
| 542 |
+
load_type = st.radio(
|
| 543 |
+
"Select Load Type",
|
| 544 |
+
["cooling", "heating"],
|
| 545 |
+
horizontal=True,
|
| 546 |
+
key="radar_chart_type"
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
# Create and display chart
|
| 550 |
+
fig = ScenarioComparisonVisualization.create_radar_chart(scenarios, load_type)
|
| 551 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 552 |
+
|
| 553 |
+
with tab5:
|
| 554 |
+
st.subheader("Parallel Coordinates")
|
| 555 |
+
|
| 556 |
+
# Create and display chart
|
| 557 |
+
fig = ScenarioComparisonVisualization.create_parallel_coordinates_chart(scenarios)
|
| 558 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
# Create a singleton instance
|
| 562 |
+
scenario_comparison = ScenarioComparisonVisualization()
|
| 563 |
+
|
| 564 |
+
# Example usage
|
| 565 |
+
if __name__ == "__main__":
|
| 566 |
+
import streamlit as st
|
| 567 |
+
|
| 568 |
+
# Create sample scenarios
|
| 569 |
+
scenarios = {
|
| 570 |
+
"Base Case": {
|
| 571 |
+
"cooling_loads": {
|
| 572 |
+
"total": 5000,
|
| 573 |
+
"total_sensible": 4000,
|
| 574 |
+
"total_latent": 1000,
|
| 575 |
+
"sensible_heat_ratio": 0.8,
|
| 576 |
+
"walls": 1000,
|
| 577 |
+
"roofs": 800,
|
| 578 |
+
"floors": 200,
|
| 579 |
+
"windows_conduction": 500,
|
| 580 |
+
"windows_solar": 800,
|
| 581 |
+
"doors": 100,
|
| 582 |
+
"infiltration_sensible": 300,
|
| 583 |
+
"infiltration_latent": 200,
|
| 584 |
+
"people_sensible": 300,
|
| 585 |
+
"people_latent": 200,
|
| 586 |
+
"lights": 400,
|
| 587 |
+
"equipment_sensible": 400,
|
| 588 |
+
"equipment_latent": 600
|
| 589 |
+
},
|
| 590 |
+
"heating_loads": {
|
| 591 |
+
"total": 6000,
|
| 592 |
+
"walls": 1500,
|
| 593 |
+
"roofs": 1000,
|
| 594 |
+
"floors": 500,
|
| 595 |
+
"windows": 1200,
|
| 596 |
+
"doors": 200,
|
| 597 |
+
"infiltration_sensible": 800,
|
| 598 |
+
"infiltration_latent": 0,
|
| 599 |
+
"ventilation_sensible": 800,
|
| 600 |
+
"ventilation_latent": 0,
|
| 601 |
+
"internal_gains_offset": 1000
|
| 602 |
+
}
|
| 603 |
+
},
|
| 604 |
+
"Improved Insulation": {
|
| 605 |
+
"cooling_loads": {
|
| 606 |
+
"total": 4200,
|
| 607 |
+
"total_sensible": 3500,
|
| 608 |
+
"total_latent": 700,
|
| 609 |
+
"sensible_heat_ratio": 0.83,
|
| 610 |
+
"walls": 600,
|
| 611 |
+
"roofs": 500,
|
| 612 |
+
"floors": 150,
|
| 613 |
+
"windows_conduction": 500,
|
| 614 |
+
"windows_solar": 800,
|
| 615 |
+
"doors": 100,
|
| 616 |
+
"infiltration_sensible": 300,
|
| 617 |
+
"infiltration_latent": 200,
|
| 618 |
+
"people_sensible": 300,
|
| 619 |
+
"people_latent": 200,
|
| 620 |
+
"lights": 400,
|
| 621 |
+
"equipment_sensible": 400,
|
| 622 |
+
"equipment_latent": 300
|
| 623 |
+
},
|
| 624 |
+
"heating_loads": {
|
| 625 |
+
"total": 4500,
|
| 626 |
+
"walls": 900,
|
| 627 |
+
"roofs": 600,
|
| 628 |
+
"floors": 300,
|
| 629 |
+
"windows": 1200,
|
| 630 |
+
"doors": 200,
|
| 631 |
+
"infiltration_sensible": 800,
|
| 632 |
+
"infiltration_latent": 0,
|
| 633 |
+
"ventilation_sensible": 800,
|
| 634 |
+
"ventilation_latent": 0,
|
| 635 |
+
"internal_gains_offset": 1000
|
| 636 |
+
}
|
| 637 |
+
},
|
| 638 |
+
"Better Windows": {
|
| 639 |
+
"cooling_loads": {
|
| 640 |
+
"total": 4000,
|
| 641 |
+
"total_sensible": 3300,
|
| 642 |
+
"total_latent": 700,
|
| 643 |
+
"sensible_heat_ratio": 0.83,
|
| 644 |
+
"walls": 1000,
|
| 645 |
+
"roofs": 800,
|
| 646 |
+
"floors": 200,
|
| 647 |
+
"windows_conduction": 250,
|
| 648 |
+
"windows_solar": 400,
|
| 649 |
+
"doors": 100,
|
| 650 |
+
"infiltration_sensible": 300,
|
| 651 |
+
"infiltration_latent": 200,
|
| 652 |
+
"people_sensible": 300,
|
| 653 |
+
"people_latent": 200,
|
| 654 |
+
"lights": 400,
|
| 655 |
+
"equipment_sensible": 400,
|
| 656 |
+
"equipment_latent": 300
|
| 657 |
+
},
|
| 658 |
+
"heating_loads": {
|
| 659 |
+
"total": 5000,
|
| 660 |
+
"walls": 1500,
|
| 661 |
+
"roofs": 1000,
|
| 662 |
+
"floors": 500,
|
| 663 |
+
"windows": 600,
|
| 664 |
+
"doors": 200,
|
| 665 |
+
"infiltration_sensible": 800,
|
| 666 |
+
"infiltration_latent": 0,
|
| 667 |
+
"ventilation_sensible": 800,
|
| 668 |
+
"ventilation_latent": 0,
|
| 669 |
+
"internal_gains_offset": 1000
|
| 670 |
+
}
|
| 671 |
+
}
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
# Display scenario comparison
|
| 675 |
+
scenario_comparison.display_scenario_comparison(scenarios)
|
utils/shading_system.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shading system module for HVAC Load Calculator.
|
| 3 |
+
This module implements shading type selection and coverage percentage interface.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
from enum import Enum
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
# Define paths
|
| 15 |
+
DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ShadingType(Enum):
|
| 19 |
+
"""Enumeration for shading types."""
|
| 20 |
+
NONE = "None"
|
| 21 |
+
INTERNAL = "Internal"
|
| 22 |
+
EXTERNAL = "External"
|
| 23 |
+
BETWEEN_GLASS = "Between-glass"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class ShadingDevice:
|
| 28 |
+
"""Class representing a shading device."""
|
| 29 |
+
|
| 30 |
+
id: str
|
| 31 |
+
name: str
|
| 32 |
+
shading_type: ShadingType
|
| 33 |
+
shading_coefficient: float # 0-1 (1 = no shading)
|
| 34 |
+
coverage_percentage: float = 100.0 # 0-100%
|
| 35 |
+
description: str = ""
|
| 36 |
+
|
| 37 |
+
def __post_init__(self):
|
| 38 |
+
"""Validate shading device data after initialization."""
|
| 39 |
+
if self.shading_coefficient < 0 or self.shading_coefficient > 1:
|
| 40 |
+
raise ValueError("Shading coefficient must be between 0 and 1")
|
| 41 |
+
if self.coverage_percentage < 0 or self.coverage_percentage > 100:
|
| 42 |
+
raise ValueError("Coverage percentage must be between 0 and 100")
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def effective_shading_coefficient(self) -> float:
|
| 46 |
+
"""Calculate the effective shading coefficient considering coverage percentage."""
|
| 47 |
+
# If coverage is less than 100%, the effective coefficient is a weighted average
|
| 48 |
+
# between the device coefficient and 1.0 (no shading)
|
| 49 |
+
coverage_factor = self.coverage_percentage / 100.0
|
| 50 |
+
return self.shading_coefficient * coverage_factor + 1.0 * (1 - coverage_factor)
|
| 51 |
+
|
| 52 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 53 |
+
"""Convert the shading device to a dictionary."""
|
| 54 |
+
return {
|
| 55 |
+
"id": self.id,
|
| 56 |
+
"name": self.name,
|
| 57 |
+
"shading_type": self.shading_type.value,
|
| 58 |
+
"shading_coefficient": self.shading_coefficient,
|
| 59 |
+
"coverage_percentage": self.coverage_percentage,
|
| 60 |
+
"description": self.description,
|
| 61 |
+
"effective_shading_coefficient": self.effective_shading_coefficient
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class ShadingSystem:
|
| 66 |
+
"""Class for managing shading devices and calculations."""
|
| 67 |
+
|
| 68 |
+
def __init__(self):
|
| 69 |
+
"""Initialize shading system."""
|
| 70 |
+
self.shading_devices = {}
|
| 71 |
+
self.load_preset_devices()
|
| 72 |
+
|
| 73 |
+
def load_preset_devices(self) -> None:
|
| 74 |
+
"""Load preset shading devices."""
|
| 75 |
+
# Internal shading devices
|
| 76 |
+
self.shading_devices["preset_venetian_blinds"] = ShadingDevice(
|
| 77 |
+
id="preset_venetian_blinds",
|
| 78 |
+
name="Venetian Blinds",
|
| 79 |
+
shading_type=ShadingType.INTERNAL,
|
| 80 |
+
shading_coefficient=0.6,
|
| 81 |
+
description="Standard internal venetian blinds"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
self.shading_devices["preset_roller_shade"] = ShadingDevice(
|
| 85 |
+
id="preset_roller_shade",
|
| 86 |
+
name="Roller Shade",
|
| 87 |
+
shading_type=ShadingType.INTERNAL,
|
| 88 |
+
shading_coefficient=0.7,
|
| 89 |
+
description="Standard internal roller shade"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
self.shading_devices["preset_drapes_light"] = ShadingDevice(
|
| 93 |
+
id="preset_drapes_light",
|
| 94 |
+
name="Light Drapes",
|
| 95 |
+
shading_type=ShadingType.INTERNAL,
|
| 96 |
+
shading_coefficient=0.8,
|
| 97 |
+
description="Light-colored internal drapes"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
self.shading_devices["preset_drapes_dark"] = ShadingDevice(
|
| 101 |
+
id="preset_drapes_dark",
|
| 102 |
+
name="Dark Drapes",
|
| 103 |
+
shading_type=ShadingType.INTERNAL,
|
| 104 |
+
shading_coefficient=0.5,
|
| 105 |
+
description="Dark-colored internal drapes"
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# External shading devices
|
| 109 |
+
self.shading_devices["preset_overhang"] = ShadingDevice(
|
| 110 |
+
id="preset_overhang",
|
| 111 |
+
name="Overhang",
|
| 112 |
+
shading_type=ShadingType.EXTERNAL,
|
| 113 |
+
shading_coefficient=0.4,
|
| 114 |
+
description="External overhang"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
self.shading_devices["preset_louvers"] = ShadingDevice(
|
| 118 |
+
id="preset_louvers",
|
| 119 |
+
name="Louvers",
|
| 120 |
+
shading_type=ShadingType.EXTERNAL,
|
| 121 |
+
shading_coefficient=0.3,
|
| 122 |
+
description="External louvers"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
self.shading_devices["preset_exterior_screen"] = ShadingDevice(
|
| 126 |
+
id="preset_exterior_screen",
|
| 127 |
+
name="Exterior Screen",
|
| 128 |
+
shading_type=ShadingType.EXTERNAL,
|
| 129 |
+
shading_coefficient=0.5,
|
| 130 |
+
description="External screen"
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# Between-glass shading devices
|
| 134 |
+
self.shading_devices["preset_between_glass_blinds"] = ShadingDevice(
|
| 135 |
+
id="preset_between_glass_blinds",
|
| 136 |
+
name="Between-glass Blinds",
|
| 137 |
+
shading_type=ShadingType.BETWEEN_GLASS,
|
| 138 |
+
shading_coefficient=0.5,
|
| 139 |
+
description="Blinds between glass panes"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
def get_device(self, device_id: str) -> Optional[ShadingDevice]:
|
| 143 |
+
"""
|
| 144 |
+
Get a shading device by ID.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
device_id: Device identifier
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
ShadingDevice object or None if not found
|
| 151 |
+
"""
|
| 152 |
+
return self.shading_devices.get(device_id)
|
| 153 |
+
|
| 154 |
+
def get_devices_by_type(self, shading_type: ShadingType) -> List[ShadingDevice]:
|
| 155 |
+
"""
|
| 156 |
+
Get all shading devices of a specific type.
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
shading_type: Shading type
|
| 160 |
+
|
| 161 |
+
Returns:
|
| 162 |
+
List of ShadingDevice objects
|
| 163 |
+
"""
|
| 164 |
+
return [device for device in self.shading_devices.values()
|
| 165 |
+
if device.shading_type == shading_type]
|
| 166 |
+
|
| 167 |
+
def get_preset_devices(self) -> List[ShadingDevice]:
|
| 168 |
+
"""
|
| 169 |
+
Get all preset shading devices.
|
| 170 |
+
|
| 171 |
+
Returns:
|
| 172 |
+
List of ShadingDevice objects
|
| 173 |
+
"""
|
| 174 |
+
return [device for device_id, device in self.shading_devices.items()
|
| 175 |
+
if device_id.startswith("preset_")]
|
| 176 |
+
|
| 177 |
+
def get_custom_devices(self) -> List[ShadingDevice]:
|
| 178 |
+
"""
|
| 179 |
+
Get all custom shading devices.
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
List of ShadingDevice objects
|
| 183 |
+
"""
|
| 184 |
+
return [device for device_id, device in self.shading_devices.items()
|
| 185 |
+
if device_id.startswith("custom_")]
|
| 186 |
+
|
| 187 |
+
def add_device(self, name: str, shading_type: ShadingType,
|
| 188 |
+
shading_coefficient: float, coverage_percentage: float = 100.0,
|
| 189 |
+
description: str = "") -> str:
|
| 190 |
+
"""
|
| 191 |
+
Add a custom shading device.
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
name: Device name
|
| 195 |
+
shading_type: Shading type
|
| 196 |
+
shading_coefficient: Shading coefficient (0-1)
|
| 197 |
+
coverage_percentage: Coverage percentage (0-100)
|
| 198 |
+
description: Device description
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
Device ID
|
| 202 |
+
"""
|
| 203 |
+
import uuid
|
| 204 |
+
|
| 205 |
+
device_id = f"custom_shading_{str(uuid.uuid4())[:8]}"
|
| 206 |
+
device = ShadingDevice(
|
| 207 |
+
id=device_id,
|
| 208 |
+
name=name,
|
| 209 |
+
shading_type=shading_type,
|
| 210 |
+
shading_coefficient=shading_coefficient,
|
| 211 |
+
coverage_percentage=coverage_percentage,
|
| 212 |
+
description=description
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
self.shading_devices[device_id] = device
|
| 216 |
+
return device_id
|
| 217 |
+
|
| 218 |
+
def update_device(self, device_id: str, name: str = None,
|
| 219 |
+
shading_coefficient: float = None,
|
| 220 |
+
coverage_percentage: float = None,
|
| 221 |
+
description: str = None) -> bool:
|
| 222 |
+
"""
|
| 223 |
+
Update a shading device.
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
device_id: Device identifier
|
| 227 |
+
name: New device name (optional)
|
| 228 |
+
shading_coefficient: New shading coefficient (optional)
|
| 229 |
+
coverage_percentage: New coverage percentage (optional)
|
| 230 |
+
description: New device description (optional)
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
True if the device was updated, False otherwise
|
| 234 |
+
"""
|
| 235 |
+
if device_id not in self.shading_devices:
|
| 236 |
+
return False
|
| 237 |
+
|
| 238 |
+
# Don't allow updating preset devices
|
| 239 |
+
if device_id.startswith("preset_"):
|
| 240 |
+
return False
|
| 241 |
+
|
| 242 |
+
device = self.shading_devices[device_id]
|
| 243 |
+
|
| 244 |
+
if name is not None:
|
| 245 |
+
device.name = name
|
| 246 |
+
|
| 247 |
+
if shading_coefficient is not None:
|
| 248 |
+
if shading_coefficient < 0 or shading_coefficient > 1:
|
| 249 |
+
return False
|
| 250 |
+
device.shading_coefficient = shading_coefficient
|
| 251 |
+
|
| 252 |
+
if coverage_percentage is not None:
|
| 253 |
+
if coverage_percentage < 0 or coverage_percentage > 100:
|
| 254 |
+
return False
|
| 255 |
+
device.coverage_percentage = coverage_percentage
|
| 256 |
+
|
| 257 |
+
if description is not None:
|
| 258 |
+
device.description = description
|
| 259 |
+
|
| 260 |
+
return True
|
| 261 |
+
|
| 262 |
+
def remove_device(self, device_id: str) -> bool:
|
| 263 |
+
"""
|
| 264 |
+
Remove a shading device.
|
| 265 |
+
|
| 266 |
+
Args:
|
| 267 |
+
device_id: Device identifier
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
True if the device was removed, False otherwise
|
| 271 |
+
"""
|
| 272 |
+
if device_id not in self.shading_devices:
|
| 273 |
+
return False
|
| 274 |
+
|
| 275 |
+
# Don't allow removing preset devices
|
| 276 |
+
if device_id.startswith("preset_"):
|
| 277 |
+
return False
|
| 278 |
+
|
| 279 |
+
del self.shading_devices[device_id]
|
| 280 |
+
return True
|
| 281 |
+
|
| 282 |
+
def calculate_effective_shgc(self, base_shgc: float, device_id: str) -> float:
|
| 283 |
+
"""
|
| 284 |
+
Calculate the effective SHGC (Solar Heat Gain Coefficient) with shading.
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
base_shgc: Base SHGC of the window
|
| 288 |
+
device_id: Shading device identifier
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
Effective SHGC with shading
|
| 292 |
+
"""
|
| 293 |
+
if device_id not in self.shading_devices:
|
| 294 |
+
return base_shgc
|
| 295 |
+
|
| 296 |
+
device = self.shading_devices[device_id]
|
| 297 |
+
return base_shgc * device.effective_shading_coefficient
|
| 298 |
+
|
| 299 |
+
def export_to_json(self, file_path: str) -> None:
|
| 300 |
+
"""
|
| 301 |
+
Export all shading devices to a JSON file.
|
| 302 |
+
|
| 303 |
+
Args:
|
| 304 |
+
file_path: Path to the output JSON file
|
| 305 |
+
"""
|
| 306 |
+
data = {device_id: device.to_dict() for device_id, device in self.shading_devices.items()}
|
| 307 |
+
|
| 308 |
+
with open(file_path, 'w') as f:
|
| 309 |
+
json.dump(data, f, indent=4)
|
| 310 |
+
|
| 311 |
+
def import_from_json(self, file_path: str) -> int:
|
| 312 |
+
"""
|
| 313 |
+
Import shading devices from a JSON file.
|
| 314 |
+
|
| 315 |
+
Args:
|
| 316 |
+
file_path: Path to the input JSON file
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
Number of devices imported
|
| 320 |
+
"""
|
| 321 |
+
with open(file_path, 'r') as f:
|
| 322 |
+
data = json.load(f)
|
| 323 |
+
|
| 324 |
+
count = 0
|
| 325 |
+
for device_id, device_data in data.items():
|
| 326 |
+
try:
|
| 327 |
+
shading_type = ShadingType(device_data["shading_type"])
|
| 328 |
+
device = ShadingDevice(
|
| 329 |
+
id=device_id,
|
| 330 |
+
name=device_data["name"],
|
| 331 |
+
shading_type=shading_type,
|
| 332 |
+
shading_coefficient=device_data["shading_coefficient"],
|
| 333 |
+
coverage_percentage=device_data.get("coverage_percentage", 100.0),
|
| 334 |
+
description=device_data.get("description", "")
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
self.shading_devices[device_id] = device
|
| 338 |
+
count += 1
|
| 339 |
+
except Exception as e:
|
| 340 |
+
print(f"Error importing shading device {device_id}: {e}")
|
| 341 |
+
|
| 342 |
+
return count
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
# Create a singleton instance
|
| 346 |
+
shading_system = ShadingSystem()
|
| 347 |
+
|
| 348 |
+
# Export shading system to JSON if needed
|
| 349 |
+
if __name__ == "__main__":
|
| 350 |
+
shading_system.export_to_json(os.path.join(DATA_DIR, "data", "shading_system.json"))
|
utils/time_based_visualization.py
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Time-based visualization module for HVAC Load Calculator.
|
| 3 |
+
This module provides visualization tools for time-based load analysis.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import plotly.graph_objects as go
|
| 10 |
+
import plotly.express as px
|
| 11 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 12 |
+
import math
|
| 13 |
+
import calendar
|
| 14 |
+
from datetime import datetime, timedelta
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TimeBasedVisualization:
|
| 18 |
+
"""Class for time-based visualization."""
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
def create_hourly_load_profile(hourly_loads: Dict[str, List[float]],
|
| 22 |
+
date: str = "Jul 15") -> go.Figure:
|
| 23 |
+
"""
|
| 24 |
+
Create an hourly load profile chart.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
hourly_loads: Dictionary with hourly load data
|
| 28 |
+
date: Date for the profile (e.g., "Jul 15")
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Plotly figure with hourly load profile
|
| 32 |
+
"""
|
| 33 |
+
# Create hour labels
|
| 34 |
+
hours = list(range(24))
|
| 35 |
+
hour_labels = [f"{h}:00" for h in hours]
|
| 36 |
+
|
| 37 |
+
# Create figure
|
| 38 |
+
fig = go.Figure()
|
| 39 |
+
|
| 40 |
+
# Add total load trace
|
| 41 |
+
if "total" in hourly_loads:
|
| 42 |
+
fig.add_trace(go.Scatter(
|
| 43 |
+
x=hour_labels,
|
| 44 |
+
y=hourly_loads["total"],
|
| 45 |
+
mode="lines+markers",
|
| 46 |
+
name="Total Load",
|
| 47 |
+
line=dict(color="rgba(55, 83, 109, 1)", width=3),
|
| 48 |
+
marker=dict(size=8)
|
| 49 |
+
))
|
| 50 |
+
|
| 51 |
+
# Add component load traces
|
| 52 |
+
for component, loads in hourly_loads.items():
|
| 53 |
+
if component == "total":
|
| 54 |
+
continue
|
| 55 |
+
|
| 56 |
+
# Format component name for display
|
| 57 |
+
display_name = component.replace("_", " ").title()
|
| 58 |
+
|
| 59 |
+
fig.add_trace(go.Scatter(
|
| 60 |
+
x=hour_labels,
|
| 61 |
+
y=loads,
|
| 62 |
+
mode="lines+markers",
|
| 63 |
+
name=display_name,
|
| 64 |
+
marker=dict(size=6),
|
| 65 |
+
line=dict(width=2)
|
| 66 |
+
))
|
| 67 |
+
|
| 68 |
+
# Update layout
|
| 69 |
+
fig.update_layout(
|
| 70 |
+
title=f"Hourly Load Profile ({date})",
|
| 71 |
+
xaxis_title="Hour of Day",
|
| 72 |
+
yaxis_title="Load (W)",
|
| 73 |
+
height=500,
|
| 74 |
+
legend=dict(
|
| 75 |
+
orientation="h",
|
| 76 |
+
yanchor="bottom",
|
| 77 |
+
y=1.02,
|
| 78 |
+
xanchor="right",
|
| 79 |
+
x=1
|
| 80 |
+
),
|
| 81 |
+
hovermode="x unified"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return fig
|
| 85 |
+
|
| 86 |
+
@staticmethod
|
| 87 |
+
def create_daily_load_profile(daily_loads: Dict[str, List[float]],
|
| 88 |
+
month: str = "July") -> go.Figure:
|
| 89 |
+
"""
|
| 90 |
+
Create a daily load profile chart for a month.
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
daily_loads: Dictionary with daily load data
|
| 94 |
+
month: Month name
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
Plotly figure with daily load profile
|
| 98 |
+
"""
|
| 99 |
+
# Get number of days in month
|
| 100 |
+
month_num = list(calendar.month_name).index(month)
|
| 101 |
+
year = datetime.now().year
|
| 102 |
+
num_days = calendar.monthrange(year, month_num)[1]
|
| 103 |
+
|
| 104 |
+
# Create day labels
|
| 105 |
+
days = list(range(1, num_days + 1))
|
| 106 |
+
day_labels = [f"{d}" for d in days]
|
| 107 |
+
|
| 108 |
+
# Create figure
|
| 109 |
+
fig = go.Figure()
|
| 110 |
+
|
| 111 |
+
# Add total load trace
|
| 112 |
+
if "total" in daily_loads:
|
| 113 |
+
fig.add_trace(go.Scatter(
|
| 114 |
+
x=day_labels,
|
| 115 |
+
y=daily_loads["total"][:num_days],
|
| 116 |
+
mode="lines+markers",
|
| 117 |
+
name="Total Load",
|
| 118 |
+
line=dict(color="rgba(55, 83, 109, 1)", width=3),
|
| 119 |
+
marker=dict(size=8)
|
| 120 |
+
))
|
| 121 |
+
|
| 122 |
+
# Add component load traces
|
| 123 |
+
for component, loads in daily_loads.items():
|
| 124 |
+
if component == "total":
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
# Format component name for display
|
| 128 |
+
display_name = component.replace("_", " ").title()
|
| 129 |
+
|
| 130 |
+
fig.add_trace(go.Scatter(
|
| 131 |
+
x=day_labels,
|
| 132 |
+
y=loads[:num_days],
|
| 133 |
+
mode="lines+markers",
|
| 134 |
+
name=display_name,
|
| 135 |
+
marker=dict(size=6),
|
| 136 |
+
line=dict(width=2)
|
| 137 |
+
))
|
| 138 |
+
|
| 139 |
+
# Update layout
|
| 140 |
+
fig.update_layout(
|
| 141 |
+
title=f"Daily Load Profile ({month})",
|
| 142 |
+
xaxis_title="Day of Month",
|
| 143 |
+
yaxis_title="Load (W)",
|
| 144 |
+
height=500,
|
| 145 |
+
legend=dict(
|
| 146 |
+
orientation="h",
|
| 147 |
+
yanchor="bottom",
|
| 148 |
+
y=1.02,
|
| 149 |
+
xanchor="right",
|
| 150 |
+
x=1
|
| 151 |
+
),
|
| 152 |
+
hovermode="x unified"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return fig
|
| 156 |
+
|
| 157 |
+
@staticmethod
|
| 158 |
+
def create_monthly_load_comparison(monthly_loads: Dict[str, List[float]],
|
| 159 |
+
load_type: str = "cooling") -> go.Figure:
|
| 160 |
+
"""
|
| 161 |
+
Create a monthly load comparison chart.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
monthly_loads: Dictionary with monthly load data
|
| 165 |
+
load_type: Type of load ("cooling" or "heating")
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
Plotly figure with monthly load comparison
|
| 169 |
+
"""
|
| 170 |
+
# Create month labels
|
| 171 |
+
months = list(calendar.month_name)[1:]
|
| 172 |
+
|
| 173 |
+
# Create figure
|
| 174 |
+
fig = go.Figure()
|
| 175 |
+
|
| 176 |
+
# Add total load bars
|
| 177 |
+
if "total" in monthly_loads:
|
| 178 |
+
fig.add_trace(go.Bar(
|
| 179 |
+
x=months,
|
| 180 |
+
y=monthly_loads["total"],
|
| 181 |
+
name="Total Load",
|
| 182 |
+
marker_color="rgba(55, 83, 109, 0.7)",
|
| 183 |
+
opacity=0.7
|
| 184 |
+
))
|
| 185 |
+
|
| 186 |
+
# Add component load bars
|
| 187 |
+
for component, loads in monthly_loads.items():
|
| 188 |
+
if component == "total":
|
| 189 |
+
continue
|
| 190 |
+
|
| 191 |
+
# Format component name for display
|
| 192 |
+
display_name = component.replace("_", " ").title()
|
| 193 |
+
|
| 194 |
+
fig.add_trace(go.Bar(
|
| 195 |
+
x=months,
|
| 196 |
+
y=loads,
|
| 197 |
+
name=display_name,
|
| 198 |
+
visible="legendonly"
|
| 199 |
+
))
|
| 200 |
+
|
| 201 |
+
# Update layout
|
| 202 |
+
title = f"Monthly {load_type.title()} Load Comparison"
|
| 203 |
+
y_title = f"{load_type.title()} Load (kWh)"
|
| 204 |
+
|
| 205 |
+
fig.update_layout(
|
| 206 |
+
title=title,
|
| 207 |
+
xaxis_title="Month",
|
| 208 |
+
yaxis_title=y_title,
|
| 209 |
+
height=500,
|
| 210 |
+
legend=dict(
|
| 211 |
+
orientation="h",
|
| 212 |
+
yanchor="bottom",
|
| 213 |
+
y=1.02,
|
| 214 |
+
xanchor="right",
|
| 215 |
+
x=1
|
| 216 |
+
),
|
| 217 |
+
hovermode="x unified"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
return fig
|
| 221 |
+
|
| 222 |
+
@staticmethod
|
| 223 |
+
def create_annual_load_distribution(annual_loads: Dict[str, float],
|
| 224 |
+
load_type: str = "cooling") -> go.Figure:
|
| 225 |
+
"""
|
| 226 |
+
Create an annual load distribution pie chart.
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
annual_loads: Dictionary with annual load data by component
|
| 230 |
+
load_type: Type of load ("cooling" or "heating")
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
Plotly figure with annual load distribution
|
| 234 |
+
"""
|
| 235 |
+
# Extract components and values
|
| 236 |
+
components = []
|
| 237 |
+
values = []
|
| 238 |
+
|
| 239 |
+
for component, load in annual_loads.items():
|
| 240 |
+
if component == "total":
|
| 241 |
+
continue
|
| 242 |
+
|
| 243 |
+
# Format component name for display
|
| 244 |
+
display_name = component.replace("_", " ").title()
|
| 245 |
+
components.append(display_name)
|
| 246 |
+
values.append(load)
|
| 247 |
+
|
| 248 |
+
# Create pie chart
|
| 249 |
+
fig = go.Figure(data=[go.Pie(
|
| 250 |
+
labels=components,
|
| 251 |
+
values=values,
|
| 252 |
+
hole=0.3,
|
| 253 |
+
textinfo="label+percent",
|
| 254 |
+
insidetextorientation="radial"
|
| 255 |
+
)])
|
| 256 |
+
|
| 257 |
+
# Update layout
|
| 258 |
+
title = f"Annual {load_type.title()} Load Distribution"
|
| 259 |
+
|
| 260 |
+
fig.update_layout(
|
| 261 |
+
title=title,
|
| 262 |
+
height=500,
|
| 263 |
+
legend=dict(
|
| 264 |
+
orientation="h",
|
| 265 |
+
yanchor="bottom",
|
| 266 |
+
y=1.02,
|
| 267 |
+
xanchor="right",
|
| 268 |
+
x=1
|
| 269 |
+
)
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
return fig
|
| 273 |
+
|
| 274 |
+
@staticmethod
|
| 275 |
+
def create_peak_load_analysis(peak_loads: Dict[str, Dict[str, Any]],
|
| 276 |
+
load_type: str = "cooling") -> go.Figure:
|
| 277 |
+
"""
|
| 278 |
+
Create a peak load analysis chart.
|
| 279 |
+
|
| 280 |
+
Args:
|
| 281 |
+
peak_loads: Dictionary with peak load data
|
| 282 |
+
load_type: Type of load ("cooling" or "heating")
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
Plotly figure with peak load analysis
|
| 286 |
+
"""
|
| 287 |
+
# Extract peak load data
|
| 288 |
+
components = []
|
| 289 |
+
values = []
|
| 290 |
+
times = []
|
| 291 |
+
|
| 292 |
+
for component, data in peak_loads.items():
|
| 293 |
+
if component == "total":
|
| 294 |
+
continue
|
| 295 |
+
|
| 296 |
+
# Format component name for display
|
| 297 |
+
display_name = component.replace("_", " ").title()
|
| 298 |
+
components.append(display_name)
|
| 299 |
+
values.append(data["value"])
|
| 300 |
+
times.append(data["time"])
|
| 301 |
+
|
| 302 |
+
# Create bar chart
|
| 303 |
+
fig = go.Figure(data=[go.Bar(
|
| 304 |
+
x=components,
|
| 305 |
+
y=values,
|
| 306 |
+
text=times,
|
| 307 |
+
textposition="auto",
|
| 308 |
+
hovertemplate="<b>%{x}</b><br>Peak Load: %{y:.0f} W<br>Time: %{text}<extra></extra>"
|
| 309 |
+
)])
|
| 310 |
+
|
| 311 |
+
# Update layout
|
| 312 |
+
title = f"Peak {load_type.title()} Load Analysis"
|
| 313 |
+
y_title = f"Peak {load_type.title()} Load (W)"
|
| 314 |
+
|
| 315 |
+
fig.update_layout(
|
| 316 |
+
title=title,
|
| 317 |
+
xaxis_title="Component",
|
| 318 |
+
yaxis_title=y_title,
|
| 319 |
+
height=500
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
return fig
|
| 323 |
+
|
| 324 |
+
@staticmethod
|
| 325 |
+
def create_load_duration_curve(hourly_loads: List[float],
|
| 326 |
+
load_type: str = "cooling") -> go.Figure:
|
| 327 |
+
"""
|
| 328 |
+
Create a load duration curve.
|
| 329 |
+
|
| 330 |
+
Args:
|
| 331 |
+
hourly_loads: List of hourly loads for the year
|
| 332 |
+
load_type: Type of load ("cooling" or "heating")
|
| 333 |
+
|
| 334 |
+
Returns:
|
| 335 |
+
Plotly figure with load duration curve
|
| 336 |
+
"""
|
| 337 |
+
# Sort loads in descending order
|
| 338 |
+
sorted_loads = sorted(hourly_loads, reverse=True)
|
| 339 |
+
|
| 340 |
+
# Create hour indices
|
| 341 |
+
hours = list(range(1, len(sorted_loads) + 1))
|
| 342 |
+
|
| 343 |
+
# Create figure
|
| 344 |
+
fig = go.Figure(data=[go.Scatter(
|
| 345 |
+
x=hours,
|
| 346 |
+
y=sorted_loads,
|
| 347 |
+
mode="lines",
|
| 348 |
+
line=dict(color="rgba(55, 83, 109, 1)", width=2),
|
| 349 |
+
fill="tozeroy",
|
| 350 |
+
fillcolor="rgba(55, 83, 109, 0.2)"
|
| 351 |
+
)])
|
| 352 |
+
|
| 353 |
+
# Update layout
|
| 354 |
+
title = f"{load_type.title()} Load Duration Curve"
|
| 355 |
+
x_title = "Hours"
|
| 356 |
+
y_title = f"{load_type.title()} Load (W)"
|
| 357 |
+
|
| 358 |
+
fig.update_layout(
|
| 359 |
+
title=title,
|
| 360 |
+
xaxis_title=x_title,
|
| 361 |
+
yaxis_title=y_title,
|
| 362 |
+
height=500,
|
| 363 |
+
xaxis=dict(
|
| 364 |
+
type="log",
|
| 365 |
+
range=[0, math.log10(len(hours))]
|
| 366 |
+
)
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
return fig
|
| 370 |
+
|
| 371 |
+
@staticmethod
|
| 372 |
+
def create_heat_map(hourly_data: List[List[float]],
|
| 373 |
+
x_labels: List[str],
|
| 374 |
+
y_labels: List[str],
|
| 375 |
+
title: str,
|
| 376 |
+
colorscale: str = "Viridis") -> go.Figure:
|
| 377 |
+
"""
|
| 378 |
+
Create a heat map visualization.
|
| 379 |
+
|
| 380 |
+
Args:
|
| 381 |
+
hourly_data: 2D list of hourly data
|
| 382 |
+
x_labels: Labels for x-axis
|
| 383 |
+
y_labels: Labels for y-axis
|
| 384 |
+
title: Chart title
|
| 385 |
+
colorscale: Colorscale for the heatmap
|
| 386 |
+
|
| 387 |
+
Returns:
|
| 388 |
+
Plotly figure with heat map
|
| 389 |
+
"""
|
| 390 |
+
# Create figure
|
| 391 |
+
fig = go.Figure(data=go.Heatmap(
|
| 392 |
+
z=hourly_data,
|
| 393 |
+
x=x_labels,
|
| 394 |
+
y=y_labels,
|
| 395 |
+
colorscale=colorscale,
|
| 396 |
+
colorbar=dict(title="Load (W)")
|
| 397 |
+
))
|
| 398 |
+
|
| 399 |
+
# Update layout
|
| 400 |
+
fig.update_layout(
|
| 401 |
+
title=title,
|
| 402 |
+
height=600,
|
| 403 |
+
xaxis=dict(
|
| 404 |
+
title="Hour of Day",
|
| 405 |
+
tickmode="array",
|
| 406 |
+
tickvals=list(range(0, 24, 2)),
|
| 407 |
+
ticktext=[f"{h}:00" for h in range(0, 24, 2)]
|
| 408 |
+
),
|
| 409 |
+
yaxis=dict(
|
| 410 |
+
title="Day",
|
| 411 |
+
autorange="reversed"
|
| 412 |
+
)
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
return fig
|
| 416 |
+
|
| 417 |
+
@staticmethod
|
| 418 |
+
def display_time_based_visualization(cooling_loads: Dict[str, Any] = None,
|
| 419 |
+
heating_loads: Dict[str, Any] = None) -> None:
|
| 420 |
+
"""
|
| 421 |
+
Display time-based visualization in Streamlit.
|
| 422 |
+
|
| 423 |
+
Args:
|
| 424 |
+
cooling_loads: Dictionary with cooling load data
|
| 425 |
+
heating_loads: Dictionary with heating load data
|
| 426 |
+
"""
|
| 427 |
+
st.header("Time-Based Visualization")
|
| 428 |
+
|
| 429 |
+
# Check if load data exists
|
| 430 |
+
if cooling_loads is None and heating_loads is None:
|
| 431 |
+
st.warning("No load data available for visualization.")
|
| 432 |
+
|
| 433 |
+
# Create sample data for demonstration
|
| 434 |
+
st.info("Using sample data for demonstration.")
|
| 435 |
+
|
| 436 |
+
# Generate sample cooling loads
|
| 437 |
+
cooling_loads = {
|
| 438 |
+
"hourly": {
|
| 439 |
+
"total": [1000 + 500 * math.sin(h * math.pi / 12) + 1000 * math.sin(h * math.pi / 6) for h in range(24)],
|
| 440 |
+
"walls": [300 + 150 * math.sin(h * math.pi / 12) for h in range(24)],
|
| 441 |
+
"roofs": [400 + 200 * math.sin(h * math.pi / 12) for h in range(24)],
|
| 442 |
+
"windows": [500 + 300 * math.sin(h * math.pi / 6) for h in range(24)],
|
| 443 |
+
"internal": [200 + 100 * math.sin(h * math.pi / 8) for h in range(24)]
|
| 444 |
+
},
|
| 445 |
+
"daily": {
|
| 446 |
+
"total": [2000 + 1000 * math.sin(d * math.pi / 15) for d in range(1, 32)],
|
| 447 |
+
"walls": [600 + 300 * math.sin(d * math.pi / 15) for d in range(1, 32)],
|
| 448 |
+
"roofs": [800 + 400 * math.sin(d * math.pi / 15) for d in range(1, 32)],
|
| 449 |
+
"windows": [1000 + 500 * math.sin(d * math.pi / 15) for d in range(1, 32)]
|
| 450 |
+
},
|
| 451 |
+
"monthly": {
|
| 452 |
+
"total": [1000, 1200, 1500, 2000, 2500, 3000, 3500, 3200, 2800, 2000, 1500, 1200],
|
| 453 |
+
"walls": [300, 350, 400, 500, 600, 700, 800, 750, 650, 500, 400, 350],
|
| 454 |
+
"roofs": [400, 450, 500, 600, 700, 800, 900, 850, 750, 600, 500, 450],
|
| 455 |
+
"windows": [500, 550, 600, 700, 800, 900, 1000, 950, 850, 700, 600, 550]
|
| 456 |
+
},
|
| 457 |
+
"annual": {
|
| 458 |
+
"total": 25000,
|
| 459 |
+
"walls": 6000,
|
| 460 |
+
"roofs": 8000,
|
| 461 |
+
"windows": 9000,
|
| 462 |
+
"internal": 2000
|
| 463 |
+
},
|
| 464 |
+
"peak": {
|
| 465 |
+
"total": {"value": 3500, "time": "Jul 15, 15:00"},
|
| 466 |
+
"walls": {"value": 800, "time": "Jul 15, 16:00"},
|
| 467 |
+
"roofs": {"value": 900, "time": "Jul 15, 14:00"},
|
| 468 |
+
"windows": {"value": 1000, "time": "Jul 15, 15:00"},
|
| 469 |
+
"internal": {"value": 200, "time": "Jul 15, 17:00"}
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
# Generate sample heating loads
|
| 474 |
+
heating_loads = {
|
| 475 |
+
"hourly": {
|
| 476 |
+
"total": [3000 - 1000 * math.sin(h * math.pi / 12) for h in range(24)],
|
| 477 |
+
"walls": [900 - 300 * math.sin(h * math.pi / 12) for h in range(24)],
|
| 478 |
+
"roofs": [1200 - 400 * math.sin(h * math.pi / 12) for h in range(24)],
|
| 479 |
+
"windows": [1500 - 500 * math.sin(h * math.pi / 12) for h in range(24)]
|
| 480 |
+
},
|
| 481 |
+
"daily": {
|
| 482 |
+
"total": [3000 - 1000 * math.sin(d * math.pi / 15) for d in range(1, 32)],
|
| 483 |
+
"walls": [900 - 300 * math.sin(d * math.pi / 15) for d in range(1, 32)],
|
| 484 |
+
"roofs": [1200 - 400 * math.sin(d * math.pi / 15) for d in range(1, 32)],
|
| 485 |
+
"windows": [1500 - 500 * math.sin(d * math.pi / 15) for d in range(1, 32)]
|
| 486 |
+
},
|
| 487 |
+
"monthly": {
|
| 488 |
+
"total": [3500, 3200, 2800, 2000, 1500, 1000, 800, 1000, 1500, 2000, 2800, 3500],
|
| 489 |
+
"walls": [1050, 960, 840, 600, 450, 300, 240, 300, 450, 600, 840, 1050],
|
| 490 |
+
"roofs": [1400, 1280, 1120, 800, 600, 400, 320, 400, 600, 800, 1120, 1400],
|
| 491 |
+
"windows": [1750, 1600, 1400, 1000, 750, 500, 400, 500, 750, 1000, 1400, 1750]
|
| 492 |
+
},
|
| 493 |
+
"annual": {
|
| 494 |
+
"total": 25000,
|
| 495 |
+
"walls": 7500,
|
| 496 |
+
"roofs": 10000,
|
| 497 |
+
"windows": 12500,
|
| 498 |
+
"infiltration": 5000
|
| 499 |
+
},
|
| 500 |
+
"peak": {
|
| 501 |
+
"total": {"value": 3500, "time": "Jan 15, 06:00"},
|
| 502 |
+
"walls": {"value": 1050, "time": "Jan 15, 06:00"},
|
| 503 |
+
"roofs": {"value": 1400, "time": "Jan 15, 06:00"},
|
| 504 |
+
"windows": {"value": 1750, "time": "Jan 15, 06:00"},
|
| 505 |
+
"infiltration": {"value": 500, "time": "Jan 15, 06:00"}
|
| 506 |
+
}
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
# Create tabs for different visualizations
|
| 510 |
+
tab1, tab2, tab3, tab4, tab5 = st.tabs([
|
| 511 |
+
"Hourly Profiles",
|
| 512 |
+
"Monthly Comparison",
|
| 513 |
+
"Annual Distribution",
|
| 514 |
+
"Peak Load Analysis",
|
| 515 |
+
"Heat Maps"
|
| 516 |
+
])
|
| 517 |
+
|
| 518 |
+
with tab1:
|
| 519 |
+
st.subheader("Hourly Load Profiles")
|
| 520 |
+
|
| 521 |
+
# Add load type selector
|
| 522 |
+
load_type = st.radio(
|
| 523 |
+
"Select Load Type",
|
| 524 |
+
["cooling", "heating"],
|
| 525 |
+
horizontal=True,
|
| 526 |
+
key="hourly_profile_type"
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
# Add date selector
|
| 530 |
+
date = st.selectbox(
|
| 531 |
+
"Select Date",
|
| 532 |
+
["Jan 15", "Apr 15", "Jul 15", "Oct 15"],
|
| 533 |
+
index=2,
|
| 534 |
+
key="hourly_profile_date"
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
# Get appropriate load data
|
| 538 |
+
if load_type == "cooling":
|
| 539 |
+
hourly_data = cooling_loads.get("hourly", {})
|
| 540 |
+
else:
|
| 541 |
+
hourly_data = heating_loads.get("hourly", {})
|
| 542 |
+
|
| 543 |
+
# Create and display chart
|
| 544 |
+
fig = TimeBasedVisualization.create_hourly_load_profile(hourly_data, date)
|
| 545 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 546 |
+
|
| 547 |
+
# Add daily profile option
|
| 548 |
+
st.subheader("Daily Load Profiles")
|
| 549 |
+
|
| 550 |
+
# Add month selector
|
| 551 |
+
month = st.selectbox(
|
| 552 |
+
"Select Month",
|
| 553 |
+
list(calendar.month_name)[1:],
|
| 554 |
+
index=6, # July
|
| 555 |
+
key="daily_profile_month"
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
# Get appropriate load data
|
| 559 |
+
if load_type == "cooling":
|
| 560 |
+
daily_data = cooling_loads.get("daily", {})
|
| 561 |
+
else:
|
| 562 |
+
daily_data = heating_loads.get("daily", {})
|
| 563 |
+
|
| 564 |
+
# Create and display chart
|
| 565 |
+
fig = TimeBasedVisualization.create_daily_load_profile(daily_data, month)
|
| 566 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 567 |
+
|
| 568 |
+
with tab2:
|
| 569 |
+
st.subheader("Monthly Load Comparison")
|
| 570 |
+
|
| 571 |
+
# Add load type selector
|
| 572 |
+
load_type = st.radio(
|
| 573 |
+
"Select Load Type",
|
| 574 |
+
["cooling", "heating"],
|
| 575 |
+
horizontal=True,
|
| 576 |
+
key="monthly_comparison_type"
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Get appropriate load data
|
| 580 |
+
if load_type == "cooling":
|
| 581 |
+
monthly_data = cooling_loads.get("monthly", {})
|
| 582 |
+
else:
|
| 583 |
+
monthly_data = heating_loads.get("monthly", {})
|
| 584 |
+
|
| 585 |
+
# Create and display chart
|
| 586 |
+
fig = TimeBasedVisualization.create_monthly_load_comparison(monthly_data, load_type)
|
| 587 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 588 |
+
|
| 589 |
+
# Add download button for CSV
|
| 590 |
+
monthly_df = pd.DataFrame(monthly_data)
|
| 591 |
+
monthly_df.index = list(calendar.month_name)[1:]
|
| 592 |
+
|
| 593 |
+
csv = monthly_df.to_csv().encode('utf-8')
|
| 594 |
+
st.download_button(
|
| 595 |
+
label=f"Download Monthly {load_type.title()} Loads as CSV",
|
| 596 |
+
data=csv,
|
| 597 |
+
file_name=f"monthly_{load_type}_loads.csv",
|
| 598 |
+
mime="text/csv"
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
+
with tab3:
|
| 602 |
+
st.subheader("Annual Load Distribution")
|
| 603 |
+
|
| 604 |
+
# Add load type selector
|
| 605 |
+
load_type = st.radio(
|
| 606 |
+
"Select Load Type",
|
| 607 |
+
["cooling", "heating"],
|
| 608 |
+
horizontal=True,
|
| 609 |
+
key="annual_distribution_type"
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
# Get appropriate load data
|
| 613 |
+
if load_type == "cooling":
|
| 614 |
+
annual_data = cooling_loads.get("annual", {})
|
| 615 |
+
else:
|
| 616 |
+
annual_data = heating_loads.get("annual", {})
|
| 617 |
+
|
| 618 |
+
# Create and display chart
|
| 619 |
+
fig = TimeBasedVisualization.create_annual_load_distribution(annual_data, load_type)
|
| 620 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 621 |
+
|
| 622 |
+
# Display annual total
|
| 623 |
+
total = annual_data.get("total", 0)
|
| 624 |
+
st.metric(f"Total Annual {load_type.title()} Load", f"{total:,.0f} kWh")
|
| 625 |
+
|
| 626 |
+
# Add download button for CSV
|
| 627 |
+
annual_df = pd.DataFrame({"Component": list(annual_data.keys()), "Load (kWh)": list(annual_data.values())})
|
| 628 |
+
|
| 629 |
+
csv = annual_df.to_csv(index=False).encode('utf-8')
|
| 630 |
+
st.download_button(
|
| 631 |
+
label=f"Download Annual {load_type.title()} Loads as CSV",
|
| 632 |
+
data=csv,
|
| 633 |
+
file_name=f"annual_{load_type}_loads.csv",
|
| 634 |
+
mime="text/csv"
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
with tab4:
|
| 638 |
+
st.subheader("Peak Load Analysis")
|
| 639 |
+
|
| 640 |
+
# Add load type selector
|
| 641 |
+
load_type = st.radio(
|
| 642 |
+
"Select Load Type",
|
| 643 |
+
["cooling", "heating"],
|
| 644 |
+
horizontal=True,
|
| 645 |
+
key="peak_load_type"
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
# Get appropriate load data
|
| 649 |
+
if load_type == "cooling":
|
| 650 |
+
peak_data = cooling_loads.get("peak", {})
|
| 651 |
+
else:
|
| 652 |
+
peak_data = heating_loads.get("peak", {})
|
| 653 |
+
|
| 654 |
+
# Create and display chart
|
| 655 |
+
fig = TimeBasedVisualization.create_peak_load_analysis(peak_data, load_type)
|
| 656 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 657 |
+
|
| 658 |
+
# Display peak total
|
| 659 |
+
peak_total = peak_data.get("total", {}).get("value", 0)
|
| 660 |
+
peak_time = peak_data.get("total", {}).get("time", "")
|
| 661 |
+
|
| 662 |
+
st.metric(f"Peak {load_type.title()} Load", f"{peak_total:,.0f} W")
|
| 663 |
+
st.write(f"Peak Time: {peak_time}")
|
| 664 |
+
|
| 665 |
+
# Add download button for CSV
|
| 666 |
+
peak_df = pd.DataFrame({
|
| 667 |
+
"Component": list(peak_data.keys()),
|
| 668 |
+
"Peak Load (W)": [data.get("value", 0) for data in peak_data.values()],
|
| 669 |
+
"Time": [data.get("time", "") for data in peak_data.values()]
|
| 670 |
+
})
|
| 671 |
+
|
| 672 |
+
csv = peak_df.to_csv(index=False).encode('utf-8')
|
| 673 |
+
st.download_button(
|
| 674 |
+
label=f"Download Peak {load_type.title()} Loads as CSV",
|
| 675 |
+
data=csv,
|
| 676 |
+
file_name=f"peak_{load_type}_loads.csv",
|
| 677 |
+
mime="text/csv"
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
with tab5:
|
| 681 |
+
st.subheader("Heat Maps")
|
| 682 |
+
|
| 683 |
+
# Add load type selector
|
| 684 |
+
load_type = st.radio(
|
| 685 |
+
"Select Load Type",
|
| 686 |
+
["cooling", "heating"],
|
| 687 |
+
horizontal=True,
|
| 688 |
+
key="heat_map_type"
|
| 689 |
+
)
|
| 690 |
+
|
| 691 |
+
# Add month selector
|
| 692 |
+
month = st.selectbox(
|
| 693 |
+
"Select Month",
|
| 694 |
+
list(calendar.month_name)[1:],
|
| 695 |
+
index=6, # July
|
| 696 |
+
key="heat_map_month"
|
| 697 |
+
)
|
| 698 |
+
|
| 699 |
+
# Generate heat map data
|
| 700 |
+
month_num = list(calendar.month_name).index(month)
|
| 701 |
+
year = datetime.now().year
|
| 702 |
+
num_days = calendar.monthrange(year, month_num)[1]
|
| 703 |
+
|
| 704 |
+
# Get appropriate hourly data
|
| 705 |
+
if load_type == "cooling":
|
| 706 |
+
hourly_data = cooling_loads.get("hourly", {}).get("total", [])
|
| 707 |
+
else:
|
| 708 |
+
hourly_data = heating_loads.get("hourly", {}).get("total", [])
|
| 709 |
+
|
| 710 |
+
# Create 2D array for heat map
|
| 711 |
+
heat_map_data = []
|
| 712 |
+
for day in range(1, num_days + 1):
|
| 713 |
+
# Generate hourly data with day-to-day variation
|
| 714 |
+
day_factor = 1 + 0.2 * math.sin(day * math.pi / 15)
|
| 715 |
+
day_data = [load * day_factor for load in hourly_data]
|
| 716 |
+
heat_map_data.append(day_data)
|
| 717 |
+
|
| 718 |
+
# Create hour and day labels
|
| 719 |
+
hour_labels = list(range(24))
|
| 720 |
+
day_labels = list(range(1, num_days + 1))
|
| 721 |
+
|
| 722 |
+
# Create and display heat map
|
| 723 |
+
title = f"{load_type.title()} Load Heat Map ({month})"
|
| 724 |
+
colorscale = "Hot" if load_type == "cooling" else "Ice"
|
| 725 |
+
|
| 726 |
+
fig = TimeBasedVisualization.create_heat_map(heat_map_data, hour_labels, day_labels, title, colorscale)
|
| 727 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 728 |
+
|
| 729 |
+
# Add explanation
|
| 730 |
+
st.info(
|
| 731 |
+
"The heat map shows the hourly load pattern for each day of the selected month. "
|
| 732 |
+
"Darker colors indicate higher loads. This visualization helps identify peak load periods "
|
| 733 |
+
"and daily/weekly patterns."
|
| 734 |
+
)
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
# Create a singleton instance
|
| 738 |
+
time_based_visualization = TimeBasedVisualization()
|
| 739 |
+
|
| 740 |
+
# Example usage
|
| 741 |
+
if __name__ == "__main__":
|
| 742 |
+
import streamlit as st
|
| 743 |
+
|
| 744 |
+
# Display time-based visualization with sample data
|
| 745 |
+
time_based_visualization.display_time_based_visualization()
|
utils/u_value_calculator.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
U-Value calculator module for HVAC Load Calculator.
|
| 3 |
+
This module implements the layer-by-layer assembly builder and U-value calculation functions.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
|
| 13 |
+
# Import data models
|
| 14 |
+
from data.building_components import MaterialLayer
|
| 15 |
+
from data.reference_data import reference_data
|
| 16 |
+
|
| 17 |
+
# Define paths
|
| 18 |
+
DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class MaterialAssembly:
|
| 23 |
+
"""Class representing a material assembly for U-value calculation."""
|
| 24 |
+
|
| 25 |
+
name: str
|
| 26 |
+
description: str = ""
|
| 27 |
+
layers: List[MaterialLayer] = field(default_factory=list)
|
| 28 |
+
|
| 29 |
+
# Surface resistances (m²·K/W)
|
| 30 |
+
r_si: float = 0.13 # Interior surface resistance
|
| 31 |
+
r_se: float = 0.04 # Exterior surface resistance
|
| 32 |
+
|
| 33 |
+
def add_layer(self, layer: MaterialLayer) -> None:
|
| 34 |
+
"""
|
| 35 |
+
Add a material layer to the assembly.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
layer: MaterialLayer object
|
| 39 |
+
"""
|
| 40 |
+
self.layers.append(layer)
|
| 41 |
+
|
| 42 |
+
def remove_layer(self, index: int) -> bool:
|
| 43 |
+
"""
|
| 44 |
+
Remove a material layer from the assembly.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
index: Index of the layer to remove
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
True if the layer was removed, False otherwise
|
| 51 |
+
"""
|
| 52 |
+
if index < 0 or index >= len(self.layers):
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
self.layers.pop(index)
|
| 56 |
+
return True
|
| 57 |
+
|
| 58 |
+
def move_layer(self, from_index: int, to_index: int) -> bool:
|
| 59 |
+
"""
|
| 60 |
+
Move a material layer within the assembly.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
from_index: Current index of the layer
|
| 64 |
+
to_index: New index for the layer
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
True if the layer was moved, False otherwise
|
| 68 |
+
"""
|
| 69 |
+
if (from_index < 0 or from_index >= len(self.layers) or
|
| 70 |
+
to_index < 0 or to_index >= len(self.layers)):
|
| 71 |
+
return False
|
| 72 |
+
|
| 73 |
+
layer = self.layers.pop(from_index)
|
| 74 |
+
self.layers.insert(to_index, layer)
|
| 75 |
+
return True
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def total_thickness(self) -> float:
|
| 79 |
+
"""Calculate the total thickness of the assembly in meters."""
|
| 80 |
+
return sum(layer.thickness for layer in self.layers)
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def r_value_layers(self) -> float:
|
| 84 |
+
"""Calculate the total thermal resistance of all layers in m²·K/W."""
|
| 85 |
+
return sum(layer.r_value for layer in self.layers)
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def r_value_total(self) -> float:
|
| 89 |
+
"""Calculate the total thermal resistance including surface resistances in m²·K/W."""
|
| 90 |
+
return self.r_si + self.r_value_layers + self.r_se
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def u_value(self) -> float:
|
| 94 |
+
"""Calculate the U-value of the assembly in W/(m²·K)."""
|
| 95 |
+
if self.r_value_total == 0:
|
| 96 |
+
return float('inf')
|
| 97 |
+
return 1 / self.r_value_total
|
| 98 |
+
|
| 99 |
+
@property
|
| 100 |
+
def thermal_mass(self) -> Optional[float]:
|
| 101 |
+
"""Calculate the total thermal mass of the assembly in J/(m²·K)."""
|
| 102 |
+
masses = [layer.thermal_mass for layer in self.layers]
|
| 103 |
+
if None in masses:
|
| 104 |
+
return None
|
| 105 |
+
return sum(masses)
|
| 106 |
+
|
| 107 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 108 |
+
"""Convert the material assembly to a dictionary."""
|
| 109 |
+
return {
|
| 110 |
+
"name": self.name,
|
| 111 |
+
"description": self.description,
|
| 112 |
+
"layers": [layer.to_dict() for layer in self.layers],
|
| 113 |
+
"r_si": self.r_si,
|
| 114 |
+
"r_se": self.r_se,
|
| 115 |
+
"total_thickness": self.total_thickness,
|
| 116 |
+
"r_value_layers": self.r_value_layers,
|
| 117 |
+
"r_value_total": self.r_value_total,
|
| 118 |
+
"u_value": self.u_value,
|
| 119 |
+
"thermal_mass": self.thermal_mass
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class UValueCalculator:
|
| 124 |
+
"""Class for calculating U-values of material assemblies."""
|
| 125 |
+
|
| 126 |
+
def __init__(self):
|
| 127 |
+
"""Initialize U-value calculator."""
|
| 128 |
+
self.assemblies = {}
|
| 129 |
+
self.load_preset_assemblies()
|
| 130 |
+
|
| 131 |
+
def load_preset_assemblies(self) -> None:
|
| 132 |
+
"""Load preset material assemblies."""
|
| 133 |
+
# Create preset assemblies from reference data
|
| 134 |
+
|
| 135 |
+
# Wall assemblies
|
| 136 |
+
for wall_id, wall_data in reference_data.wall_types.items():
|
| 137 |
+
# Create material layers
|
| 138 |
+
layers = []
|
| 139 |
+
for layer_data in wall_data.get("layers", []):
|
| 140 |
+
material_id = layer_data.get("material")
|
| 141 |
+
thickness = layer_data.get("thickness")
|
| 142 |
+
|
| 143 |
+
material = reference_data.get_material(material_id)
|
| 144 |
+
if material:
|
| 145 |
+
layer = MaterialLayer(
|
| 146 |
+
name=material["name"],
|
| 147 |
+
thickness=thickness,
|
| 148 |
+
conductivity=material["conductivity"],
|
| 149 |
+
density=material.get("density"),
|
| 150 |
+
specific_heat=material.get("specific_heat")
|
| 151 |
+
)
|
| 152 |
+
layers.append(layer)
|
| 153 |
+
|
| 154 |
+
# Create assembly
|
| 155 |
+
assembly_id = f"preset_wall_{wall_id}"
|
| 156 |
+
assembly = MaterialAssembly(
|
| 157 |
+
name=wall_data["name"],
|
| 158 |
+
description=wall_data["description"],
|
| 159 |
+
layers=layers
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
self.assemblies[assembly_id] = assembly
|
| 163 |
+
|
| 164 |
+
# Roof assemblies
|
| 165 |
+
for roof_id, roof_data in reference_data.roof_types.items():
|
| 166 |
+
# Create material layers
|
| 167 |
+
layers = []
|
| 168 |
+
for layer_data in roof_data.get("layers", []):
|
| 169 |
+
material_id = layer_data.get("material")
|
| 170 |
+
thickness = layer_data.get("thickness")
|
| 171 |
+
|
| 172 |
+
material = reference_data.get_material(material_id)
|
| 173 |
+
if material:
|
| 174 |
+
layer = MaterialLayer(
|
| 175 |
+
name=material["name"],
|
| 176 |
+
thickness=thickness,
|
| 177 |
+
conductivity=material["conductivity"],
|
| 178 |
+
density=material.get("density"),
|
| 179 |
+
specific_heat=material.get("specific_heat")
|
| 180 |
+
)
|
| 181 |
+
layers.append(layer)
|
| 182 |
+
|
| 183 |
+
# Create assembly
|
| 184 |
+
assembly_id = f"preset_roof_{roof_id}"
|
| 185 |
+
assembly = MaterialAssembly(
|
| 186 |
+
name=roof_data["name"],
|
| 187 |
+
description=roof_data["description"],
|
| 188 |
+
layers=layers
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
self.assemblies[assembly_id] = assembly
|
| 192 |
+
|
| 193 |
+
# Floor assemblies
|
| 194 |
+
for floor_id, floor_data in reference_data.floor_types.items():
|
| 195 |
+
# Create material layers
|
| 196 |
+
layers = []
|
| 197 |
+
for layer_data in floor_data.get("layers", []):
|
| 198 |
+
material_id = layer_data.get("material")
|
| 199 |
+
thickness = layer_data.get("thickness")
|
| 200 |
+
|
| 201 |
+
material = reference_data.get_material(material_id)
|
| 202 |
+
if material:
|
| 203 |
+
layer = MaterialLayer(
|
| 204 |
+
name=material["name"],
|
| 205 |
+
thickness=thickness,
|
| 206 |
+
conductivity=material["conductivity"],
|
| 207 |
+
density=material.get("density"),
|
| 208 |
+
specific_heat=material.get("specific_heat")
|
| 209 |
+
)
|
| 210 |
+
layers.append(layer)
|
| 211 |
+
|
| 212 |
+
# Create assembly
|
| 213 |
+
assembly_id = f"preset_floor_{floor_id}"
|
| 214 |
+
assembly = MaterialAssembly(
|
| 215 |
+
name=floor_data["name"],
|
| 216 |
+
description=floor_data["description"],
|
| 217 |
+
layers=layers
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
self.assemblies[assembly_id] = assembly
|
| 221 |
+
|
| 222 |
+
def get_assembly(self, assembly_id: str) -> Optional[MaterialAssembly]:
|
| 223 |
+
"""
|
| 224 |
+
Get a material assembly by ID.
|
| 225 |
+
|
| 226 |
+
Args:
|
| 227 |
+
assembly_id: Assembly identifier
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
MaterialAssembly object or None if not found
|
| 231 |
+
"""
|
| 232 |
+
return self.assemblies.get(assembly_id)
|
| 233 |
+
|
| 234 |
+
def get_preset_assemblies(self) -> Dict[str, MaterialAssembly]:
|
| 235 |
+
"""
|
| 236 |
+
Get all preset material assemblies.
|
| 237 |
+
|
| 238 |
+
Returns:
|
| 239 |
+
Dictionary of preset MaterialAssembly objects
|
| 240 |
+
"""
|
| 241 |
+
return {assembly_id: assembly for assembly_id, assembly in self.assemblies.items()
|
| 242 |
+
if assembly_id.startswith("preset_")}
|
| 243 |
+
|
| 244 |
+
def get_custom_assemblies(self) -> Dict[str, MaterialAssembly]:
|
| 245 |
+
"""
|
| 246 |
+
Get all custom material assemblies.
|
| 247 |
+
|
| 248 |
+
Returns:
|
| 249 |
+
Dictionary of custom MaterialAssembly objects
|
| 250 |
+
"""
|
| 251 |
+
return {assembly_id: assembly for assembly_id, assembly in self.assemblies.items()
|
| 252 |
+
if assembly_id.startswith("custom_")}
|
| 253 |
+
|
| 254 |
+
def create_assembly(self, name: str, description: str = "") -> str:
|
| 255 |
+
"""
|
| 256 |
+
Create a new material assembly.
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
name: Assembly name
|
| 260 |
+
description: Assembly description
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
Assembly ID
|
| 264 |
+
"""
|
| 265 |
+
import uuid
|
| 266 |
+
|
| 267 |
+
assembly_id = f"custom_assembly_{str(uuid.uuid4())[:8]}"
|
| 268 |
+
assembly = MaterialAssembly(name=name, description=description)
|
| 269 |
+
|
| 270 |
+
self.assemblies[assembly_id] = assembly
|
| 271 |
+
return assembly_id
|
| 272 |
+
|
| 273 |
+
def add_layer_to_assembly(self, assembly_id: str, material_id: str, thickness: float) -> bool:
|
| 274 |
+
"""
|
| 275 |
+
Add a material layer to an assembly.
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
assembly_id: Assembly identifier
|
| 279 |
+
material_id: Material identifier
|
| 280 |
+
thickness: Layer thickness in meters
|
| 281 |
+
|
| 282 |
+
Returns:
|
| 283 |
+
True if the layer was added, False otherwise
|
| 284 |
+
"""
|
| 285 |
+
if assembly_id not in self.assemblies:
|
| 286 |
+
return False
|
| 287 |
+
|
| 288 |
+
material = reference_data.get_material(material_id)
|
| 289 |
+
if not material:
|
| 290 |
+
return False
|
| 291 |
+
|
| 292 |
+
layer = MaterialLayer(
|
| 293 |
+
name=material["name"],
|
| 294 |
+
thickness=thickness,
|
| 295 |
+
conductivity=material["conductivity"],
|
| 296 |
+
density=material.get("density"),
|
| 297 |
+
specific_heat=material.get("specific_heat")
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
self.assemblies[assembly_id].add_layer(layer)
|
| 301 |
+
return True
|
| 302 |
+
|
| 303 |
+
def add_custom_layer_to_assembly(self, assembly_id: str, name: str, thickness: float,
|
| 304 |
+
conductivity: float, density: float = None,
|
| 305 |
+
specific_heat: float = None) -> bool:
|
| 306 |
+
"""
|
| 307 |
+
Add a custom material layer to an assembly.
|
| 308 |
+
|
| 309 |
+
Args:
|
| 310 |
+
assembly_id: Assembly identifier
|
| 311 |
+
name: Layer name
|
| 312 |
+
thickness: Layer thickness in meters
|
| 313 |
+
conductivity: Thermal conductivity in W/(m·K)
|
| 314 |
+
density: Density in kg/m³ (optional)
|
| 315 |
+
specific_heat: Specific heat capacity in J/(kg·K) (optional)
|
| 316 |
+
|
| 317 |
+
Returns:
|
| 318 |
+
True if the layer was added, False otherwise
|
| 319 |
+
"""
|
| 320 |
+
if assembly_id not in self.assemblies:
|
| 321 |
+
return False
|
| 322 |
+
|
| 323 |
+
layer = MaterialLayer(
|
| 324 |
+
name=name,
|
| 325 |
+
thickness=thickness,
|
| 326 |
+
conductivity=conductivity,
|
| 327 |
+
density=density,
|
| 328 |
+
specific_heat=specific_heat
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
self.assemblies[assembly_id].add_layer(layer)
|
| 332 |
+
return True
|
| 333 |
+
|
| 334 |
+
def remove_layer_from_assembly(self, assembly_id: str, layer_index: int) -> bool:
|
| 335 |
+
"""
|
| 336 |
+
Remove a material layer from an assembly.
|
| 337 |
+
|
| 338 |
+
Args:
|
| 339 |
+
assembly_id: Assembly identifier
|
| 340 |
+
layer_index: Index of the layer to remove
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
True if the layer was removed, False otherwise
|
| 344 |
+
"""
|
| 345 |
+
if assembly_id not in self.assemblies:
|
| 346 |
+
return False
|
| 347 |
+
|
| 348 |
+
return self.assemblies[assembly_id].remove_layer(layer_index)
|
| 349 |
+
|
| 350 |
+
def move_layer_in_assembly(self, assembly_id: str, from_index: int, to_index: int) -> bool:
|
| 351 |
+
"""
|
| 352 |
+
Move a material layer within an assembly.
|
| 353 |
+
|
| 354 |
+
Args:
|
| 355 |
+
assembly_id: Assembly identifier
|
| 356 |
+
from_index: Current index of the layer
|
| 357 |
+
to_index: New index for the layer
|
| 358 |
+
|
| 359 |
+
Returns:
|
| 360 |
+
True if the layer was moved, False otherwise
|
| 361 |
+
"""
|
| 362 |
+
if assembly_id not in self.assemblies:
|
| 363 |
+
return False
|
| 364 |
+
|
| 365 |
+
return self.assemblies[assembly_id].move_layer(from_index, to_index)
|
| 366 |
+
|
| 367 |
+
def calculate_u_value(self, assembly_id: str) -> Optional[float]:
|
| 368 |
+
"""
|
| 369 |
+
Calculate the U-value of an assembly.
|
| 370 |
+
|
| 371 |
+
Args:
|
| 372 |
+
assembly_id: Assembly identifier
|
| 373 |
+
|
| 374 |
+
Returns:
|
| 375 |
+
U-value in W/(m²·K) or None if the assembly was not found
|
| 376 |
+
"""
|
| 377 |
+
if assembly_id not in self.assemblies:
|
| 378 |
+
return None
|
| 379 |
+
|
| 380 |
+
return self.assemblies[assembly_id].u_value
|
| 381 |
+
|
| 382 |
+
def calculate_r_value(self, assembly_id: str) -> Optional[float]:
|
| 383 |
+
"""
|
| 384 |
+
Calculate the R-value of an assembly.
|
| 385 |
+
|
| 386 |
+
Args:
|
| 387 |
+
assembly_id: Assembly identifier
|
| 388 |
+
|
| 389 |
+
Returns:
|
| 390 |
+
R-value in m²·K/W or None if the assembly was not found
|
| 391 |
+
"""
|
| 392 |
+
if assembly_id not in self.assemblies:
|
| 393 |
+
return None
|
| 394 |
+
|
| 395 |
+
return self.assemblies[assembly_id].r_value_total
|
| 396 |
+
|
| 397 |
+
def export_to_json(self, file_path: str) -> None:
|
| 398 |
+
"""
|
| 399 |
+
Export all assemblies to a JSON file.
|
| 400 |
+
|
| 401 |
+
Args:
|
| 402 |
+
file_path: Path to the output JSON file
|
| 403 |
+
"""
|
| 404 |
+
data = {assembly_id: assembly.to_dict() for assembly_id, assembly in self.assemblies.items()}
|
| 405 |
+
|
| 406 |
+
with open(file_path, 'w') as f:
|
| 407 |
+
json.dump(data, f, indent=4)
|
| 408 |
+
|
| 409 |
+
def import_from_json(self, file_path: str) -> int:
|
| 410 |
+
"""
|
| 411 |
+
Import assemblies from a JSON file.
|
| 412 |
+
|
| 413 |
+
Args:
|
| 414 |
+
file_path: Path to the input JSON file
|
| 415 |
+
|
| 416 |
+
Returns:
|
| 417 |
+
Number of assemblies imported
|
| 418 |
+
"""
|
| 419 |
+
with open(file_path, 'r') as f:
|
| 420 |
+
data = json.load(f)
|
| 421 |
+
|
| 422 |
+
count = 0
|
| 423 |
+
for assembly_id, assembly_data in data.items():
|
| 424 |
+
try:
|
| 425 |
+
# Create assembly
|
| 426 |
+
assembly = MaterialAssembly(
|
| 427 |
+
name=assembly_data["name"],
|
| 428 |
+
description=assembly_data.get("description", ""),
|
| 429 |
+
r_si=assembly_data.get("r_si", 0.13),
|
| 430 |
+
r_se=assembly_data.get("r_se", 0.04)
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
# Add layers
|
| 434 |
+
for layer_data in assembly_data.get("layers", []):
|
| 435 |
+
layer = MaterialLayer(
|
| 436 |
+
name=layer_data["name"],
|
| 437 |
+
thickness=layer_data["thickness"],
|
| 438 |
+
conductivity=layer_data["conductivity"],
|
| 439 |
+
density=layer_data.get("density"),
|
| 440 |
+
specific_heat=layer_data.get("specific_heat")
|
| 441 |
+
)
|
| 442 |
+
assembly.add_layer(layer)
|
| 443 |
+
|
| 444 |
+
self.assemblies[assembly_id] = assembly
|
| 445 |
+
count += 1
|
| 446 |
+
except Exception as e:
|
| 447 |
+
print(f"Error importing assembly {assembly_id}: {e}")
|
| 448 |
+
|
| 449 |
+
return count
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# Create a singleton instance
|
| 453 |
+
u_value_calculator = UValueCalculator()
|
| 454 |
+
|
| 455 |
+
# Export U-value calculator to JSON if needed
|
| 456 |
+
if __name__ == "__main__":
|
| 457 |
+
u_value_calculator.export_to_json(os.path.join(DATA_DIR, "data", "u_value_calculator.json"))
|