Spaces:
Sleeping
Sleeping
Upload 27 files
Browse files- README.md +9 -15
- app/building_info_form.py +232 -0
- app/component_selection.py +801 -0
- app/data_export.py +870 -0
- app/data_persistence.py +540 -0
- app/data_validation.py +494 -0
- app/main.py +1205 -0
- app/results_display.py +674 -0
- data/ashrae_tables.py +744 -0
- data/building_components.py +568 -0
- data/climate_data.py +599 -0
- data/drapery.py +304 -0
- data/reference_data.py +447 -0
- gitattributes +35 -0
- requirements.txt +8 -3
- utils/area_calculation_system.py +523 -0
- utils/component_library.py +365 -0
- utils/component_visualization.py +721 -0
- utils/cooling_load.py +1029 -0
- utils/heat_transfer.py +327 -0
- utils/heating_load.py +610 -0
- utils/psychrometric_visualization.py +635 -0
- utils/psychrometrics.py +581 -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
README.md
CHANGED
|
@@ -1,19 +1,13 @@
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
pinned: false
|
| 11 |
-
short_description: Streamlit template space
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
|
| 17 |
-
|
| 18 |
-
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 19 |
-
forums](https://discuss.streamlit.io).
|
|
|
|
| 1 |
---
|
| 2 |
+
name: hvac-calculator
|
| 3 |
+
title: HVAC Load Calculator
|
| 4 |
+
emoji: 🌡️
|
| 5 |
+
colorFrom: blue
|
| 6 |
+
colorTo: green
|
| 7 |
+
sdk: streamlit
|
| 8 |
+
sdk_version: 1.28.0
|
| 9 |
+
app_file: app/main.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/building_info_form.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Building information input form for HVAC Load Calculator.
|
| 3 |
+
This module provides the UI components for entering building information.
|
| 4 |
+
|
| 5 |
+
Author: Dr Majed Abuseif
|
| 6 |
+
Date: March 2025
|
| 7 |
+
Version: 1.0.0
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import streamlit as st
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pycountry
|
| 14 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
# Import data models
|
| 18 |
+
from data.building_components import Orientation, ComponentType
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class BuildingInfoForm:
|
| 22 |
+
"""Class for building information input form."""
|
| 23 |
+
|
| 24 |
+
def __init__(self):
|
| 25 |
+
"""Initialize the building information form."""
|
| 26 |
+
self.countries = sorted([country.name for country in pycountry.countries])
|
| 27 |
+
|
| 28 |
+
def display(self):
|
| 29 |
+
"""Display the building information form."""
|
| 30 |
+
self.display_building_info_form(st.session_state)
|
| 31 |
+
|
| 32 |
+
def display_building_info_form(self, session_state: Dict[str, Any]) -> None:
|
| 33 |
+
"""Display building information input form in Streamlit."""
|
| 34 |
+
st.header("Building Information")
|
| 35 |
+
|
| 36 |
+
if "building_info" not in session_state:
|
| 37 |
+
session_state["building_info"] = {
|
| 38 |
+
"project_name": "",
|
| 39 |
+
"building_name": "",
|
| 40 |
+
"country": "",
|
| 41 |
+
"city": "",
|
| 42 |
+
"building_type": "",
|
| 43 |
+
"floor_area": 0.0,
|
| 44 |
+
"width": 0.0,
|
| 45 |
+
"depth": 0.0,
|
| 46 |
+
"building_height": 3.0,
|
| 47 |
+
"orientation": "NORTH",
|
| 48 |
+
"operating_hours": "8:00-18:00"
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
default_values = {
|
| 52 |
+
"project_name": "",
|
| 53 |
+
"building_name": "",
|
| 54 |
+
"country": "",
|
| 55 |
+
"city": "",
|
| 56 |
+
"building_type": "",
|
| 57 |
+
"floor_area": 0.0,
|
| 58 |
+
"width": 0.0,
|
| 59 |
+
"depth": 0.0,
|
| 60 |
+
"building_height": 3.0,
|
| 61 |
+
"orientation": "NORTH",
|
| 62 |
+
"operating_hours": "8:00-18:00"
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
for key, default_value in default_values.items():
|
| 66 |
+
if key not in session_state["building_info"]:
|
| 67 |
+
session_state["building_info"][key] = default_value
|
| 68 |
+
|
| 69 |
+
if "data_saved" not in session_state:
|
| 70 |
+
session_state["data_saved"] = False
|
| 71 |
+
|
| 72 |
+
with st.form(key="building_info_form"):
|
| 73 |
+
st.subheader("Project Information")
|
| 74 |
+
|
| 75 |
+
col1, col2 = st.columns(2)
|
| 76 |
+
with col1:
|
| 77 |
+
session_state["building_info"]["project_name"] = st.text_input(
|
| 78 |
+
"Project Name",
|
| 79 |
+
value=session_state["building_info"]["project_name"],
|
| 80 |
+
help="Enter the project's identification name"
|
| 81 |
+
)
|
| 82 |
+
session_state["building_info"]["building_name"] = st.text_input(
|
| 83 |
+
"Building Name",
|
| 84 |
+
value=session_state["building_info"]["building_name"],
|
| 85 |
+
help="Enter the building's identification name"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
with col2:
|
| 89 |
+
session_state["building_info"]["country"] = st.selectbox(
|
| 90 |
+
"Country",
|
| 91 |
+
options=[""] + self.countries,
|
| 92 |
+
index=0 if not session_state["building_info"]["country"] else
|
| 93 |
+
self.countries.index(session_state["building_info"]["country"]) + 1,
|
| 94 |
+
help="Select the building's country location"
|
| 95 |
+
)
|
| 96 |
+
session_state["building_info"]["city"] = st.text_input(
|
| 97 |
+
"City",
|
| 98 |
+
value=session_state["building_info"]["city"],
|
| 99 |
+
help="Enter the building's city location"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
st.subheader("Building Characteristics")
|
| 103 |
+
|
| 104 |
+
col1, col2 = st.columns(2)
|
| 105 |
+
with col1:
|
| 106 |
+
session_state["building_info"]["building_type"] = st.selectbox(
|
| 107 |
+
"Building Type",
|
| 108 |
+
["Residential", "Office", "Retail", "Educational", "Healthcare", "Industrial", "Other"],
|
| 109 |
+
index=1 if session_state["building_info"]["building_type"] == "" else
|
| 110 |
+
["Residential", "Office", "Retail", "Educational", "Healthcare", "Industrial", "Other"].index(session_state["building_info"]["building_type"]),
|
| 111 |
+
help="Select the building's purpose or usage type"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
with col2:
|
| 115 |
+
session_state["building_info"]["building_height"] = st.number_input(
|
| 116 |
+
"Building Height (m)",
|
| 117 |
+
min_value=2.0,
|
| 118 |
+
max_value=1000.0,
|
| 119 |
+
value=float(session_state["building_info"]["building_height"]),
|
| 120 |
+
step=0.1,
|
| 121 |
+
help="Enter the total height of the building in meters"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
st.subheader("Building Dimensions")
|
| 125 |
+
session_state["building_info"]["floor_area"] = st.number_input(
|
| 126 |
+
"Total Floor Area (m²)",
|
| 127 |
+
min_value=0.0,
|
| 128 |
+
value=float(session_state["building_info"]["floor_area"]),
|
| 129 |
+
step=10.0,
|
| 130 |
+
help="Enter the total floor area of the building in square meters (optional if width and depth provided)"
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# Center the OR using columns
|
| 134 |
+
col1, col2, col3 = st.columns([2, 1, 2])
|
| 135 |
+
with col2:
|
| 136 |
+
st.markdown("Enter the total floor area above OR the building width and depth below")
|
| 137 |
+
|
| 138 |
+
col1, col2 = st.columns(2)
|
| 139 |
+
with col1:
|
| 140 |
+
session_state["building_info"]["width"] = st.number_input(
|
| 141 |
+
"Width (m)",
|
| 142 |
+
min_value=0.0,
|
| 143 |
+
value=float(session_state["building_info"]["width"]),
|
| 144 |
+
step=1.0,
|
| 145 |
+
help="Enter the building's width in meters (optional if area provided)"
|
| 146 |
+
)
|
| 147 |
+
with col2:
|
| 148 |
+
session_state["building_info"]["depth"] = st.number_input(
|
| 149 |
+
"Depth (m)",
|
| 150 |
+
min_value=0.0,
|
| 151 |
+
value=float(session_state["building_info"]["depth"]),
|
| 152 |
+
step=1.0,
|
| 153 |
+
help="Enter the building's depth in meters (optional if area provided)"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
st.subheader("Building Orientation")
|
| 157 |
+
session_state["building_info"]["orientation"] = st.selectbox(
|
| 158 |
+
"Building Orientation",
|
| 159 |
+
["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
|
| 160 |
+
index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["building_info"]["orientation"]),
|
| 161 |
+
help="Select the direction of the building's main facade"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
st.subheader("Operating Hours")
|
| 165 |
+
session_state["building_info"]["operating_hours"] = st.text_input(
|
| 166 |
+
"Operating Hours",
|
| 167 |
+
value=session_state["building_info"]["operating_hours"],
|
| 168 |
+
help="Enter the building's daily operating hours (e.g., 8:00-18:00)"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
submitted = st.form_submit_button("Save Building Information")
|
| 172 |
+
|
| 173 |
+
if submitted:
|
| 174 |
+
valid, errors = self.validate_building_info(session_state["building_info"])
|
| 175 |
+
if not valid:
|
| 176 |
+
for error in errors:
|
| 177 |
+
st.error(error)
|
| 178 |
+
else:
|
| 179 |
+
if session_state["building_info"]["width"] > 0 and session_state["building_info"]["depth"] > 0:
|
| 180 |
+
calculated_area = session_state["building_info"]["width"] * session_state["building_info"]["depth"]
|
| 181 |
+
if session_state["building_info"]["floor_area"] == 0:
|
| 182 |
+
session_state["building_info"]["floor_area"] = calculated_area
|
| 183 |
+
|
| 184 |
+
total_volume = session_state["building_info"]["floor_area"] * session_state["building_info"]["building_height"]
|
| 185 |
+
|
| 186 |
+
session_state["save_results"] = {
|
| 187 |
+
"success": "Building information saved successfully!",
|
| 188 |
+
"area": f"Total Floor Area: {session_state['building_info']['floor_area']:.1f} m²",
|
| 189 |
+
"volume": f"Total Building Volume: {total_volume:.1f} m³"
|
| 190 |
+
}
|
| 191 |
+
session_state["data_saved"] = True
|
| 192 |
+
|
| 193 |
+
# Display results if they exist
|
| 194 |
+
if "save_results" in session_state and session_state["data_saved"]:
|
| 195 |
+
st.success(session_state["save_results"]["success"])
|
| 196 |
+
st.info(session_state["save_results"]["area"])
|
| 197 |
+
st.info(session_state["save_results"]["volume"])
|
| 198 |
+
|
| 199 |
+
# Proceed button with immediate navigation
|
| 200 |
+
if session_state["data_saved"]:
|
| 201 |
+
if st.button("Proceed to Climate Data"):
|
| 202 |
+
session_state["page"] = "Climate Data"
|
| 203 |
+
session_state["data_saved"] = False
|
| 204 |
+
if "save_results" in session_state:
|
| 205 |
+
del session_state["save_results"]
|
| 206 |
+
|
| 207 |
+
@staticmethod
|
| 208 |
+
def validate_building_info(building_info: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
| 209 |
+
"""Validate building information."""
|
| 210 |
+
valid = True
|
| 211 |
+
errors = []
|
| 212 |
+
|
| 213 |
+
required_fields = ["project_name", "building_name", "country", "city", "building_type"]
|
| 214 |
+
for field in required_fields:
|
| 215 |
+
if field not in building_info or not building_info[field]:
|
| 216 |
+
valid = False
|
| 217 |
+
errors.append(f"Missing required field: {field}")
|
| 218 |
+
|
| 219 |
+
if building_info.get("floor_area", 0) <= 0 and (building_info.get("width", 0) <= 0 or building_info.get("depth", 0) <= 0):
|
| 220 |
+
valid = False
|
| 221 |
+
errors.append("Must provide either floor area or both width and depth dimensions")
|
| 222 |
+
|
| 223 |
+
if building_info.get("building_height", 0) <= 0:
|
| 224 |
+
valid = False
|
| 225 |
+
errors.append("Building height must be greater than zero")
|
| 226 |
+
|
| 227 |
+
return valid, errors
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
if __name__ == "__main__":
|
| 231 |
+
form = BuildingInfoForm()
|
| 232 |
+
form.display()
|
app/component_selection.py
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HVAC Component Selection Module
|
| 3 |
+
Provides UI for selecting building components in the HVAC Load Calculator.
|
| 4 |
+
All dependencies are included within this file for standalone operation.
|
| 5 |
+
Updated 2025-04-28: Added perimeter field to Floor class for F-factor calculations.
|
| 6 |
+
Updated 2025-04-29: Fixed Floors table headings, added insulated field for dynamic F-factor.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import streamlit as st
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import numpy as np
|
| 12 |
+
import json
|
| 13 |
+
import uuid
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from enum import Enum
|
| 16 |
+
from typing import Dict, List, Any, Optional
|
| 17 |
+
import io
|
| 18 |
+
|
| 19 |
+
# --- Enums ---
|
| 20 |
+
class Orientation(Enum):
|
| 21 |
+
NORTH = "North"
|
| 22 |
+
NORTHEAST = "Northeast"
|
| 23 |
+
EAST = "East"
|
| 24 |
+
SOUTHEAST = "Southeast"
|
| 25 |
+
SOUTH = "South"
|
| 26 |
+
SOUTHWEST = "Southwest"
|
| 27 |
+
WEST = "West"
|
| 28 |
+
NORTHWEST = "Northwest"
|
| 29 |
+
HORIZONTAL = "Horizontal"
|
| 30 |
+
NOT_APPLICABLE = "N/A"
|
| 31 |
+
|
| 32 |
+
class ComponentType(Enum):
|
| 33 |
+
WALL = "Wall"
|
| 34 |
+
ROOF = "Roof"
|
| 35 |
+
FLOOR = "Floor"
|
| 36 |
+
WINDOW = "Window"
|
| 37 |
+
DOOR = "Door"
|
| 38 |
+
|
| 39 |
+
# --- Data Models ---
|
| 40 |
+
@dataclass
|
| 41 |
+
class MaterialLayer:
|
| 42 |
+
name: str
|
| 43 |
+
thickness: float # in mm
|
| 44 |
+
conductivity: float # W/(m·K)
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class BuildingComponent:
|
| 48 |
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 49 |
+
name: str = "Unnamed Component"
|
| 50 |
+
component_type: ComponentType = ComponentType.WALL
|
| 51 |
+
u_value: float = 0.0 # W/(m²·K)
|
| 52 |
+
area: float = 0.0 # m²
|
| 53 |
+
orientation: Orientation = Orientation.NOT_APPLICABLE
|
| 54 |
+
|
| 55 |
+
def __post_init__(self):
|
| 56 |
+
if self.area <= 0:
|
| 57 |
+
raise ValueError("Area must be greater than zero")
|
| 58 |
+
if self.u_value <= 0:
|
| 59 |
+
raise ValueError("U-value must be greater than zero")
|
| 60 |
+
|
| 61 |
+
def to_dict(self) -> dict:
|
| 62 |
+
return {
|
| 63 |
+
"id": self.id, "name": self.name, "component_type": self.component_type.value,
|
| 64 |
+
"u_value": self.u_value, "area": self.area, "orientation": self.orientation.value
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class Wall(BuildingComponent):
|
| 69 |
+
wall_type: str = "Brick"
|
| 70 |
+
wall_group: str = "A" # ASHRAE group
|
| 71 |
+
absorptivity: float = 0.6
|
| 72 |
+
shading_coefficient: float = 1.0
|
| 73 |
+
infiltration_rate_cfm: float = 0.0
|
| 74 |
+
|
| 75 |
+
def __post_init__(self):
|
| 76 |
+
super().__post_init__()
|
| 77 |
+
self.component_type = ComponentType.WALL
|
| 78 |
+
if not 0 <= self.absorptivity <= 1:
|
| 79 |
+
raise ValueError("Absorptivity must be between 0 and 1")
|
| 80 |
+
if not 0 <= self.shading_coefficient <= 1:
|
| 81 |
+
raise ValueError("Shading coefficient must be between 0 and 1")
|
| 82 |
+
if self.infiltration_rate_cfm < 0:
|
| 83 |
+
raise ValueError("Infiltration rate cannot be negative")
|
| 84 |
+
VALID_WALL_GROUPS = {"A", "B", "C", "D", "E", "F", "G", "H"}
|
| 85 |
+
if self.wall_group not in VALID_WALL_GROUPS:
|
| 86 |
+
st.warning(f"Invalid wall_group '{self.wall_group}' for wall '{self.name}'. Defaulting to 'A'.")
|
| 87 |
+
self.wall_group = "A"
|
| 88 |
+
|
| 89 |
+
def to_dict(self) -> dict:
|
| 90 |
+
base_dict = super().to_dict()
|
| 91 |
+
base_dict.update({
|
| 92 |
+
"wall_type": self.wall_type, "wall_group": self.wall_group, "absorptivity": self.absorptivity,
|
| 93 |
+
"shading_coefficient": self.shading_coefficient, "infiltration_rate_cfm": self.infiltration_rate_cfm
|
| 94 |
+
})
|
| 95 |
+
return base_dict
|
| 96 |
+
|
| 97 |
+
@dataclass
|
| 98 |
+
class Roof(BuildingComponent):
|
| 99 |
+
roof_type: str = "Concrete"
|
| 100 |
+
roof_group: str = "A" # ASHRAE group
|
| 101 |
+
slope: str = "Flat"
|
| 102 |
+
absorptivity: float = 0.6
|
| 103 |
+
|
| 104 |
+
def __post_init__(self):
|
| 105 |
+
super().__post_init__()
|
| 106 |
+
self.component_type = ComponentType.ROOF
|
| 107 |
+
if not self.orientation == Orientation.HORIZONTAL:
|
| 108 |
+
self.orientation = Orientation.HORIZONTAL
|
| 109 |
+
if not 0 <= self.absorptivity <= 1:
|
| 110 |
+
raise ValueError("Absorptivity must be between 0 and 1")
|
| 111 |
+
VALID_ROOF_GROUPS = {"A", "B", "C", "D", "E", "F", "G"}
|
| 112 |
+
if self.roof_group not in VALID_ROOF_GROUPS:
|
| 113 |
+
st.warning(f"Invalid roof_group '{self.roof_group}' for roof '{self.name}'. Defaulting to 'A'.")
|
| 114 |
+
self.roof_group = "A"
|
| 115 |
+
|
| 116 |
+
def to_dict(self) -> dict:
|
| 117 |
+
base_dict = super().to_dict()
|
| 118 |
+
base_dict.update({
|
| 119 |
+
"roof_type": self.roof_type, "roof_group": self.roof_group, "slope": self.slope, "absorptivity": self.absorptivity
|
| 120 |
+
})
|
| 121 |
+
return base_dict
|
| 122 |
+
|
| 123 |
+
@dataclass
|
| 124 |
+
class Floor(BuildingComponent):
|
| 125 |
+
floor_type: str = "Concrete"
|
| 126 |
+
ground_contact: bool = True
|
| 127 |
+
ground_temperature_c: float = 25.0
|
| 128 |
+
perimeter: float = 0.0
|
| 129 |
+
insulated: bool = False # NEW: For dynamic F-factor
|
| 130 |
+
|
| 131 |
+
def __post_init__(self):
|
| 132 |
+
super().__post_init__()
|
| 133 |
+
self.component_type = ComponentType.FLOOR
|
| 134 |
+
self.orientation = Orientation.NOT_APPLICABLE
|
| 135 |
+
if self.perimeter < 0:
|
| 136 |
+
raise ValueError("Perimeter cannot be negative")
|
| 137 |
+
if self.ground_contact and not (-10 <= self.ground_temperature_c <= 40):
|
| 138 |
+
raise ValueError("Ground temperature must be between -10°C and 40°C for ground-contact floors")
|
| 139 |
+
|
| 140 |
+
def to_dict(self) -> dict:
|
| 141 |
+
base_dict = super().to_dict()
|
| 142 |
+
base_dict.update({
|
| 143 |
+
"floor_type": self.floor_type, "ground_contact": self.ground_contact,
|
| 144 |
+
"ground_temperature_c": self.ground_temperature_c, "perimeter": self.perimeter,
|
| 145 |
+
"insulated": self.insulated
|
| 146 |
+
})
|
| 147 |
+
return base_dict
|
| 148 |
+
|
| 149 |
+
@dataclass
|
| 150 |
+
class Window(BuildingComponent):
|
| 151 |
+
shgc: float = 0.7
|
| 152 |
+
shading_device: str = "None"
|
| 153 |
+
shading_coefficient: float = 1.0
|
| 154 |
+
frame_type: str = "Aluminum"
|
| 155 |
+
frame_percentage: float = 20.0
|
| 156 |
+
infiltration_rate_cfm: float = 0.0
|
| 157 |
+
|
| 158 |
+
def __post_init__(self):
|
| 159 |
+
super().__post_init__()
|
| 160 |
+
self.component_type = ComponentType.WINDOW
|
| 161 |
+
if not 0 <= self.shgc <= 1:
|
| 162 |
+
raise ValueError("SHGC must be between 0 and 1")
|
| 163 |
+
if not 0 <= self.shading_coefficient <= 1:
|
| 164 |
+
raise ValueError("Shading coefficient must be between 0 and 1")
|
| 165 |
+
if not 0 <= self.frame_percentage <= 30:
|
| 166 |
+
raise ValueError("Frame percentage must be between 0 and 30")
|
| 167 |
+
if self.infiltration_rate_cfm < 0:
|
| 168 |
+
raise ValueError("Infiltration rate cannot be negative")
|
| 169 |
+
|
| 170 |
+
def to_dict(self) -> dict:
|
| 171 |
+
base_dict = super().to_dict()
|
| 172 |
+
base_dict.update({
|
| 173 |
+
"shgc": self.shgc, "shading_device": self.shading_device, "shading_coefficient": self.shading_coefficient,
|
| 174 |
+
"frame_type": self.frame_type, "frame_percentage": self.frame_percentage, "infiltration_rate_cfm": self.infiltration_rate_cfm
|
| 175 |
+
})
|
| 176 |
+
return base_dict
|
| 177 |
+
|
| 178 |
+
@dataclass
|
| 179 |
+
class Door(BuildingComponent):
|
| 180 |
+
door_type: str = "Solid Wood"
|
| 181 |
+
infiltration_rate_cfm: float = 0.0
|
| 182 |
+
|
| 183 |
+
def __post_init__(self):
|
| 184 |
+
super().__post_init__()
|
| 185 |
+
self.component_type = ComponentType.DOOR
|
| 186 |
+
if self.infiltration_rate_cfm < 0:
|
| 187 |
+
raise ValueError("Infiltration rate cannot be negative")
|
| 188 |
+
|
| 189 |
+
def to_dict(self) -> dict:
|
| 190 |
+
base_dict = super().to_dict()
|
| 191 |
+
base_dict.update({"door_type": self.door_type, "infiltration_rate_cfm": self.infiltration_rate_cfm})
|
| 192 |
+
return base_dict
|
| 193 |
+
|
| 194 |
+
# --- Reference Data ---
|
| 195 |
+
class ReferenceData:
|
| 196 |
+
def __init__(self):
|
| 197 |
+
self.data = {
|
| 198 |
+
"materials": {
|
| 199 |
+
"Concrete": {"conductivity": 1.4},
|
| 200 |
+
"Insulation": {"conductivity": 0.04},
|
| 201 |
+
"Brick": {"conductivity": 0.8},
|
| 202 |
+
"Glass": {"conductivity": 1.0},
|
| 203 |
+
"Wood": {"conductivity": 0.15}
|
| 204 |
+
},
|
| 205 |
+
"wall_types": {
|
| 206 |
+
"Brick Wall": {"u_value": 2.0, "absorptivity": 0.6, "wall_group": "A"},
|
| 207 |
+
"Insulated Brick": {"u_value": 0.5, "absorptivity": 0.6, "wall_group": "B"},
|
| 208 |
+
"Concrete Block": {"u_value": 1.8, "absorptivity": 0.6, "wall_group": "C"},
|
| 209 |
+
"Insulated Concrete": {"u_value": 0.4, "absorptivity": 0.6, "wall_group": "D"},
|
| 210 |
+
"Timber Frame": {"u_value": 0.3, "absorptivity": 0.6, "wall_group": "E"},
|
| 211 |
+
"Cavity Brick": {"u_value": 0.6, "absorptivity": 0.6, "wall_group": "F"},
|
| 212 |
+
"Lightweight Panel": {"u_value": 1.0, "absorptivity": 0.6, "wall_group": "G"},
|
| 213 |
+
"Reinforced Concrete": {"u_value": 1.5, "absorptivity": 0.6, "wall_group": "H"},
|
| 214 |
+
"SIP": {"u_value": 0.25, "absorptivity": 0.6, "wall_group": "A"},
|
| 215 |
+
"Custom": {"u_value": 0.5, "absorptivity": 0.6, "wall_group": "A"}
|
| 216 |
+
},
|
| 217 |
+
"roof_types": {
|
| 218 |
+
"Concrete Roof": {"u_value": 0.3, "absorptivity": 0.6, "group": "A"},
|
| 219 |
+
"Metal Roof": {"u_value": 1.0, "absorptivity": 0.75, "group": "B"}
|
| 220 |
+
},
|
| 221 |
+
"roof_ventilation_methods": {
|
| 222 |
+
"No Ventilation": 0.0,
|
| 223 |
+
"Natural Low": 0.1,
|
| 224 |
+
"Natural High": 0.5,
|
| 225 |
+
"Mechanical": 1.0
|
| 226 |
+
},
|
| 227 |
+
"floor_types": {
|
| 228 |
+
"Concrete Slab": {"u_value": 0.4, "ground_contact": True},
|
| 229 |
+
"Wood Floor": {"u_value": 0.8, "ground_contact": False}
|
| 230 |
+
},
|
| 231 |
+
"window_types": {
|
| 232 |
+
"Double Glazed": {"u_value": 2.8, "shgc": 0.7, "frame_type": "Aluminum"},
|
| 233 |
+
"Single Glazed": {"u_value": 5.0, "shgc": 0.9, "frame_type": "Wood"}
|
| 234 |
+
},
|
| 235 |
+
"shading_devices": {
|
| 236 |
+
"None": 1.0,
|
| 237 |
+
"Venetian Blinds": 0.6,
|
| 238 |
+
"Overhang": 0.4,
|
| 239 |
+
"Roller Shades": 0.5,
|
| 240 |
+
"Drapes": 0.7
|
| 241 |
+
},
|
| 242 |
+
"door_types": {
|
| 243 |
+
"Solid Wood": {"u_value": 2.0},
|
| 244 |
+
"Glass Door": {"u_value": 3.5}
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
def get_materials(self) -> List[Dict[str, Any]]:
|
| 249 |
+
return [{"name": k, "conductivity": v["conductivity"]} for k, v in self.data["materials"].items()]
|
| 250 |
+
|
| 251 |
+
reference_data = ReferenceData()
|
| 252 |
+
|
| 253 |
+
# --- Component Library ---
|
| 254 |
+
class ComponentLibrary:
|
| 255 |
+
def __init__(self):
|
| 256 |
+
self.components = {}
|
| 257 |
+
|
| 258 |
+
def add_component(self, component: BuildingComponent):
|
| 259 |
+
self.components[component.id] = component
|
| 260 |
+
|
| 261 |
+
def remove_component(self, component_id: str):
|
| 262 |
+
if not component_id.startswith("preset_") and component_id in self.components:
|
| 263 |
+
del self.components[component_id]
|
| 264 |
+
|
| 265 |
+
component_library = ComponentLibrary()
|
| 266 |
+
|
| 267 |
+
# --- U-Value Calculator ---
|
| 268 |
+
class UValueCalculator:
|
| 269 |
+
def __init__(self):
|
| 270 |
+
self.materials = reference_data.get_materials()
|
| 271 |
+
|
| 272 |
+
def calculate_u_value(self, layers: List[Dict[str, float]], outside_resistance: float, inside_resistance: float) -> float:
|
| 273 |
+
r_layers = sum(layer["thickness"] / 1000 / layer["conductivity"] for layer in layers)
|
| 274 |
+
r_total = outside_resistance + r_layers + inside_resistance
|
| 275 |
+
return 1 / r_total if r_total > 0 else 0
|
| 276 |
+
|
| 277 |
+
u_value_calculator = UValueCalculator()
|
| 278 |
+
|
| 279 |
+
# --- Component Selection Interface ---
|
| 280 |
+
class ComponentSelectionInterface:
|
| 281 |
+
def __init__(self):
|
| 282 |
+
self.component_library = component_library
|
| 283 |
+
self.u_value_calculator = u_value_calculator
|
| 284 |
+
self.reference_data = reference_data
|
| 285 |
+
|
| 286 |
+
def display_component_selection(self, session_state: Any) -> None:
|
| 287 |
+
st.title("Building Components")
|
| 288 |
+
|
| 289 |
+
if 'components' not in session_state:
|
| 290 |
+
session_state.components = {'walls': [], 'roofs': [], 'floors': [], 'windows': [], 'doors': []}
|
| 291 |
+
if 'roof_air_volume_m3' not in session_state:
|
| 292 |
+
session_state.roof_air_volume_m3 = 0.0
|
| 293 |
+
if 'roof_ventilation_ach' not in session_state:
|
| 294 |
+
session_state.roof_ventilation_ach = 0.0
|
| 295 |
+
|
| 296 |
+
tabs = st.tabs(["Walls", "Roofs", "Floors", "Windows", "Doors", "U-Value Calculator"])
|
| 297 |
+
|
| 298 |
+
with tabs[0]:
|
| 299 |
+
self._display_component_tab(session_state, ComponentType.WALL)
|
| 300 |
+
with tabs[1]:
|
| 301 |
+
self._display_component_tab(session_state, ComponentType.ROOF)
|
| 302 |
+
with tabs[2]:
|
| 303 |
+
self._display_component_tab(session_state, ComponentType.FLOOR)
|
| 304 |
+
with tabs[3]:
|
| 305 |
+
self._display_component_tab(session_state, ComponentType.WINDOW)
|
| 306 |
+
with tabs[4]:
|
| 307 |
+
self._display_component_tab(session_state, ComponentType.DOOR)
|
| 308 |
+
with tabs[5]:
|
| 309 |
+
self._display_u_value_calculator_tab(session_state)
|
| 310 |
+
|
| 311 |
+
if st.button("Save Components"):
|
| 312 |
+
self._save_components(session_state)
|
| 313 |
+
|
| 314 |
+
def _display_component_tab(self, session_state: Any, component_type: ComponentType) -> None:
|
| 315 |
+
type_name = component_type.value.lower()
|
| 316 |
+
st.subheader(f"{type_name.capitalize()} Components")
|
| 317 |
+
|
| 318 |
+
with st.expander(f"Add {type_name.capitalize()}", expanded=True):
|
| 319 |
+
if component_type == ComponentType.WALL:
|
| 320 |
+
self._display_add_wall_form(session_state)
|
| 321 |
+
elif component_type == ComponentType.ROOF:
|
| 322 |
+
self._display_add_roof_form(session_state)
|
| 323 |
+
elif component_type == ComponentType.FLOOR:
|
| 324 |
+
self._display_add_floor_form(session_state)
|
| 325 |
+
elif component_type == ComponentType.WINDOW:
|
| 326 |
+
self._display_add_window_form(session_state)
|
| 327 |
+
elif component_type == ComponentType.DOOR:
|
| 328 |
+
self._display_add_door_form(session_state)
|
| 329 |
+
|
| 330 |
+
components = session_state.components.get(type_name + 's', [])
|
| 331 |
+
if components or component_type == ComponentType.ROOF:
|
| 332 |
+
st.subheader(f"Existing {type_name.capitalize()} Components")
|
| 333 |
+
self._display_components_table(session_state, component_type, components)
|
| 334 |
+
|
| 335 |
+
def _display_add_wall_form(self, session_state: Any) -> None:
|
| 336 |
+
st.write("Add walls manually or upload a file.")
|
| 337 |
+
method = st.radio("Add Wall Method", ["Manual Entry", "File Upload"])
|
| 338 |
+
if "add_wall_submitted" not in session_state:
|
| 339 |
+
session_state.add_wall_submitted = False
|
| 340 |
+
|
| 341 |
+
if method == "Manual Entry":
|
| 342 |
+
with st.form("add_wall_form", clear_on_submit=True):
|
| 343 |
+
col1, col2 = st.columns(2)
|
| 344 |
+
with col1:
|
| 345 |
+
name = st.text_input("Name", "New Wall")
|
| 346 |
+
area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
|
| 347 |
+
orientation = st.selectbox("Orientation", [o.value for o in Orientation if o != Orientation.HORIZONTAL and o != Orientation.NOT_APPLICABLE], index=0)
|
| 348 |
+
with col2:
|
| 349 |
+
wall_options = self.reference_data.data["wall_types"]
|
| 350 |
+
selected_wall = st.selectbox("Wall Type", options=list(wall_options.keys()))
|
| 351 |
+
u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(wall_options[selected_wall]["u_value"]), step=0.01)
|
| 352 |
+
wall_group = st.selectbox("Wall Group (ASHRAE)", ["A", "B", "C", "D", "E", "F", "G", "H"], index=0)
|
| 353 |
+
absorptivity = st.selectbox("Solar Absorptivity", ["Light (0.3)", "Light to Medium (0.45)", "Medium (0.6)", "Medium to Dark (0.75)", "Dark (0.9)"], index=2)
|
| 354 |
+
shading_coefficient = st.number_input("Shading Coefficient", min_value=0.0, max_value=1.0, value=1.0, step=0.05)
|
| 355 |
+
infiltration_rate = st.number_input("Infiltration Rate (CFM)", min_value=0.0, value=0.0, step=0.1)
|
| 356 |
+
|
| 357 |
+
submitted = st.form_submit_button("Add Wall")
|
| 358 |
+
if submitted and not session_state.add_wall_submitted:
|
| 359 |
+
try:
|
| 360 |
+
absorptivity_value = float(absorptivity.split("(")[1].strip(")"))
|
| 361 |
+
new_wall = Wall(
|
| 362 |
+
name=name, u_value=u_value, area=area, orientation=Orientation(orientation),
|
| 363 |
+
wall_type=selected_wall, wall_group=wall_group, absorptivity=absorptivity_value,
|
| 364 |
+
shading_coefficient=shading_coefficient, infiltration_rate_cfm=infiltration_rate
|
| 365 |
+
)
|
| 366 |
+
self.component_library.add_component(new_wall)
|
| 367 |
+
session_state.components['walls'].append(new_wall)
|
| 368 |
+
st.success(f"Added {new_wall.name}")
|
| 369 |
+
session_state.add_wall_submitted = True
|
| 370 |
+
st.rerun()
|
| 371 |
+
except ValueError as e:
|
| 372 |
+
st.error(f"Error: {str(e)}")
|
| 373 |
+
|
| 374 |
+
if session_state.add_wall_submitted:
|
| 375 |
+
session_state.add_wall_submitted = False
|
| 376 |
+
|
| 377 |
+
elif method == "File Upload":
|
| 378 |
+
uploaded_file = st.file_uploader("Upload Walls File", type=["csv", "xlsx"], key="wall_upload")
|
| 379 |
+
required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Wall Type", "Wall Group", "Absorptivity", "Shading Coefficient", "Infiltration Rate (CFM)"]
|
| 380 |
+
template_data = pd.DataFrame(columns=required_cols)
|
| 381 |
+
template_data.loc[0] = ["Example Wall", 10.0, 2.0, "North", "Brick Wall", "A", 0.6, 1.0, 0.0]
|
| 382 |
+
st.download_button(label="Download Wall Template", data=template_data.to_csv(index=False), file_name="wall_template.csv", mime="text/csv")
|
| 383 |
+
if uploaded_file:
|
| 384 |
+
df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
|
| 385 |
+
if all(col in df.columns for col in required_cols):
|
| 386 |
+
valid_wall_groups = {"A", "B", "C", "D", "E", "F", "G", "H"}
|
| 387 |
+
for _, row in df.iterrows():
|
| 388 |
+
try:
|
| 389 |
+
wall_group = str(row["Wall Group"])
|
| 390 |
+
if wall_group not in valid_wall_groups:
|
| 391 |
+
st.warning(f"Invalid Wall Group '{wall_group}' in row '{row['Name']}'. Defaulting to 'A'.")
|
| 392 |
+
wall_group = "A"
|
| 393 |
+
new_wall = Wall(
|
| 394 |
+
name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
|
| 395 |
+
orientation=Orientation(row["Orientation"]), wall_type=str(row["Wall Type"]),
|
| 396 |
+
wall_group=wall_group, absorptivity=float(row["Absorptivity"]),
|
| 397 |
+
shading_coefficient=float(row["Shading Coefficient"]), infiltration_rate_cfm=float(row["Infiltration Rate (CFM)"])
|
| 398 |
+
)
|
| 399 |
+
self.component_library.add_component(new_wall)
|
| 400 |
+
session_state.components['walls'].append(new_wall)
|
| 401 |
+
except ValueError as e:
|
| 402 |
+
st.error(f"Error in row {row['Name']}: {str(e)}")
|
| 403 |
+
st.success("Walls uploaded successfully!")
|
| 404 |
+
st.rerun()
|
| 405 |
+
else:
|
| 406 |
+
st.error(f"File must contain: {', '.join(required_cols)}")
|
| 407 |
+
|
| 408 |
+
def _display_add_roof_form(self, session_state: Any) -> None:
|
| 409 |
+
st.write("Add roofs manually or upload a file.")
|
| 410 |
+
method = st.radio("Add Roof Method", ["Manual Entry", "File Upload"])
|
| 411 |
+
if "add_roof_submitted" not in session_state:
|
| 412 |
+
session_state.add_roof_submitted = False
|
| 413 |
+
|
| 414 |
+
st.subheader("Roof System Ventilation")
|
| 415 |
+
air_volume = st.number_input("Air Volume (m³)", min_value=0.0, value=session_state.roof_air_volume_m3, step=1.0, help="Total volume between roof and ceiling")
|
| 416 |
+
vent_options = {f"{k} (ACH={v})": v for k, v in self.reference_data.data["roof_ventilation_methods"].items()}
|
| 417 |
+
vent_options["Custom"] = None
|
| 418 |
+
ventilation_method = st.selectbox("Ventilation Method", options=list(vent_options.keys()), index=0, help="Applies to entire roof system")
|
| 419 |
+
ventilation_ach = st.number_input("Custom Ventilation Rate (ACH)", min_value=0.0, max_value=10.0, value=0.0, step=0.1) if ventilation_method == "Custom" else vent_options[ventilation_method]
|
| 420 |
+
session_state.roof_air_volume_m3 = air_volume
|
| 421 |
+
session_state.roof_ventilation_ach = ventilation_ach
|
| 422 |
+
|
| 423 |
+
if method == "Manual Entry":
|
| 424 |
+
with st.form("add_roof_form", clear_on_submit=True):
|
| 425 |
+
col1, col2 = st.columns(2)
|
| 426 |
+
with col1:
|
| 427 |
+
name = st.text_input("Name", "New Roof")
|
| 428 |
+
area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
|
| 429 |
+
orientation = Orientation.HORIZONTAL.value
|
| 430 |
+
with col2:
|
| 431 |
+
roof_options = self.reference_data.data["roof_types"]
|
| 432 |
+
selected_roof = st.selectbox("Roof Type", options=list(roof_options.keys()))
|
| 433 |
+
u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(roof_options[selected_roof]["u_value"]), step=0.01)
|
| 434 |
+
roof_group = st.selectbox("Roof Group (ASHRAE)", ["A", "B", "C", "D", "E", "F", "G"], index=0)
|
| 435 |
+
slope = st.selectbox("Slope", ["Flat", "Pitched"], index=0)
|
| 436 |
+
absorptivity = st.selectbox("Solar Absorptivity", ["Light (0.3)", "Light to Medium (0.45)", "Medium (0.6)", "Medium to Dark (0.75)", "Dark (0.9)"], index=2)
|
| 437 |
+
|
| 438 |
+
submitted = st.form_submit_button("Add Roof")
|
| 439 |
+
if submitted and not session_state.add_roof_submitted:
|
| 440 |
+
try:
|
| 441 |
+
absorptivity_value = float(absorptivity.split("(")[1].strip(")"))
|
| 442 |
+
new_roof = Roof(
|
| 443 |
+
name=name, u_value=u_value, area=area, orientation=Orientation(orientation),
|
| 444 |
+
roof_type=selected_roof, roof_group=roof_group, slope=slope, absorptivity=absorptivity_value
|
| 445 |
+
)
|
| 446 |
+
self.component_library.add_component(new_roof)
|
| 447 |
+
session_state.components['roofs'].append(new_roof)
|
| 448 |
+
st.success(f"Added {new_roof.name}")
|
| 449 |
+
session_state.add_roof_submitted = True
|
| 450 |
+
st.rerun()
|
| 451 |
+
except ValueError as e:
|
| 452 |
+
st.error(f"Error: {str(e)}")
|
| 453 |
+
|
| 454 |
+
if session_state.add_roof_submitted:
|
| 455 |
+
session_state.add_roof_submitted = False
|
| 456 |
+
|
| 457 |
+
elif method == "File Upload":
|
| 458 |
+
uploaded_file = st.file_uploader("Upload Roofs File", type=["csv", "xlsx"], key="roof_upload")
|
| 459 |
+
required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Roof Type", "Roof Group", "Slope", "Absorptivity"]
|
| 460 |
+
template_data = pd.DataFrame(columns=required_cols)
|
| 461 |
+
template_data.loc[0] = ["Example Roof", 10.0, 0.3, "Horizontal", "Concrete Roof", "A", "Flat", 0.6]
|
| 462 |
+
st.download_button(label="Download Roof Template", data=template_data.to_csv(index=False), file_name="roof_template.csv", mime="text/csv")
|
| 463 |
+
if uploaded_file:
|
| 464 |
+
df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
|
| 465 |
+
if all(col in df.columns for col in required_cols):
|
| 466 |
+
valid_roof_groups = {"A", "B", "C", "D", "E", "F", "G"}
|
| 467 |
+
for _, row in df.iterrows():
|
| 468 |
+
try:
|
| 469 |
+
roof_group = str(row["Roof Group"])
|
| 470 |
+
if roof_group not in valid_roof_groups:
|
| 471 |
+
st.warning(f"Invalid Roof Group '{roof_group}' in row '{row['Name']}'. Defaulting to 'A'.")
|
| 472 |
+
roof_group = "A"
|
| 473 |
+
new_roof = Roof(
|
| 474 |
+
name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
|
| 475 |
+
orientation=Orientation(row["Orientation"]), roof_type=str(row["Roof Type"]),
|
| 476 |
+
roof_group=roof_group, slope=str(row["Slope"]), absorptivity=float(row["Absorptivity"])
|
| 477 |
+
)
|
| 478 |
+
self.component_library.add_component(new_roof)
|
| 479 |
+
session_state.components['roofs'].append(new_roof)
|
| 480 |
+
except ValueError as e:
|
| 481 |
+
st.error(f"Error in row {row['Name']}: {str(e)}")
|
| 482 |
+
st.success("Roofs uploaded successfully!")
|
| 483 |
+
st.rerun()
|
| 484 |
+
else:
|
| 485 |
+
st.error(f"File must contain: {', '.join(required_cols)}")
|
| 486 |
+
|
| 487 |
+
def _display_add_floor_form(self, session_state: Any) -> None:
|
| 488 |
+
st.write("Add floors manually or upload a file.")
|
| 489 |
+
method = st.radio("Add Floor Method", ["Manual Entry", "File Upload"])
|
| 490 |
+
if "add_floor_submitted" not in session_state:
|
| 491 |
+
session_state.add_floor_submitted = False
|
| 492 |
+
|
| 493 |
+
if method == "Manual Entry":
|
| 494 |
+
with st.form("add_floor_form", clear_on_submit=True):
|
| 495 |
+
col1, col2 = st.columns(2)
|
| 496 |
+
with col1:
|
| 497 |
+
name = st.text_input("Name", "New Floor")
|
| 498 |
+
area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
|
| 499 |
+
perimeter = st.number_input("Perimeter (m)", min_value=0.0, value=0.0, step=0.1)
|
| 500 |
+
with col2:
|
| 501 |
+
floor_options = self.reference_data.data["floor_types"]
|
| 502 |
+
selected_floor = st.selectbox("Floor Type", options=list(floor_options.keys()))
|
| 503 |
+
u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(floor_options[selected_floor]["u_value"]), step=0.01)
|
| 504 |
+
ground_contact = st.selectbox("Ground Contact", ["Yes", "No"], index=0 if floor_options[selected_floor]["ground_contact"] else 1)
|
| 505 |
+
ground_temp = st.number_input("Ground Temperature (°C)", min_value=-10.0, max_value=40.0, value=25.0, step=0.1) if ground_contact == "Yes" else 25.0
|
| 506 |
+
insulated = st.checkbox("Insulated Floor (e.g., R-10)", value=False) # NEW: Insulation option
|
| 507 |
+
|
| 508 |
+
submitted = st.form_submit_button("Add Floor")
|
| 509 |
+
if submitted and not session_state.add_floor_submitted:
|
| 510 |
+
try:
|
| 511 |
+
new_floor = Floor(
|
| 512 |
+
name=name, u_value=u_value, area=area, floor_type=selected_floor,
|
| 513 |
+
ground_contact=(ground_contact == "Yes"), ground_temperature_c=ground_temp,
|
| 514 |
+
perimeter=perimeter, insulated=insulated
|
| 515 |
+
)
|
| 516 |
+
self.component_library.add_component(new_floor)
|
| 517 |
+
session_state.components['floors'].append(new_floor)
|
| 518 |
+
st.success(f"Added {new_floor.name}")
|
| 519 |
+
session_state.add_floor_submitted = True
|
| 520 |
+
st.rerun()
|
| 521 |
+
except ValueError as e:
|
| 522 |
+
st.error(f"Error: {str(e)}")
|
| 523 |
+
|
| 524 |
+
if session_state.add_floor_submitted:
|
| 525 |
+
session_state.add_floor_submitted = False
|
| 526 |
+
|
| 527 |
+
elif method == "File Upload":
|
| 528 |
+
uploaded_file = st.file_uploader("Upload Floors File", type=["csv", "xlsx"], key="floor_upload")
|
| 529 |
+
required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Floor Type", "Ground Contact", "Ground Temperature (°C)", "Perimeter (m)", "Insulated"] # NEW: Added Insulated
|
| 530 |
+
template_data = pd.DataFrame(columns=required_cols)
|
| 531 |
+
template_data.loc[0] = ["Example Floor", 10.0, 0.4, "Concrete Slab", "Yes", 25.0, 12.0, "No"]
|
| 532 |
+
st.download_button(label="Download Floor Template", data=template_data.to_csv(index=False), file_name="floor_template.csv", mime="text/csv")
|
| 533 |
+
if uploaded_file:
|
| 534 |
+
df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
|
| 535 |
+
if all(col in df.columns for col in required_cols):
|
| 536 |
+
for _, row in df.iterrows():
|
| 537 |
+
try:
|
| 538 |
+
insulated = str(row["Insulated"]).lower() in ["yes", "true", "1"]
|
| 539 |
+
new_floor = Floor(
|
| 540 |
+
name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
|
| 541 |
+
floor_type=str(row["Floor Type"]), ground_contact=(str(row["Ground Contact"]).lower() == "yes"),
|
| 542 |
+
ground_temperature_c=float(row["Ground Temperature (°C)"]), perimeter=float(row["Perimeter (m)"]),
|
| 543 |
+
insulated=insulated
|
| 544 |
+
)
|
| 545 |
+
self.component_library.add_component(new_floor)
|
| 546 |
+
session_state.components['floors'].append(new_floor)
|
| 547 |
+
except ValueError as e:
|
| 548 |
+
st.error(f"Error in row {row['Name']}: {str(e)}")
|
| 549 |
+
st.success("Floors uploaded successfully!")
|
| 550 |
+
st.rerun()
|
| 551 |
+
else:
|
| 552 |
+
st.error(f"File must contain: {', '.join(required_cols)}")
|
| 553 |
+
|
| 554 |
+
def _display_add_window_form(self, session_state: Any) -> None:
|
| 555 |
+
st.write("Add windows manually or upload a file.")
|
| 556 |
+
method = st.radio("Add Window Method", ["Manual Entry", "File Upload"])
|
| 557 |
+
if "add_window_submitted" not in session_state:
|
| 558 |
+
session_state.add_window_submitted = False
|
| 559 |
+
|
| 560 |
+
if method == "Manual Entry":
|
| 561 |
+
with st.form("add_window_form", clear_on_submit=True):
|
| 562 |
+
col1, col2 = st.columns(2)
|
| 563 |
+
with col1:
|
| 564 |
+
name = st.text_input("Name", "New Window")
|
| 565 |
+
area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
|
| 566 |
+
orientation = st.selectbox("Orientation", [o.value for o in Orientation if o != Orientation.HORIZONTAL and o != Orientation.NOT_APPLICABLE], index=0)
|
| 567 |
+
shgc = st.number_input("SHGC", min_value=0.0, max_value=1.0, value=0.7, step=0.01)
|
| 568 |
+
with col2:
|
| 569 |
+
window_options = self.reference_data.data["window_types"]
|
| 570 |
+
selected_window = st.selectbox("Window Type", options=list(window_options.keys()))
|
| 571 |
+
u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(window_options[selected_window]["u_value"]), step=0.01)
|
| 572 |
+
shading_options = {f"{k} (SC={v})": k for k, v in self.reference_data.data["shading_devices"].items()}
|
| 573 |
+
shading_options["Custom"] = "Custom"
|
| 574 |
+
shading_device = st.selectbox("Shading Device", options=list(shading_options.keys()), index=0)
|
| 575 |
+
shading_coefficient = st.number_input("Custom Shading Coefficient", min_value=0.0, max_value=1.0, value=1.0, step=0.05) if shading_device == "Custom" else self.reference_data.data["shading_devices"][shading_options[shading_device]]
|
| 576 |
+
frame_type = st.selectbox("Frame Type", ["Aluminum", "Wood", "Vinyl"], index=0)
|
| 577 |
+
frame_percentage = st.slider("Frame Percentage (%)", min_value=0.0, max_value=30.0, value=20.0)
|
| 578 |
+
infiltration_rate = st.number_input("Infiltration Rate (CFM)", min_value=0.0, value=0.0, step=0.1)
|
| 579 |
+
|
| 580 |
+
submitted = st.form_submit_button("Add Window")
|
| 581 |
+
if submitted and not session_state.add_window_submitted:
|
| 582 |
+
try:
|
| 583 |
+
new_window = Window(
|
| 584 |
+
name=name, u_value=u_value, area=area, orientation=Orientation(orientation),
|
| 585 |
+
shgc=shgc, shading_device=shading_options[shading_device], shading_coefficient=shading_coefficient,
|
| 586 |
+
frame_type=frame_type, frame_percentage=frame_percentage, infiltration_rate_cfm=infiltration_rate
|
| 587 |
+
)
|
| 588 |
+
self.component_library.add_component(new_window)
|
| 589 |
+
session_state.components['windows'].append(new_window)
|
| 590 |
+
st.success(f"Added {new_window.name}")
|
| 591 |
+
session_state.add_window_submitted = True
|
| 592 |
+
st.rerun()
|
| 593 |
+
except ValueError as e:
|
| 594 |
+
st.error(f"Error: {str(e)}")
|
| 595 |
+
|
| 596 |
+
if session_state.add_window_submitted:
|
| 597 |
+
session_state.add_window_submitted = False
|
| 598 |
+
|
| 599 |
+
elif method == "File Upload":
|
| 600 |
+
uploaded_file = st.file_uploader("Upload Windows File", type=["csv", "xlsx"], key="window_upload")
|
| 601 |
+
required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "SHGC", "Shading Device", "Shading Coefficient", "Frame Type", "Frame Percentage", "Infiltration Rate (CFM)"]
|
| 602 |
+
st.download_button(label="Download Window Template", data=pd.DataFrame(columns=required_cols).to_csv(index=False), file_name="window_template.csv", mime="text/csv")
|
| 603 |
+
if uploaded_file:
|
| 604 |
+
df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
|
| 605 |
+
if all(col in df.columns for col in required_cols):
|
| 606 |
+
for _, row in df.iterrows():
|
| 607 |
+
try:
|
| 608 |
+
new_window = Window(
|
| 609 |
+
name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
|
| 610 |
+
orientation=Orientation(row["Orientation"]), shgc=float(row["SHGC"]),
|
| 611 |
+
shading_device=str(row["Shading Device"]), shading_coefficient=float(row["Shading Coefficient"]),
|
| 612 |
+
frame_type=str(row["Frame Type"]), frame_percentage=float(row["Frame Percentage"]),
|
| 613 |
+
infiltration_rate_cfm=float(row["Infiltration Rate (CFM)"])
|
| 614 |
+
)
|
| 615 |
+
self.component_library.add_component(new_window)
|
| 616 |
+
session_state.components['windows'].append(new_window)
|
| 617 |
+
except ValueError as e:
|
| 618 |
+
st.error(f"Error in row {row['Name']}: {str(e)}")
|
| 619 |
+
st.success("Windows uploaded successfully!")
|
| 620 |
+
st.rerun()
|
| 621 |
+
else:
|
| 622 |
+
st.error(f"File must contain: {', '.join(required_cols)}")
|
| 623 |
+
|
| 624 |
+
def _display_add_door_form(self, session_state: Any) -> None:
|
| 625 |
+
st.write("Add doors manually or upload a file.")
|
| 626 |
+
method = st.radio("Add Door Method", ["Manual Entry", "File Upload"])
|
| 627 |
+
if "add_door_submitted" not in session_state:
|
| 628 |
+
session_state.add_door_submitted = False
|
| 629 |
+
|
| 630 |
+
if method == "Manual Entry":
|
| 631 |
+
with st.form("add_door_form", clear_on_submit=True):
|
| 632 |
+
col1, col2 = st.columns(2)
|
| 633 |
+
with col1:
|
| 634 |
+
name = st.text_input("Name", "New Door")
|
| 635 |
+
area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
|
| 636 |
+
orientation = st.selectbox("Orientation", [o.value for o in Orientation if o != Orientation.HORIZONTAL and o != Orientation.NOT_APPLICABLE], index=0)
|
| 637 |
+
with col2:
|
| 638 |
+
door_options = self.reference_data.data["door_types"]
|
| 639 |
+
selected_door = st.selectbox("Door Type", options=list(door_options.keys()))
|
| 640 |
+
u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(door_options[selected_door]["u_value"]), step=0.01)
|
| 641 |
+
infiltration_rate = st.number_input("Infiltration Rate (CFM)", min_value=0.0, value=0.0, step=0.1)
|
| 642 |
+
|
| 643 |
+
submitted = st.form_submit_button("Add Door")
|
| 644 |
+
if submitted and not session_state.add_door_submitted:
|
| 645 |
+
try:
|
| 646 |
+
new_door = Door(
|
| 647 |
+
name=name, u_value=u_value, area=area, orientation=Orientation(orientation),
|
| 648 |
+
door_type=selected_door, infiltration_rate_cfm=infiltration_rate
|
| 649 |
+
)
|
| 650 |
+
self.component_library.add_component(new_door)
|
| 651 |
+
session_state.components['doors'].append(new_door)
|
| 652 |
+
st.success(f"Added {new_door.name}")
|
| 653 |
+
session_state.add_door_submitted = True
|
| 654 |
+
st.rerun()
|
| 655 |
+
except ValueError as e:
|
| 656 |
+
st.error(f"Error: {str(e)}")
|
| 657 |
+
|
| 658 |
+
if session_state.add_door_submitted:
|
| 659 |
+
session_state.add_door_submitted = False
|
| 660 |
+
|
| 661 |
+
elif method == "File Upload":
|
| 662 |
+
uploaded_file = st.file_uploader("Upload Doors File", type=["csv", "xlsx"], key="door_upload")
|
| 663 |
+
required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Door Type", "Infiltration Rate (CFM)"]
|
| 664 |
+
st.download_button(label="Download Door Template", data=pd.DataFrame(columns=required_cols).to_csv(index=False), file_name="door_template.csv", mime="text/csv")
|
| 665 |
+
if uploaded_file:
|
| 666 |
+
df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
|
| 667 |
+
if all(col in df.columns for col in required_cols):
|
| 668 |
+
for _, row in df.iterrows():
|
| 669 |
+
try:
|
| 670 |
+
new_door = Door(
|
| 671 |
+
name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
|
| 672 |
+
orientation=Orientation(row["Orientation"]), door_type=str(row["Door Type"]),
|
| 673 |
+
infiltration_rate_cfm=float(row["Infiltration Rate (CFM)"])
|
| 674 |
+
)
|
| 675 |
+
self.component_library.add_component(new_door)
|
| 676 |
+
session_state.components['doors'].append(new_door)
|
| 677 |
+
except ValueError as e:
|
| 678 |
+
st.error(f"Error in row {row['Name']}: {str(e)}")
|
| 679 |
+
st.success("Doors uploaded successfully!")
|
| 680 |
+
st.rerun()
|
| 681 |
+
else:
|
| 682 |
+
st.error(f"File must contain: {', '.join(required_cols)}")
|
| 683 |
+
|
| 684 |
+
def _display_components_table(self, session_state: Any, component_type: ComponentType, components: List[BuildingComponent]) -> None:
|
| 685 |
+
type_name = component_type.value.lower()
|
| 686 |
+
if component_type == ComponentType.ROOF:
|
| 687 |
+
st.write(f"Roof Air Volume: {session_state.roof_air_volume_m3} m³, Ventilation Rate: {session_state.roof_ventilation_ach} ACH")
|
| 688 |
+
|
| 689 |
+
if components:
|
| 690 |
+
headers = {
|
| 691 |
+
ComponentType.WALL: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Wall Type", "Wall Group", "Absorptivity", "Shading Coefficient", "Infiltration Rate (CFM)", "Delete"],
|
| 692 |
+
ComponentType.ROOF: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Roof Type", "Roof Group", "Slope", "Absorptivity", "Delete"],
|
| 693 |
+
ComponentType.FLOOR: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Floor Type", "Ground Contact", "Ground Temperature (°C)", "Perimeter (m)", "Insulated", "Delete"], # NEW: Added Insulated
|
| 694 |
+
ComponentType.WINDOW: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "SHGC", "Shading Device", "Shading Coefficient", "Frame Type", "Frame Percentage", "Infiltration Rate (CFM)", "Delete"],
|
| 695 |
+
ComponentType.DOOR: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Door Type", "Infiltration Rate (CFM)", "Delete"]
|
| 696 |
+
}[component_type]
|
| 697 |
+
cols = st.columns([1] * len(headers))
|
| 698 |
+
for i, header in enumerate(headers):
|
| 699 |
+
cols[i].write(f"**{header}**")
|
| 700 |
+
|
| 701 |
+
for comp in components:
|
| 702 |
+
cols = st.columns([1] * len(headers))
|
| 703 |
+
cols[0].write(comp.name)
|
| 704 |
+
cols[1].write(comp.area)
|
| 705 |
+
cols[2].write(comp.u_value)
|
| 706 |
+
cols[3].write(comp.orientation.value)
|
| 707 |
+
if component_type == ComponentType.WALL:
|
| 708 |
+
cols[4].write(comp.wall_type)
|
| 709 |
+
cols[5].write(comp.wall_group)
|
| 710 |
+
cols[6].write(comp.absorptivity)
|
| 711 |
+
cols[7].write(comp.shading_coefficient)
|
| 712 |
+
cols[8].write(comp.infiltration_rate_cfm)
|
| 713 |
+
elif component_type == ComponentType.ROOF:
|
| 714 |
+
cols[4].write(comp.roof_type)
|
| 715 |
+
cols[5].write(comp.roof_group)
|
| 716 |
+
cols[6].write(comp.slope)
|
| 717 |
+
cols[7].write(comp.absorptivity)
|
| 718 |
+
elif component_type == ComponentType.FLOOR:
|
| 719 |
+
cols[4].write(comp.floor_type)
|
| 720 |
+
cols[5].write("Yes" if comp.ground_contact else "No")
|
| 721 |
+
cols[6].write(comp.ground_temperature_c if comp.ground_contact else "N/A")
|
| 722 |
+
cols[7].write(comp.perimeter)
|
| 723 |
+
cols[8].write("Yes" if comp.insulated else "No") # NEW: Display insulated
|
| 724 |
+
elif component_type == ComponentType.WINDOW:
|
| 725 |
+
cols[4].write(comp.shgc)
|
| 726 |
+
cols[5].write(comp.shading_device)
|
| 727 |
+
cols[6].write(comp.shading_coefficient)
|
| 728 |
+
cols[7].write(comp.frame_type)
|
| 729 |
+
cols[8].write(comp.frame_percentage)
|
| 730 |
+
cols[9].write(comp.infiltration_rate_cfm)
|
| 731 |
+
elif component_type == ComponentType.DOOR:
|
| 732 |
+
cols[4].write(comp.door_type)
|
| 733 |
+
cols[5].write(comp.infiltration_rate_cfm)
|
| 734 |
+
if cols[-1].button("Delete", key=f"delete_{comp.id}"):
|
| 735 |
+
self.component_library.remove_component(comp.id)
|
| 736 |
+
session_state.components[type_name + 's'] = [c for c in components if c.id != comp.id]
|
| 737 |
+
st.success(f"Deleted {comp.name}")
|
| 738 |
+
st.rerun()
|
| 739 |
+
|
| 740 |
+
def _display_u_value_calculator_tab(self, session_state: Any) -> None:
|
| 741 |
+
st.subheader("U-Value Calculator (Standalone)")
|
| 742 |
+
if "u_value_layers" not in session_state:
|
| 743 |
+
session_state.u_value_layers = []
|
| 744 |
+
|
| 745 |
+
if session_state.u_value_layers:
|
| 746 |
+
st.write("Material Layers (Outside to Inside):")
|
| 747 |
+
layer_data = [{"Layer": i+1, "Material": l["name"], "Thickness (mm)": l["thickness"],
|
| 748 |
+
"Conductivity (W/m·K)": l["conductivity"], "R-Value (m²·K/W)": l["thickness"] / 1000 / l["conductivity"]}
|
| 749 |
+
for i, l in enumerate(session_state.u_value_layers)]
|
| 750 |
+
st.dataframe(pd.DataFrame(layer_data))
|
| 751 |
+
outside_resistance = st.selectbox("Outside Resistance (m²·K/W)", ["Summer (0.04)", "Winter (0.03)", "Custom"], index=0)
|
| 752 |
+
outside_r = float(st.number_input("Custom Outside Resistance", min_value=0.0, value=0.04, step=0.01)) if outside_resistance == "Custom" else (0.04 if outside_resistance.startswith("Summer") else 0.03)
|
| 753 |
+
inside_r = st.number_input("Inside Resistance (m²·K/W)", min_value=0.0, value=0.13, step=0.01)
|
| 754 |
+
u_value = self.u_value_calculator.calculate_u_value(session_state.u_value_layers, outside_r, inside_r)
|
| 755 |
+
st.metric("U-Value", f"{u_value:.3f} W/m²·K")
|
| 756 |
+
|
| 757 |
+
with st.form("u_value_form"):
|
| 758 |
+
col1, col2 = st.columns(2)
|
| 759 |
+
with col1:
|
| 760 |
+
material_options = {m["name"]: m["conductivity"] for m in self.u_value_calculator.materials}
|
| 761 |
+
material_name = st.selectbox("Material", options=list(material_options.keys()))
|
| 762 |
+
conductivity = st.number_input("Conductivity (W/m·K)", min_value=0.0, value=material_options[material_name], step=0.01)
|
| 763 |
+
with col2:
|
| 764 |
+
thickness = st.number_input("Thickness (mm)", min_value=0.0, value=100.0, step=1.0)
|
| 765 |
+
submitted = st.form_submit_button("Add Layer")
|
| 766 |
+
if submitted:
|
| 767 |
+
session_state.u_value_layers.append({"name": material_name, "thickness": thickness, "conductivity": conductivity})
|
| 768 |
+
st.rerun()
|
| 769 |
+
|
| 770 |
+
col1, col2 = st.columns(2)
|
| 771 |
+
with col1:
|
| 772 |
+
if st.button("Remove Last Layer"):
|
| 773 |
+
if session_state.u_value_layers:
|
| 774 |
+
session_state.u_value_layers.pop()
|
| 775 |
+
st.rerun()
|
| 776 |
+
with col2:
|
| 777 |
+
if st.button("Reset"):
|
| 778 |
+
session_state.u_value_layers = []
|
| 779 |
+
st.rerun()
|
| 780 |
+
|
| 781 |
+
def _save_components(self, session_state: Any) -> None:
|
| 782 |
+
components_dict = {
|
| 783 |
+
"walls": [c.to_dict() for c in session_state.components["walls"]],
|
| 784 |
+
"roofs": [c.to_dict() for c in session_state.components["roofs"]],
|
| 785 |
+
"floors": [c.to_dict() for c in session_state.components["floors"]],
|
| 786 |
+
"windows": [c.to_dict() for c in session_state.components["windows"]],
|
| 787 |
+
"doors": [c.to_dict() for c in session_state.components["doors"]],
|
| 788 |
+
"roof_air_volume_m3": session_state.roof_air_volume_m3,
|
| 789 |
+
"roof_ventilation_ach": session_state.roof_ventilation_ach
|
| 790 |
+
}
|
| 791 |
+
file_path = "components_export.json"
|
| 792 |
+
with open(file_path, 'w') as f:
|
| 793 |
+
json.dump(components_dict, f, indent=4)
|
| 794 |
+
with open(file_path, 'r') as f:
|
| 795 |
+
st.download_button(label="Download Components", data=f, file_name="components.json", mime="application/json")
|
| 796 |
+
st.success("Components saved successfully.")
|
| 797 |
+
|
| 798 |
+
# --- Main Execution ---
|
| 799 |
+
if __name__ == "__main__":
|
| 800 |
+
interface = ComponentSelectionInterface()
|
| 801 |
+
interface.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,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("num_people", 0) < 0:
|
| 267 |
+
is_valid = False
|
| 268 |
+
messages.append(f"People Load #{i+1}: Number of people must be non-negative.")
|
| 269 |
+
|
| 270 |
+
if person.get("hours_in_operation", 0) <= 0:
|
| 271 |
+
is_valid = False
|
| 272 |
+
messages.append(f"People Load #{i+1}: Hours in operation must be positive.")
|
| 273 |
+
|
| 274 |
+
# Check lighting loads
|
| 275 |
+
lighting = internal_loads.get("lighting", [])
|
| 276 |
+
for i, light in enumerate(lighting):
|
| 277 |
+
# Check required fields
|
| 278 |
+
if not light.get("name"):
|
| 279 |
+
is_valid = False
|
| 280 |
+
messages.append(f"Lighting Load #{i+1}: Name is required.")
|
| 281 |
+
|
| 282 |
+
# Check numeric fields
|
| 283 |
+
if light.get("power", 0) < 0:
|
| 284 |
+
is_valid = False
|
| 285 |
+
messages.append(f"Lighting Load #{i+1}: Power must be non-negative.")
|
| 286 |
+
|
| 287 |
+
if light.get("usage_factor", 0) < 0 or light.get("usage_factor", 0) > 1:
|
| 288 |
+
is_valid = False
|
| 289 |
+
messages.append(f"Lighting Load #{i+1}: Usage factor must be between 0 and 1.")
|
| 290 |
+
|
| 291 |
+
if light.get("hours_in_operation", 0) <= 0:
|
| 292 |
+
is_valid = False
|
| 293 |
+
messages.append(f"Lighting Load #{i+1}: Hours in operation must be positive.")
|
| 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 |
+
if equip.get("hours_in_operation", 0) <= 0:
|
| 317 |
+
is_valid = False
|
| 318 |
+
messages.append(f"Equipment Load #{i+1}: Hours in operation must be positive.")
|
| 319 |
+
|
| 320 |
+
return is_valid, messages
|
| 321 |
+
|
| 322 |
+
@staticmethod
|
| 323 |
+
def validate_calculation_settings(settings: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
| 324 |
+
"""
|
| 325 |
+
Validate calculation settings.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
settings: Dictionary with calculation settings
|
| 329 |
+
|
| 330 |
+
Returns:
|
| 331 |
+
Tuple containing validation result (True if valid) and list of validation messages
|
| 332 |
+
"""
|
| 333 |
+
is_valid = True
|
| 334 |
+
messages = []
|
| 335 |
+
|
| 336 |
+
# Check infiltration rate
|
| 337 |
+
if "infiltration_rate" in settings:
|
| 338 |
+
try:
|
| 339 |
+
infiltration_rate = float(settings["infiltration_rate"])
|
| 340 |
+
if infiltration_rate < 0:
|
| 341 |
+
is_valid = False
|
| 342 |
+
messages.append("Infiltration rate must be non-negative.")
|
| 343 |
+
except (ValueError, TypeError):
|
| 344 |
+
is_valid = False
|
| 345 |
+
messages.append("Infiltration rate must be a number.")
|
| 346 |
+
|
| 347 |
+
# Check ventilation rate
|
| 348 |
+
if "ventilation_rate" in settings:
|
| 349 |
+
try:
|
| 350 |
+
ventilation_rate = float(settings["ventilation_rate"])
|
| 351 |
+
if ventilation_rate < 0:
|
| 352 |
+
is_valid = False
|
| 353 |
+
messages.append("Ventilation rate must be non-negative.")
|
| 354 |
+
except (ValueError, TypeError):
|
| 355 |
+
is_valid = False
|
| 356 |
+
messages.append("Ventilation rate must be a number.")
|
| 357 |
+
|
| 358 |
+
# Check safety factors
|
| 359 |
+
safety_factors = ["cooling_safety_factor", "heating_safety_factor"]
|
| 360 |
+
for factor in safety_factors:
|
| 361 |
+
if factor in settings:
|
| 362 |
+
try:
|
| 363 |
+
value = float(settings[factor])
|
| 364 |
+
if value < 0:
|
| 365 |
+
is_valid = False
|
| 366 |
+
messages.append(f"{factor.replace('_', ' ').title()} must be non-negative.")
|
| 367 |
+
except (ValueError, TypeError):
|
| 368 |
+
is_valid = False
|
| 369 |
+
messages.append(f"{factor.replace('_', ' ').title()} must be a number.")
|
| 370 |
+
|
| 371 |
+
return is_valid, messages
|
| 372 |
+
|
| 373 |
+
@staticmethod
|
| 374 |
+
def display_validation_messages(messages: List[str], container=None) -> None:
|
| 375 |
+
"""
|
| 376 |
+
Display validation messages in Streamlit.
|
| 377 |
+
|
| 378 |
+
Args:
|
| 379 |
+
messages: List of validation messages
|
| 380 |
+
container: Optional Streamlit container to display messages in
|
| 381 |
+
"""
|
| 382 |
+
if not messages:
|
| 383 |
+
return
|
| 384 |
+
|
| 385 |
+
# Separate errors and warnings
|
| 386 |
+
errors = [msg for msg in messages if not msg.startswith("Warning:")]
|
| 387 |
+
warnings = [msg for msg in messages if msg.startswith("Warning:")]
|
| 388 |
+
|
| 389 |
+
# Use provided container or st directly
|
| 390 |
+
display = container if container is not None else st
|
| 391 |
+
|
| 392 |
+
# Display errors
|
| 393 |
+
if errors:
|
| 394 |
+
error_msg = "Please fix the following errors:\n" + "\n".join([f"- {msg}" for msg in errors])
|
| 395 |
+
display.error(error_msg)
|
| 396 |
+
|
| 397 |
+
# Display warnings
|
| 398 |
+
if warnings:
|
| 399 |
+
warning_msg = "Warnings:\n" + "\n".join([f"- {msg[8:]}" for msg in warnings])
|
| 400 |
+
display.warning(warning_msg)
|
| 401 |
+
|
| 402 |
+
@staticmethod
|
| 403 |
+
def validate_and_proceed(
|
| 404 |
+
session_state: Dict[str, Any],
|
| 405 |
+
validation_function: Callable[[Dict[str, Any]], Tuple[bool, List[str]]],
|
| 406 |
+
data_key: str,
|
| 407 |
+
success_message: str = "Validation successful!",
|
| 408 |
+
proceed_callback: Optional[Callable] = None
|
| 409 |
+
) -> bool:
|
| 410 |
+
"""
|
| 411 |
+
Validate data and proceed if valid.
|
| 412 |
+
|
| 413 |
+
Args:
|
| 414 |
+
session_state: Streamlit session state
|
| 415 |
+
validation_function: Function to validate data
|
| 416 |
+
data_key: Key for data in session state
|
| 417 |
+
success_message: Message to display on success
|
| 418 |
+
proceed_callback: Optional callback function to execute if validation succeeds
|
| 419 |
+
|
| 420 |
+
Returns:
|
| 421 |
+
Boolean indicating whether validation succeeded
|
| 422 |
+
"""
|
| 423 |
+
if data_key not in session_state:
|
| 424 |
+
st.error(f"No {data_key.replace('_', ' ').title()} data found.")
|
| 425 |
+
return False
|
| 426 |
+
|
| 427 |
+
# Validate data
|
| 428 |
+
is_valid, messages = validation_function(session_state[data_key])
|
| 429 |
+
|
| 430 |
+
# Display validation messages
|
| 431 |
+
DataValidation.display_validation_messages(messages)
|
| 432 |
+
|
| 433 |
+
# Proceed if valid
|
| 434 |
+
if is_valid:
|
| 435 |
+
st.success(success_message)
|
| 436 |
+
|
| 437 |
+
# Execute callback if provided
|
| 438 |
+
if proceed_callback is not None:
|
| 439 |
+
proceed_callback()
|
| 440 |
+
|
| 441 |
+
return True
|
| 442 |
+
|
| 443 |
+
return False
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
# Create a singleton instance
|
| 447 |
+
data_validation = DataValidation()
|
| 448 |
+
|
| 449 |
+
# Example usage
|
| 450 |
+
if __name__ == "__main__":
|
| 451 |
+
import streamlit as st
|
| 452 |
+
|
| 453 |
+
# Initialize session state with dummy data for testing
|
| 454 |
+
if "building_info" not in st.session_state:
|
| 455 |
+
st.session_state["building_info"] = {
|
| 456 |
+
"project_name": "Test Project",
|
| 457 |
+
"building_name": "Test Building",
|
| 458 |
+
"location": "New York",
|
| 459 |
+
"climate_zone": "4A",
|
| 460 |
+
"building_type": "Office",
|
| 461 |
+
"floor_area": 1000.0,
|
| 462 |
+
"num_floors": 2,
|
| 463 |
+
"floor_height": 3.0,
|
| 464 |
+
"orientation": "NORTH",
|
| 465 |
+
"occupancy": 50,
|
| 466 |
+
"operating_hours": "8:00-18:00",
|
| 467 |
+
"design_conditions": {
|
| 468 |
+
"summer_outdoor_db": 35.0,
|
| 469 |
+
"summer_outdoor_wb": 25.0,
|
| 470 |
+
"summer_indoor_db": 24.0,
|
| 471 |
+
"summer_indoor_rh": 50.0,
|
| 472 |
+
"winter_outdoor_db": -5.0,
|
| 473 |
+
"winter_outdoor_rh": 80.0,
|
| 474 |
+
"winter_indoor_db": 21.0,
|
| 475 |
+
"winter_indoor_rh": 40.0
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
# Test validation
|
| 480 |
+
st.header("Test Building Information Validation")
|
| 481 |
+
|
| 482 |
+
# Add some invalid data for testing
|
| 483 |
+
if st.button("Make Data Invalid"):
|
| 484 |
+
st.session_state["building_info"]["floor_area"] = -100.0
|
| 485 |
+
st.session_state["building_info"]["design_conditions"]["summer_outdoor_wb"] = 40.0
|
| 486 |
+
|
| 487 |
+
# Validate building info
|
| 488 |
+
if st.button("Validate Building Info"):
|
| 489 |
+
data_validation.validate_and_proceed(
|
| 490 |
+
st.session_state,
|
| 491 |
+
data_validation.validate_building_info,
|
| 492 |
+
"building_info",
|
| 493 |
+
"Building information is valid!"
|
| 494 |
+
)
|
app/main.py
ADDED
|
@@ -0,0 +1,1205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HVAC Calculator Code Documentation
|
| 3 |
+
Updated 2025-04-27: Enhanced climate ID generation, input validation, debug mode, and error handling.
|
| 4 |
+
Updated 2025-04-28: Added activity-level-based internal gains, ground temperature validation, ASHRAE 62.1 ventilation rates, negative load prevention, and improved usability.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import streamlit as st
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import numpy as np
|
| 10 |
+
import plotly.express as px
|
| 11 |
+
import json
|
| 12 |
+
import pycountry
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 16 |
+
|
| 17 |
+
# Import application modules
|
| 18 |
+
from app.building_info_form import BuildingInfoForm
|
| 19 |
+
from app.component_selection import ComponentSelectionInterface, Orientation, ComponentType, Wall, Roof, Floor, Window, Door
|
| 20 |
+
from app.results_display import ResultsDisplay
|
| 21 |
+
from app.data_validation import DataValidation
|
| 22 |
+
from app.data_persistence import DataPersistence
|
| 23 |
+
from app.data_export import DataExport
|
| 24 |
+
|
| 25 |
+
# Import data modules
|
| 26 |
+
from data.reference_data import ReferenceData
|
| 27 |
+
from data.climate_data import ClimateData, ClimateLocation
|
| 28 |
+
from data.ashrae_tables import ASHRAETables
|
| 29 |
+
from data.building_components import Wall as WallModel, Roof as RoofModel
|
| 30 |
+
|
| 31 |
+
# Import utility modules
|
| 32 |
+
from utils.u_value_calculator import UValueCalculator
|
| 33 |
+
from utils.shading_system import ShadingSystem
|
| 34 |
+
from utils.area_calculation_system import AreaCalculationSystem
|
| 35 |
+
from utils.psychrometrics import Psychrometrics
|
| 36 |
+
from utils.heat_transfer import HeatTransferCalculations
|
| 37 |
+
from utils.cooling_load import CoolingLoadCalculator
|
| 38 |
+
from utils.heating_load import HeatingLoadCalculator
|
| 39 |
+
from utils.component_visualization import ComponentVisualization
|
| 40 |
+
from utils.scenario_comparison import ScenarioComparisonVisualization
|
| 41 |
+
from utils.psychrometric_visualization import PsychrometricVisualization
|
| 42 |
+
from utils.time_based_visualization import TimeBasedVisualization
|
| 43 |
+
|
| 44 |
+
# NEW: ASHRAE 62.1 Ventilation Rates (Table 6.1)
|
| 45 |
+
VENTILATION_RATES = {
|
| 46 |
+
"Office": {"people_rate": 2.5, "area_rate": 0.3}, # L/s/person, L/s/m²
|
| 47 |
+
"Classroom": {"people_rate": 5.0, "area_rate": 0.9},
|
| 48 |
+
"Retail": {"people_rate": 3.8, "area_rate": 0.9},
|
| 49 |
+
"Restaurant": {"people_rate": 5.0, "area_rate": 1.8},
|
| 50 |
+
"Custom": {"people_rate": 0.0, "area_rate": 0.0}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
class HVACCalculator:
|
| 54 |
+
def __init__(self):
|
| 55 |
+
st.set_page_config(
|
| 56 |
+
page_title="HVAC Load Calculator",
|
| 57 |
+
page_icon="🌡️",
|
| 58 |
+
layout="wide",
|
| 59 |
+
initial_sidebar_state="expanded"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Initialize session state
|
| 63 |
+
if 'page' not in st.session_state:
|
| 64 |
+
st.session_state.page = 'Building Information'
|
| 65 |
+
|
| 66 |
+
if 'building_info' not in st.session_state:
|
| 67 |
+
st.session_state.building_info = {"project_name": ""}
|
| 68 |
+
|
| 69 |
+
if 'components' not in st.session_state:
|
| 70 |
+
st.session_state.components = {
|
| 71 |
+
'walls': [],
|
| 72 |
+
'roofs': [],
|
| 73 |
+
'floors': [],
|
| 74 |
+
'windows': [],
|
| 75 |
+
'doors': []
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if 'internal_loads' not in st.session_state:
|
| 79 |
+
st.session_state.internal_loads = {
|
| 80 |
+
'people': [],
|
| 81 |
+
'lighting': [],
|
| 82 |
+
'equipment': []
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if 'calculation_results' not in st.session_state:
|
| 86 |
+
st.session_state.calculation_results = {
|
| 87 |
+
'cooling': {},
|
| 88 |
+
'heating': {}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if 'saved_scenarios' not in st.session_state:
|
| 92 |
+
st.session_state.saved_scenarios = {}
|
| 93 |
+
|
| 94 |
+
if 'climate_data' not in st.session_state:
|
| 95 |
+
st.session_state.climate_data = {}
|
| 96 |
+
|
| 97 |
+
if 'debug_mode' not in st.session_state:
|
| 98 |
+
st.session_state.debug_mode = False
|
| 99 |
+
|
| 100 |
+
# Initialize modules
|
| 101 |
+
self.building_info_form = BuildingInfoForm()
|
| 102 |
+
self.component_selection = ComponentSelectionInterface()
|
| 103 |
+
self.results_display = ResultsDisplay()
|
| 104 |
+
self.data_validation = DataValidation()
|
| 105 |
+
self.data_persistence = DataPersistence()
|
| 106 |
+
self.data_export = DataExport()
|
| 107 |
+
self.cooling_calculator = CoolingLoadCalculator()
|
| 108 |
+
self.heating_calculator = HeatingLoadCalculator()
|
| 109 |
+
|
| 110 |
+
# Persist ClimateData in session_state
|
| 111 |
+
if 'climate_data_obj' not in st.session_state:
|
| 112 |
+
st.session_state.climate_data_obj = ClimateData()
|
| 113 |
+
self.climate_data = st.session_state.climate_data_obj
|
| 114 |
+
|
| 115 |
+
# Load default climate data if locations are empty
|
| 116 |
+
try:
|
| 117 |
+
if not self.climate_data.locations:
|
| 118 |
+
self.climate_data = ClimateData.from_json("/home/user/app/climate_data.json")
|
| 119 |
+
st.session_state.climate_data_obj = self.climate_data
|
| 120 |
+
except FileNotFoundError:
|
| 121 |
+
st.warning("Default climate data file not found. Please enter climate data manually.")
|
| 122 |
+
|
| 123 |
+
self.setup_layout()
|
| 124 |
+
|
| 125 |
+
def setup_layout(self):
|
| 126 |
+
st.sidebar.title("HVAC Load Calculator")
|
| 127 |
+
st.sidebar.markdown("---")
|
| 128 |
+
|
| 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 |
+
if selected_page != st.session_state.page:
|
| 142 |
+
st.session_state.page = selected_page
|
| 143 |
+
|
| 144 |
+
self.display_page(st.session_state.page)
|
| 145 |
+
|
| 146 |
+
st.sidebar.markdown("---")
|
| 147 |
+
st.sidebar.info(
|
| 148 |
+
"HVAC Load Calculator v1.0.1\n\n"
|
| 149 |
+
"Based on ASHRAE steady-state calculation methods\n\n"
|
| 150 |
+
"Developed by: Dr Majed Abuseif\n\n"
|
| 151 |
+
"School of Architecture and Built Environment\n\n"
|
| 152 |
+
"Deakin University\n\n"
|
| 153 |
+
"© 2025"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
def display_page(self, page: str):
|
| 157 |
+
if page == "Building Information":
|
| 158 |
+
self.building_info_form.display_building_info_form(st.session_state)
|
| 159 |
+
elif page == "Climate Data":
|
| 160 |
+
self.climate_data.display_climate_input(st.session_state)
|
| 161 |
+
elif page == "Building Components":
|
| 162 |
+
self.component_selection.display_component_selection(st.session_state)
|
| 163 |
+
elif page == "Internal Loads":
|
| 164 |
+
self.display_internal_loads()
|
| 165 |
+
elif page == "Calculation Results":
|
| 166 |
+
self.display_calculation_results()
|
| 167 |
+
elif page == "Export Data":
|
| 168 |
+
self.data_export.display()
|
| 169 |
+
|
| 170 |
+
def generate_climate_id(self, country: str, city: str) -> str:
|
| 171 |
+
"""Generate a climate ID from country and city names."""
|
| 172 |
+
try:
|
| 173 |
+
country = country.strip().title()
|
| 174 |
+
city = city.strip().title()
|
| 175 |
+
if len(country) < 2 or len(city) < 3:
|
| 176 |
+
raise ValueError("Country and city names must be at least 2 and 3 characters long, respectively.")
|
| 177 |
+
return f"{country[:2].upper()}-{city[:3].upper()}"
|
| 178 |
+
except Exception as e:
|
| 179 |
+
raise ValueError(f"Invalid country or city name: {str(e)}")
|
| 180 |
+
|
| 181 |
+
def validate_calculation_inputs(self) -> Tuple[bool, str]:
|
| 182 |
+
"""Validate inputs for cooling and heating calculations."""
|
| 183 |
+
building_info = st.session_state.get('building_info', {})
|
| 184 |
+
components = st.session_state.get('components', {})
|
| 185 |
+
climate_data = st.session_state.get('climate_data', {})
|
| 186 |
+
|
| 187 |
+
# Check building info
|
| 188 |
+
if not building_info.get('floor_area', 0) > 0:
|
| 189 |
+
return False, "Floor area must be positive."
|
| 190 |
+
if not any(components.get(key, []) for key in ['walls', 'roofs', 'windows']):
|
| 191 |
+
return False, "At least one wall, roof, or window must be defined."
|
| 192 |
+
|
| 193 |
+
# NEW: Validate climate data using climate_data.py
|
| 194 |
+
if not climate_data:
|
| 195 |
+
return False, "Climate data is missing."
|
| 196 |
+
if not self.climate_data.validate_climate_data(climate_data):
|
| 197 |
+
return False, "Invalid climate data format or values."
|
| 198 |
+
|
| 199 |
+
# Validate components
|
| 200 |
+
for component_type in ['walls', 'roofs', 'windows', 'doors', 'floors']:
|
| 201 |
+
for comp in components.get(component_type, []):
|
| 202 |
+
if comp.area <= 0:
|
| 203 |
+
return False, f"Invalid area for {component_type}: {comp.name}"
|
| 204 |
+
if comp.u_value <= 0:
|
| 205 |
+
return False, f"Invalid U-value for {component_type}: {comp.name}"
|
| 206 |
+
# NEW: Validate ground temperature for floors
|
| 207 |
+
if component_type == 'floors' and getattr(comp, 'ground_contact', False):
|
| 208 |
+
if not -10 <= comp.ground_temperature_c <= 40:
|
| 209 |
+
return False, f"Ground temperature for {comp.name} must be between -10°C and 40°C"
|
| 210 |
+
# NEW: Validate perimeter
|
| 211 |
+
if getattr(comp, 'perimeter', 0) < 0:
|
| 212 |
+
return False, f"Perimeter for {comp.name} cannot be negative"
|
| 213 |
+
|
| 214 |
+
# NEW: Validate ventilation rate
|
| 215 |
+
if building_info.get('ventilation_rate', 0) < 0:
|
| 216 |
+
return False, "Ventilation rate cannot be negative"
|
| 217 |
+
if building_info.get('zone_type', '') == 'Custom' and building_info.get('ventilation_rate', 0) == 0:
|
| 218 |
+
return False, "Custom ventilation rate must be specified"
|
| 219 |
+
|
| 220 |
+
return True, "Inputs valid."
|
| 221 |
+
|
| 222 |
+
def validate_internal_load(self, load_type: str, new_load: Dict) -> Tuple[bool, str]:
|
| 223 |
+
"""Validate if a new internal load is unique and within limits."""
|
| 224 |
+
loads = st.session_state.internal_loads.get(load_type, [])
|
| 225 |
+
max_loads = 50
|
| 226 |
+
|
| 227 |
+
if len(loads) >= max_loads:
|
| 228 |
+
return False, f"Maximum of {max_loads} {load_type} loads reached."
|
| 229 |
+
|
| 230 |
+
# Check for duplicates based on key attributes
|
| 231 |
+
for existing_load in loads:
|
| 232 |
+
if load_type == 'people':
|
| 233 |
+
if (existing_load['name'] == new_load['name'] and
|
| 234 |
+
existing_load['num_people'] == new_load['num_people'] and
|
| 235 |
+
existing_load['activity_level'] == new_load['activity_level'] and
|
| 236 |
+
existing_load['zone_type'] == new_load['zone_type'] and
|
| 237 |
+
existing_load['hours_in_operation'] == new_load['hours_in_operation']):
|
| 238 |
+
return False, f"Duplicate people load '{new_load['name']}' already exists."
|
| 239 |
+
elif load_type == 'lighting':
|
| 240 |
+
if (existing_load['name'] == new_load['name'] and
|
| 241 |
+
existing_load['power'] == new_load['power'] and
|
| 242 |
+
existing_load['usage_factor'] == new_load['usage_factor'] and
|
| 243 |
+
existing_load['zone_type'] == new_load['zone_type'] and
|
| 244 |
+
existing_load['hours_in_operation'] == new_load['hours_in_operation']):
|
| 245 |
+
return False, f"Duplicate lighting load '{new_load['name']}' already exists."
|
| 246 |
+
elif load_type == 'equipment':
|
| 247 |
+
if (existing_load['name'] == new_load['name'] and
|
| 248 |
+
existing_load['power'] == new_load['power'] and
|
| 249 |
+
existing_load['usage_factor'] == new_load['usage_factor'] and
|
| 250 |
+
existing_load['radiation_fraction'] == new_load['radiation_fraction'] and
|
| 251 |
+
existing_load['zone_type'] == new_load['zone_type'] and
|
| 252 |
+
existing_load['hours_in_operation'] == new_load['hours_in_operation']):
|
| 253 |
+
return False, f"Duplicate equipment load '{new_load['name']}' already exists."
|
| 254 |
+
|
| 255 |
+
return True, "Valid load."
|
| 256 |
+
|
| 257 |
+
def display_internal_loads(self):
|
| 258 |
+
st.title("Internal Loads")
|
| 259 |
+
|
| 260 |
+
# Reset button for all internal loads
|
| 261 |
+
if st.button("Reset All Internal Loads"):
|
| 262 |
+
st.session_state.internal_loads = {'people': [], 'lighting': [], 'equipment': []}
|
| 263 |
+
st.success("All internal loads reset!")
|
| 264 |
+
st.rerun()
|
| 265 |
+
|
| 266 |
+
tabs = st.tabs(["People", "Lighting", "Equipment", "Ventilation"]) # NEW: Added Ventilation tab
|
| 267 |
+
|
| 268 |
+
with tabs[0]:
|
| 269 |
+
st.subheader("People")
|
| 270 |
+
with st.form("people_form"):
|
| 271 |
+
num_people = st.number_input(
|
| 272 |
+
"Number of People",
|
| 273 |
+
min_value=0,
|
| 274 |
+
value=0,
|
| 275 |
+
step=1,
|
| 276 |
+
help="Total number of occupants in the building"
|
| 277 |
+
)
|
| 278 |
+
activity_level = st.selectbox(
|
| 279 |
+
"Activity Level",
|
| 280 |
+
["Seated/Resting", "Light Work", "Moderate Work", "Heavy Work"],
|
| 281 |
+
help="Select typical activity level (affects internal heat gains per ASHRAE)"
|
| 282 |
+
)
|
| 283 |
+
zone_type = st.selectbox(
|
| 284 |
+
"Zone Type",
|
| 285 |
+
["Office", "Classroom", "Retail", "Residential"],
|
| 286 |
+
help="Select zone type for occupancy characteristics"
|
| 287 |
+
)
|
| 288 |
+
hours_in_operation = st.number_input(
|
| 289 |
+
"Hours in Operation",
|
| 290 |
+
min_value=0.0,
|
| 291 |
+
max_value=24.0,
|
| 292 |
+
value=8.0,
|
| 293 |
+
step=0.5,
|
| 294 |
+
help="Daily hours of occupancy"
|
| 295 |
+
)
|
| 296 |
+
people_name = st.text_input("Name", value="Occupants")
|
| 297 |
+
|
| 298 |
+
if st.form_submit_button("Add People Load"):
|
| 299 |
+
people_load = {
|
| 300 |
+
"id": f"people_{len(st.session_state.internal_loads['people'])}",
|
| 301 |
+
"name": people_name,
|
| 302 |
+
"num_people": num_people,
|
| 303 |
+
"activity_level": activity_level,
|
| 304 |
+
"zone_type": zone_type,
|
| 305 |
+
"hours_in_operation": hours_in_operation
|
| 306 |
+
}
|
| 307 |
+
is_valid, message = self.validate_internal_load('people', people_load)
|
| 308 |
+
if is_valid:
|
| 309 |
+
st.session_state.internal_loads['people'].append(people_load)
|
| 310 |
+
st.success("People load added!")
|
| 311 |
+
st.rerun()
|
| 312 |
+
else:
|
| 313 |
+
st.error(message)
|
| 314 |
+
|
| 315 |
+
if st.session_state.internal_loads['people']:
|
| 316 |
+
people_df = pd.DataFrame(st.session_state.internal_loads['people'])
|
| 317 |
+
st.dataframe(people_df, use_container_width=True)
|
| 318 |
+
|
| 319 |
+
selected_people = st.multiselect(
|
| 320 |
+
"Select People Loads to Delete",
|
| 321 |
+
[load['id'] for load in st.session_state.internal_loads['people']]
|
| 322 |
+
)
|
| 323 |
+
if st.button("Delete Selected People Loads"):
|
| 324 |
+
st.session_state.internal_loads['people'] = [
|
| 325 |
+
load for load in st.session_state.internal_loads['people']
|
| 326 |
+
if load['id'] not in selected_people
|
| 327 |
+
]
|
| 328 |
+
st.success("Selected people loads deleted!")
|
| 329 |
+
st.rerun()
|
| 330 |
+
|
| 331 |
+
with tabs[1]:
|
| 332 |
+
st.subheader("Lighting")
|
| 333 |
+
with st.form("lighting_form"):
|
| 334 |
+
power = st.number_input(
|
| 335 |
+
"Power (W)",
|
| 336 |
+
min_value=0.0,
|
| 337 |
+
value=1000.0,
|
| 338 |
+
step=100.0,
|
| 339 |
+
help="Total lighting power consumption"
|
| 340 |
+
)
|
| 341 |
+
usage_factor = st.number_input(
|
| 342 |
+
"Usage Factor",
|
| 343 |
+
min_value=0.0,
|
| 344 |
+
max_value=1.0,
|
| 345 |
+
value=0.8,
|
| 346 |
+
step=0.1,
|
| 347 |
+
help="Fraction of time lighting is in use (0 to 1)"
|
| 348 |
+
)
|
| 349 |
+
zone_type = st.selectbox(
|
| 350 |
+
"Zone Type",
|
| 351 |
+
["Office", "Classroom", "Retail", "Residential"],
|
| 352 |
+
help="Select zone type for lighting characteristics"
|
| 353 |
+
)
|
| 354 |
+
hours_in_operation = st.number_input(
|
| 355 |
+
"Hours in Operation",
|
| 356 |
+
min_value=0.0,
|
| 357 |
+
max_value=24.0,
|
| 358 |
+
value=8.0,
|
| 359 |
+
step=0.5,
|
| 360 |
+
help="Daily hours of lighting operation"
|
| 361 |
+
)
|
| 362 |
+
lighting_name = st.text_input("Name", value="General Lighting")
|
| 363 |
+
|
| 364 |
+
if st.form_submit_button("Add Lighting Load"):
|
| 365 |
+
lighting_load = {
|
| 366 |
+
"id": f"lighting_{len(st.session_state.internal_loads['lighting'])}",
|
| 367 |
+
"name": lighting_name,
|
| 368 |
+
"power": power,
|
| 369 |
+
"usage_factor": usage_factor,
|
| 370 |
+
"zone_type": zone_type,
|
| 371 |
+
"hours_in_operation": hours_in_operation
|
| 372 |
+
}
|
| 373 |
+
is_valid, message = self.validate_internal_load('lighting', lighting_load)
|
| 374 |
+
if is_valid:
|
| 375 |
+
st.session_state.internal_loads['lighting'].append(lighting_load)
|
| 376 |
+
st.success("Lighting load added!")
|
| 377 |
+
st.rerun()
|
| 378 |
+
else:
|
| 379 |
+
st.error(message)
|
| 380 |
+
|
| 381 |
+
if st.session_state.internal_loads['lighting']:
|
| 382 |
+
lighting_df = pd.DataFrame(st.session_state.internal_loads['lighting'])
|
| 383 |
+
st.dataframe(lighting_df, use_container_width=True)
|
| 384 |
+
|
| 385 |
+
selected_lighting = st.multiselect(
|
| 386 |
+
"Select Lighting Loads to Delete",
|
| 387 |
+
[load['id'] for load in st.session_state.internal_loads['lighting']]
|
| 388 |
+
)
|
| 389 |
+
if st.button("Delete Selected Lighting Loads"):
|
| 390 |
+
st.session_state.internal_loads['lighting'] = [
|
| 391 |
+
load for load in st.session_state.internal_loads['lighting']
|
| 392 |
+
if load['id'] not in selected_lighting
|
| 393 |
+
]
|
| 394 |
+
st.success("Selected lighting loads deleted!")
|
| 395 |
+
st.rerun()
|
| 396 |
+
|
| 397 |
+
with tabs[2]:
|
| 398 |
+
st.subheader("Equipment")
|
| 399 |
+
with st.form("equipment_form"):
|
| 400 |
+
power = st.number_input(
|
| 401 |
+
"Power (W)",
|
| 402 |
+
min_value=0.0,
|
| 403 |
+
value=500.0,
|
| 404 |
+
step=100.0,
|
| 405 |
+
help="Total equipment power consumption"
|
| 406 |
+
)
|
| 407 |
+
usage_factor = st.number_input(
|
| 408 |
+
"Usage Factor",
|
| 409 |
+
min_value=0.0,
|
| 410 |
+
max_value=1.0,
|
| 411 |
+
value=0.7,
|
| 412 |
+
step=0.1,
|
| 413 |
+
help="Fraction of time equipment is in use (0 to 1)"
|
| 414 |
+
)
|
| 415 |
+
radiation_fraction = st.number_input(
|
| 416 |
+
"Radiation Fraction",
|
| 417 |
+
min_value=0.0,
|
| 418 |
+
max_value=1.0,
|
| 419 |
+
value=0.3,
|
| 420 |
+
step=0.1,
|
| 421 |
+
help="Fraction of heat gain radiated to surroundings"
|
| 422 |
+
)
|
| 423 |
+
zone_type = st.selectbox(
|
| 424 |
+
"Zone Type",
|
| 425 |
+
["Office", "Classroom", "Retail", "Residential"],
|
| 426 |
+
help="Select zone type for equipment characteristics"
|
| 427 |
+
)
|
| 428 |
+
hours_in_operation = st.number_input(
|
| 429 |
+
"Hours in Operation",
|
| 430 |
+
min_value=0.0,
|
| 431 |
+
max_value=24.0,
|
| 432 |
+
value=8.0,
|
| 433 |
+
step=0.5,
|
| 434 |
+
help="Daily hours of equipment operation"
|
| 435 |
+
)
|
| 436 |
+
equipment_name = st.text_input("Name", value="Office Equipment")
|
| 437 |
+
|
| 438 |
+
if st.form_submit_button("Add Equipment Load"):
|
| 439 |
+
equipment_load = {
|
| 440 |
+
"id": f"equipment_{len(st.session_state.internal_loads['equipment'])}",
|
| 441 |
+
"name": equipment_name,
|
| 442 |
+
"power": power,
|
| 443 |
+
"usage_factor": usage_factor,
|
| 444 |
+
"radiation_fraction": radiation_fraction,
|
| 445 |
+
"zone_type": zone_type,
|
| 446 |
+
"hours_in_operation": hours_in_operation
|
| 447 |
+
}
|
| 448 |
+
is_valid, message = self.validate_internal_load('equipment', equipment_load)
|
| 449 |
+
if is_valid:
|
| 450 |
+
st.session_state.internal_loads['equipment'].append(equipment_load)
|
| 451 |
+
st.success("Equipment load added!")
|
| 452 |
+
st.rerun()
|
| 453 |
+
else:
|
| 454 |
+
st.error(message)
|
| 455 |
+
|
| 456 |
+
if st.session_state.internal_loads['equipment']:
|
| 457 |
+
equipment_df = pd.DataFrame(st.session_state.internal_loads['equipment'])
|
| 458 |
+
st.dataframe(equipment_df, use_container_width=True)
|
| 459 |
+
|
| 460 |
+
selected_equipment = st.multiselect(
|
| 461 |
+
"Select Equipment Loads to Delete",
|
| 462 |
+
[load['id'] for load in st.session_state.internal_loads['equipment']]
|
| 463 |
+
)
|
| 464 |
+
if st.button("Delete Selected Equipment Loads"):
|
| 465 |
+
st.session_state.internal_loads['equipment'] = [
|
| 466 |
+
load for load in st.session_state.internal_loads['equipment']
|
| 467 |
+
if load['id'] not in selected_equipment
|
| 468 |
+
]
|
| 469 |
+
st.success("Selected equipment loads deleted!")
|
| 470 |
+
st.rerun()
|
| 471 |
+
|
| 472 |
+
with tabs[3]: # NEW: Ventilation tab
|
| 473 |
+
st.subheader("Ventilation Requirements (ASHRAE 62.1)")
|
| 474 |
+
with st.form("ventilation_form"):
|
| 475 |
+
col1, col2 = st.columns(2)
|
| 476 |
+
with col1:
|
| 477 |
+
zone_type = st.selectbox(
|
| 478 |
+
"Zone Type",
|
| 479 |
+
["Office", "Classroom", "Retail", "Restaurant", "Custom"],
|
| 480 |
+
help="Select building zone type for ASHRAE 62.1 ventilation rates"
|
| 481 |
+
)
|
| 482 |
+
ventilation_method = st.selectbox(
|
| 483 |
+
"Ventilation Method",
|
| 484 |
+
["Constant Volume", "Demand-Controlled"],
|
| 485 |
+
help="Constant Volume uses fixed rate; Demand-Controlled adjusts based on occupancy"
|
| 486 |
+
)
|
| 487 |
+
with col2:
|
| 488 |
+
if zone_type == "Custom":
|
| 489 |
+
people_rate = st.number_input(
|
| 490 |
+
"Ventilation Rate per Person (L/s/person)",
|
| 491 |
+
min_value=0.0,
|
| 492 |
+
value=2.5,
|
| 493 |
+
step=0.1,
|
| 494 |
+
help="Custom ventilation rate per person (ASHRAE 62.1)"
|
| 495 |
+
)
|
| 496 |
+
area_rate = st.number_input(
|
| 497 |
+
"Ventilation Rate per Area (L/s/m²)",
|
| 498 |
+
min_value=0.0,
|
| 499 |
+
value=0.3,
|
| 500 |
+
step=0.1,
|
| 501 |
+
help="Custom ventilation rate per floor area (ASHRAE 62.1)"
|
| 502 |
+
)
|
| 503 |
+
else:
|
| 504 |
+
people_rate = VENTILATION_RATES[zone_type]["people_rate"]
|
| 505 |
+
area_rate = VENTILATION_RATES[zone_type]["area_rate"]
|
| 506 |
+
st.write(f"People Rate: {people_rate} L/s/person (ASHRAE 62.1)")
|
| 507 |
+
st.write(f"Area Rate: {area_rate} L/s/m² (ASHRAE 62.1)")
|
| 508 |
+
|
| 509 |
+
if st.form_submit_button("Save Ventilation Settings"):
|
| 510 |
+
total_people = sum(load['num_people'] for load in st.session_state.internal_loads.get('people', []))
|
| 511 |
+
floor_area = st.session_state.building_info.get('floor_area', 100.0)
|
| 512 |
+
ventilation_rate = (
|
| 513 |
+
(total_people * people_rate + floor_area * area_rate) / 1000 # Convert L/s to m³/s
|
| 514 |
+
)
|
| 515 |
+
if ventilation_method == 'Demand-Controlled':
|
| 516 |
+
ventilation_rate *= 0.75 # Reduce by 25% for DCV
|
| 517 |
+
st.session_state.building_info.update({
|
| 518 |
+
'zone_type': zone_type,
|
| 519 |
+
'ventilation_method': ventilation_method,
|
| 520 |
+
'ventilation_rate': ventilation_rate
|
| 521 |
+
})
|
| 522 |
+
st.success(f"Ventilation settings saved! Total rate: {ventilation_rate:.3f} m³/s")
|
| 523 |
+
|
| 524 |
+
col1, col2 = st.columns(2)
|
| 525 |
+
with col1:
|
| 526 |
+
st.button(
|
| 527 |
+
"Back to Building Components",
|
| 528 |
+
on_click=lambda: setattr(st.session_state, "page", "Building Components")
|
| 529 |
+
)
|
| 530 |
+
with col2:
|
| 531 |
+
st.button(
|
| 532 |
+
"Continue to Calculation Results",
|
| 533 |
+
on_click=lambda: setattr(st.session_state, "page", "Calculation Results")
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
def calculate_cooling(self) -> Tuple[bool, str, Dict]:
|
| 537 |
+
"""
|
| 538 |
+
Calculate cooling loads using CoolingLoadCalculator.
|
| 539 |
+
Returns: (success, message, results)
|
| 540 |
+
"""
|
| 541 |
+
try:
|
| 542 |
+
# Validate inputs
|
| 543 |
+
valid, message = self.validate_calculation_inputs()
|
| 544 |
+
if not valid:
|
| 545 |
+
return False, message, {}
|
| 546 |
+
|
| 547 |
+
# Gather inputs
|
| 548 |
+
building_components = st.session_state.get('components', {})
|
| 549 |
+
internal_loads = st.session_state.get('internal_loads', {})
|
| 550 |
+
building_info = st.session_state.get('building_info', {})
|
| 551 |
+
|
| 552 |
+
# Check climate data
|
| 553 |
+
if "climate_data" not in st.session_state or not st.session_state["climate_data"]:
|
| 554 |
+
return False, "Please enter climate data in the 'Climate Data' page.", {}
|
| 555 |
+
|
| 556 |
+
# Extract climate data
|
| 557 |
+
country = building_info.get('country', '').strip().title()
|
| 558 |
+
city = building_info.get('city', '').strip().title()
|
| 559 |
+
if not country or not city:
|
| 560 |
+
return False, "Country and city must be set in Building Information.", {}
|
| 561 |
+
climate_id = self.generate_climate_id(country, city)
|
| 562 |
+
location = self.climate_data.get_location_by_id(climate_id, st.session_state)
|
| 563 |
+
if not location:
|
| 564 |
+
available_locations = list(self.climate_data.locations.keys())[:5]
|
| 565 |
+
return False, f"No climate data for {climate_id}. Available locations: {', '.join(available_locations)}...", {}
|
| 566 |
+
|
| 567 |
+
# Validate climate data
|
| 568 |
+
if not all(k in location for k in ['summer_design_temp_db', 'summer_design_temp_wb', 'monthly_temps', 'latitude']):
|
| 569 |
+
return False, f"Invalid climate data for {climate_id}. Missing required fields.", {}
|
| 570 |
+
|
| 571 |
+
# Format conditions
|
| 572 |
+
outdoor_conditions = {
|
| 573 |
+
'temperature': location['summer_design_temp_db'],
|
| 574 |
+
'relative_humidity': location['monthly_humidity'].get('Jul', 50.0),
|
| 575 |
+
'ground_temperature': location['monthly_temps'].get('Jul', 20.0),
|
| 576 |
+
'month': 'Jul',
|
| 577 |
+
'latitude': location['latitude'], # Pass raw latitude value, validation will happen in cooling_load.py
|
| 578 |
+
'wind_speed': building_info.get('wind_speed', 4.0),
|
| 579 |
+
'day_of_year': 204 # Approx. July 23
|
| 580 |
+
}
|
| 581 |
+
indoor_conditions = {
|
| 582 |
+
'temperature': building_info.get('indoor_temp', 24.0),
|
| 583 |
+
'relative_humidity': building_info.get('indoor_rh', 50.0)
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
if st.session_state.get('debug_mode', False):
|
| 587 |
+
st.write("Debug: Cooling Input State", {
|
| 588 |
+
'climate_id': climate_id,
|
| 589 |
+
'outdoor_conditions': outdoor_conditions,
|
| 590 |
+
'indoor_conditions': indoor_conditions,
|
| 591 |
+
'components': {k: len(v) for k, v in building_components.items()},
|
| 592 |
+
'internal_loads': {
|
| 593 |
+
'people': len(internal_loads.get('people', [])),
|
| 594 |
+
'lighting': len(internal_loads.get('lighting', [])),
|
| 595 |
+
'equipment': len(internal_loads.get('equipment', []))
|
| 596 |
+
},
|
| 597 |
+
'building_info': building_info
|
| 598 |
+
})
|
| 599 |
+
|
| 600 |
+
# Format internal loads
|
| 601 |
+
formatted_internal_loads = {
|
| 602 |
+
'people': {
|
| 603 |
+
'number': sum(load['num_people'] for load in internal_loads.get('people', [])),
|
| 604 |
+
'activity_level': internal_loads.get('people', [{}])[0].get('activity_level', 'Seated/Resting'),
|
| 605 |
+
'operating_hours': f"{internal_loads.get('people', [{}])[0].get('hours_in_operation', 8)}:00-{internal_loads.get('people', [{}])[0].get('hours_in_operation', 8)+10}:00"
|
| 606 |
+
},
|
| 607 |
+
'lights': {
|
| 608 |
+
'power': sum(load['power'] for load in internal_loads.get('lighting', [])),
|
| 609 |
+
'use_factor': internal_loads.get('lighting', [{}])[0].get('usage_factor', 0.8),
|
| 610 |
+
'special_allowance': 0.1,
|
| 611 |
+
'hours_operation': f"{internal_loads.get('lighting', [{}])[0].get('hours_in_operation', 8)}h"
|
| 612 |
+
},
|
| 613 |
+
'equipment': {
|
| 614 |
+
'power': sum(load['power'] for load in internal_loads.get('equipment', [])),
|
| 615 |
+
'use_factor': internal_loads.get('equipment', [{}])[0].get('usage_factor', 0.7),
|
| 616 |
+
'radiation_factor': internal_loads.get('equipment', [{}])[0].get('radiation_fraction', 0.3),
|
| 617 |
+
'hours_operation': f"{internal_loads.get('equipment', [{}])[0].get('hours_in_operation', 8)}h"
|
| 618 |
+
},
|
| 619 |
+
'infiltration': {
|
| 620 |
+
'flow_rate': building_info.get('infiltration_rate', 0.05),
|
| 621 |
+
'height': building_info.get('building_height', 3.0),
|
| 622 |
+
'crack_length': building_info.get('crack_length', 10.0)
|
| 623 |
+
},
|
| 624 |
+
'ventilation': {
|
| 625 |
+
'flow_rate': building_info.get('ventilation_rate', 0.1)
|
| 626 |
+
},
|
| 627 |
+
'operating_hours': building_info.get('operating_hours', '8:00-18:00')
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
# Calculate hourly loads
|
| 631 |
+
hourly_loads = self.cooling_calculator.calculate_hourly_cooling_loads(
|
| 632 |
+
building_components=building_components,
|
| 633 |
+
outdoor_conditions=outdoor_conditions,
|
| 634 |
+
indoor_conditions=indoor_conditions,
|
| 635 |
+
internal_loads=formatted_internal_loads,
|
| 636 |
+
building_volume=building_info.get('floor_area', 100.0) * building_info.get('building_height', 3.0)
|
| 637 |
+
)
|
| 638 |
+
if not hourly_loads:
|
| 639 |
+
return False, "Cooling hourly loads calculation failed. Check input data.", {}
|
| 640 |
+
|
| 641 |
+
# Get design loads
|
| 642 |
+
design_loads = self.cooling_calculator.calculate_design_cooling_load(hourly_loads)
|
| 643 |
+
if not design_loads:
|
| 644 |
+
return False, "Cooling design loads calculation failed. Check input data.", {}
|
| 645 |
+
|
| 646 |
+
# Get summary
|
| 647 |
+
summary = self.cooling_calculator.calculate_cooling_load_summary(design_loads)
|
| 648 |
+
if not summary:
|
| 649 |
+
return False, "Cooling load summary calculation failed. Check input data.", {}
|
| 650 |
+
|
| 651 |
+
# Ensure summary has all required keys
|
| 652 |
+
if 'total' not in summary:
|
| 653 |
+
# Calculate total if missing
|
| 654 |
+
if 'total_sensible' in summary and 'total_latent' in summary:
|
| 655 |
+
summary['total'] = summary['total_sensible'] + summary['total_latent']
|
| 656 |
+
else:
|
| 657 |
+
# Fallback to sum of design loads if needed
|
| 658 |
+
total_load = sum(value for key, value in design_loads.items() if key != 'design_hour')
|
| 659 |
+
summary = {
|
| 660 |
+
'total_sensible': total_load * 0.7, # Approximate sensible ratio
|
| 661 |
+
'total_latent': total_load * 0.3, # Approximate latent ratio
|
| 662 |
+
'total': total_load
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
# Format results for results_display.py
|
| 666 |
+
floor_area = building_info.get('floor_area', 100.0) or 100.0
|
| 667 |
+
results = {
|
| 668 |
+
'total_load': summary['total'] / 1000, # kW
|
| 669 |
+
'sensible_load': summary['total_sensible'] / 1000, # kW
|
| 670 |
+
'latent_load': summary['total_latent'] / 1000, # kW
|
| 671 |
+
'load_per_area': summary['total'] / floor_area, # W/m²
|
| 672 |
+
'component_loads': {
|
| 673 |
+
'walls': design_loads['walls'] / 1000,
|
| 674 |
+
'roof': design_loads['roofs'] / 1000,
|
| 675 |
+
'windows': (design_loads['windows_conduction'] + design_loads['windows_solar']) / 1000,
|
| 676 |
+
'doors': design_loads['doors'] / 1000,
|
| 677 |
+
'people': (design_loads['people_sensible'] + design_loads['people_latent']) / 1000,
|
| 678 |
+
'lighting': design_loads['lights'] / 1000,
|
| 679 |
+
'equipment': (design_loads['equipment_sensible'] + design_loads['equipment_latent']) / 1000,
|
| 680 |
+
'infiltration': (design_loads['infiltration_sensible'] + design_loads['infiltration_latent']) / 1000,
|
| 681 |
+
'ventilation': (design_loads['ventilation_sensible'] + design_loads['ventilation_latent']) / 1000
|
| 682 |
+
},
|
| 683 |
+
'detailed_loads': {
|
| 684 |
+
'walls': [],
|
| 685 |
+
'roofs': [],
|
| 686 |
+
'windows': [],
|
| 687 |
+
'doors': [],
|
| 688 |
+
'internal': [],
|
| 689 |
+
'infiltration': {
|
| 690 |
+
'air_flow': formatted_internal_loads['infiltration']['flow_rate'],
|
| 691 |
+
'sensible_load': design_loads['infiltration_sensible'] / 1000,
|
| 692 |
+
'latent_load': design_loads['infiltration_latent'] / 1000,
|
| 693 |
+
'total_load': (design_loads['infiltration_sensible'] + design_loads['infiltration_latent']) / 1000
|
| 694 |
+
},
|
| 695 |
+
'ventilation': {
|
| 696 |
+
'air_flow': formatted_internal_loads['ventilation']['flow_rate'],
|
| 697 |
+
'sensible_load': design_loads['ventilation_sensible'] / 1000,
|
| 698 |
+
'latent_load': design_loads['infiltration_latent'] / 1000,
|
| 699 |
+
'total_load': (design_loads['ventilation_sensible'] + design_loads['ventilation_latent']) / 1000
|
| 700 |
+
}
|
| 701 |
+
},
|
| 702 |
+
'building_info': building_info
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
# Populate detailed loads
|
| 706 |
+
for wall in building_components.get('walls', []):
|
| 707 |
+
load = self.cooling_calculator.calculate_wall_cooling_load(
|
| 708 |
+
wall=wall,
|
| 709 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 710 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 711 |
+
month=outdoor_conditions['month'],
|
| 712 |
+
hour=design_loads['design_hour'],
|
| 713 |
+
latitude=outdoor_conditions['latitude']
|
| 714 |
+
)
|
| 715 |
+
results['detailed_loads']['walls'].append({
|
| 716 |
+
'name': wall.name,
|
| 717 |
+
'orientation': wall.orientation.value,
|
| 718 |
+
'area': wall.area,
|
| 719 |
+
'u_value': wall.u_value,
|
| 720 |
+
'cltd': self.cooling_calculator.ashrae_tables.calculate_corrected_cltd_wall(
|
| 721 |
+
wall_group=wall.wall_group,
|
| 722 |
+
orientation=wall.orientation.value,
|
| 723 |
+
hour=design_loads['design_hour'],
|
| 724 |
+
color='Dark',
|
| 725 |
+
month=outdoor_conditions['month'],
|
| 726 |
+
latitude=outdoor_conditions['latitude'],
|
| 727 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 728 |
+
outdoor_temp=outdoor_conditions['temperature']
|
| 729 |
+
),
|
| 730 |
+
'load': load / 1000
|
| 731 |
+
})
|
| 732 |
+
|
| 733 |
+
for roof in building_components.get('roofs', []):
|
| 734 |
+
load = self.cooling_calculator.calculate_roof_cooling_load(
|
| 735 |
+
roof=roof,
|
| 736 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 737 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 738 |
+
month=outdoor_conditions['month'],
|
| 739 |
+
hour=design_loads['design_hour'],
|
| 740 |
+
latitude=outdoor_conditions['latitude']
|
| 741 |
+
)
|
| 742 |
+
results['detailed_loads']['roofs'].append({
|
| 743 |
+
'name': roof.name,
|
| 744 |
+
'orientation': roof.orientation.value,
|
| 745 |
+
'area': roof.area,
|
| 746 |
+
'u_value': roof.u_value,
|
| 747 |
+
'cltd': self.cooling_calculator.ashrae_tables.calculate_corrected_cltd_roof(
|
| 748 |
+
roof_group=roof.roof_group,
|
| 749 |
+
hour=design_loads['design_hour'],
|
| 750 |
+
color='Dark',
|
| 751 |
+
month=outdoor_conditions['month'],
|
| 752 |
+
latitude=outdoor_conditions['latitude'],
|
| 753 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 754 |
+
outdoor_temp=outdoor_conditions['temperature']
|
| 755 |
+
),
|
| 756 |
+
'load': load / 1000
|
| 757 |
+
})
|
| 758 |
+
|
| 759 |
+
for window in building_components.get('windows', []):
|
| 760 |
+
load_dict = self.cooling_calculator.calculate_window_cooling_load(
|
| 761 |
+
window=window,
|
| 762 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 763 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 764 |
+
month=outdoor_conditions['month'],
|
| 765 |
+
hour=design_loads['design_hour'],
|
| 766 |
+
latitude=outdoor_conditions['latitude'],
|
| 767 |
+
shading_coefficient=window.shading_coefficient
|
| 768 |
+
)
|
| 769 |
+
# Ensure load_dict has a 'total' key
|
| 770 |
+
if 'total' not in load_dict:
|
| 771 |
+
if 'conduction' in load_dict and 'solar' in load_dict:
|
| 772 |
+
load_dict['total'] = load_dict['conduction'] + load_dict['solar']
|
| 773 |
+
else:
|
| 774 |
+
load_dict['total'] = window.u_value * window.area * (outdoor_conditions['temperature'] - indoor_conditions['temperature'])
|
| 775 |
+
|
| 776 |
+
# Pass latitude directly to get_scl method which has its own validation
|
| 777 |
+
results['detailed_loads']['windows'].append({
|
| 778 |
+
'name': window.name,
|
| 779 |
+
'orientation': window.orientation.value,
|
| 780 |
+
'area': window.area,
|
| 781 |
+
'u_value': window.u_value,
|
| 782 |
+
'shgc': window.shgc,
|
| 783 |
+
'shading_device': window.shading_device,
|
| 784 |
+
'shading_coefficient': window.shading_coefficient,
|
| 785 |
+
'scl': self.cooling_calculator.ashrae_tables.get_scl(
|
| 786 |
+
latitude=outdoor_conditions['latitude'],
|
| 787 |
+
month=outdoor_conditions['month'].title(),
|
| 788 |
+
orientation=window.orientation.value,
|
| 789 |
+
hour=design_loads['design_hour']
|
| 790 |
+
),
|
| 791 |
+
'load': load_dict['total'] / 1000
|
| 792 |
+
})
|
| 793 |
+
|
| 794 |
+
for door in building_components.get('doors', []):
|
| 795 |
+
load = self.cooling_calculator.calculate_door_cooling_load(
|
| 796 |
+
door=door,
|
| 797 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 798 |
+
indoor_temp=indoor_conditions['temperature']
|
| 799 |
+
)
|
| 800 |
+
results['detailed_loads']['doors'].append({
|
| 801 |
+
'name': door.name,
|
| 802 |
+
'orientation': door.orientation.value,
|
| 803 |
+
'area': door.area,
|
| 804 |
+
'u_value': door.u_value,
|
| 805 |
+
'cltd': outdoor_conditions['temperature'] - indoor_conditions['temperature'],
|
| 806 |
+
'load': load / 1000
|
| 807 |
+
})
|
| 808 |
+
|
| 809 |
+
for load_type, key in [('people', 'people'), ('lighting', 'lights'), ('equipment', 'equipment')]:
|
| 810 |
+
for load in internal_loads.get(key, []):
|
| 811 |
+
if load_type == 'people':
|
| 812 |
+
load_dict = self.cooling_calculator.calculate_people_cooling_load(
|
| 813 |
+
num_people=load['num_people'],
|
| 814 |
+
activity_level=load['activity_level'],
|
| 815 |
+
hour=design_loads['design_hour']
|
| 816 |
+
)
|
| 817 |
+
# Ensure load_dict has a 'total' key
|
| 818 |
+
if 'total' not in load_dict and ('sensible' in load_dict or 'latent' in load_dict):
|
| 819 |
+
load_dict['total'] = load_dict.get('sensible', 0) + load_dict.get('latent', 0)
|
| 820 |
+
elif load_type == 'lighting':
|
| 821 |
+
light_load = self.cooling_calculator.calculate_lights_cooling_load(
|
| 822 |
+
power=load['power'],
|
| 823 |
+
use_factor=load['usage_factor'],
|
| 824 |
+
special_allowance=0.1,
|
| 825 |
+
hour=design_loads['design_hour']
|
| 826 |
+
)
|
| 827 |
+
load_dict = {'total': light_load if light_load is not None else 0}
|
| 828 |
+
else:
|
| 829 |
+
load_dict = self.cooling_calculator.calculate_equipment_cooling_load(
|
| 830 |
+
power=load['power'],
|
| 831 |
+
use_factor=load['usage_factor'],
|
| 832 |
+
radiation_factor=load['radiation_fraction'],
|
| 833 |
+
hour=design_loads['design_hour']
|
| 834 |
+
)
|
| 835 |
+
# Ensure load_dict has a 'total' key
|
| 836 |
+
if 'total' not in load_dict and ('sensible' in load_dict or 'latent' in load_dict):
|
| 837 |
+
load_dict['total'] = load_dict.get('sensible', 0) + load_dict.get('latent', 0)
|
| 838 |
+
results['detailed_loads']['internal'].append({
|
| 839 |
+
'type': load_type.capitalize(),
|
| 840 |
+
'name': load['name'],
|
| 841 |
+
'quantity': load.get('num_people', load.get('power', 1)),
|
| 842 |
+
'heat_gain': load_dict.get('sensible', load_dict.get('total', 0)),
|
| 843 |
+
'clf': self.cooling_calculator.ashrae_tables.get_clf_people(
|
| 844 |
+
zone_type='A',
|
| 845 |
+
hours_occupied='6h', # Using valid '6h' instead of dynamic value that might not exist
|
| 846 |
+
hour=design_loads['design_hour']
|
| 847 |
+
) if load_type == 'people' else 1.0,
|
| 848 |
+
'load': load_dict.get('total', 0) / 1000
|
| 849 |
+
})
|
| 850 |
+
|
| 851 |
+
if st.session_state.get('debug_mode', False):
|
| 852 |
+
st.write("Debug: Cooling Results", {
|
| 853 |
+
'total_load': results.get('total_load', 'N/A'),
|
| 854 |
+
'component_loads': results.get('component_loads', 'N/A'),
|
| 855 |
+
'detailed_loads': {k: len(v) if isinstance(v, list) else v for k, v in results.get('detailed_loads', {}).items()}
|
| 856 |
+
})
|
| 857 |
+
|
| 858 |
+
return True, "Cooling calculation completed.", results
|
| 859 |
+
|
| 860 |
+
except ValueError as ve:
|
| 861 |
+
st.error(f"Input error in cooling calculation: {str(ve)}")
|
| 862 |
+
return False, f"Input error: {str(ve)}", {}
|
| 863 |
+
except KeyError as ke:
|
| 864 |
+
st.error(f"Missing data in cooling calculation: {str(ke)}")
|
| 865 |
+
return False, f"Missing data: {str(ke)}", {}
|
| 866 |
+
except Exception as e:
|
| 867 |
+
st.error(f"Unexpected error in cooling calculation: {str(e)}")
|
| 868 |
+
return False, f"Unexpected error: {str(e)}", {}
|
| 869 |
+
|
| 870 |
+
def calculate_heating(self) -> Tuple[bool, str, Dict]:
|
| 871 |
+
"""
|
| 872 |
+
Calculate heating loads using HeatingLoadCalculator.
|
| 873 |
+
Returns: (success, message, results)
|
| 874 |
+
"""
|
| 875 |
+
try:
|
| 876 |
+
# Validate inputs
|
| 877 |
+
valid, message = self.validate_calculation_inputs()
|
| 878 |
+
if not valid:
|
| 879 |
+
return False, message, {}
|
| 880 |
+
|
| 881 |
+
# Gather inputs
|
| 882 |
+
building_components = st.session_state.get('components', {})
|
| 883 |
+
internal_loads = st.session_state.get('internal_loads', {})
|
| 884 |
+
building_info = st.session_state.get('building_info', {})
|
| 885 |
+
|
| 886 |
+
# Check climate data
|
| 887 |
+
if "climate_data" not in st.session_state or not st.session_state["climate_data"]:
|
| 888 |
+
return False, "Please enter climate data in the 'Climate Data' page.", {}
|
| 889 |
+
|
| 890 |
+
# Extract climate data
|
| 891 |
+
country = building_info.get('country', '').strip().title()
|
| 892 |
+
city = building_info.get('city', '').strip().title()
|
| 893 |
+
if not country or not city:
|
| 894 |
+
return False, "Country and city must be set in Building Information.", {}
|
| 895 |
+
climate_id = self.generate_climate_id(country, city)
|
| 896 |
+
location = self.climate_data.get_location_by_id(climate_id, st.session_state)
|
| 897 |
+
if not location:
|
| 898 |
+
available_locations = list(self.climate_data.locations.keys())[:5]
|
| 899 |
+
return False, f"No climate data for {climate_id}. Available locations: {', '.join(available_locations)}...", {}
|
| 900 |
+
|
| 901 |
+
# Validate climate data
|
| 902 |
+
if not all(k in location for k in ['winter_design_temp', 'monthly_temps', 'monthly_humidity']):
|
| 903 |
+
return False, f"Invalid climate data for {climate_id}. Missing required fields.", {}
|
| 904 |
+
|
| 905 |
+
# NEW: Calculate ground temperature from floors or fallback to climate data
|
| 906 |
+
ground_contact_floors = [f for f in building_components.get('floors', []) if getattr(f, 'ground_contact', False)]
|
| 907 |
+
ground_temperature = (
|
| 908 |
+
sum(f.ground_temperature_c for f in ground_contact_floors) / len(ground_contact_floors)
|
| 909 |
+
if ground_contact_floors else
|
| 910 |
+
location['monthly_temps'].get('Jan', 10.0)
|
| 911 |
+
)
|
| 912 |
+
if not -10 <= ground_temperature <= 40:
|
| 913 |
+
return False, f"Invalid ground temperature: {ground_temperature}°C", {}
|
| 914 |
+
|
| 915 |
+
# NEW: Skip heating calculation if outdoor temp exceeds indoor temp
|
| 916 |
+
indoor_temp = building_info.get('indoor_temp', 21.0)
|
| 917 |
+
outdoor_temp = location['winter_design_temp']
|
| 918 |
+
if outdoor_temp >= indoor_temp:
|
| 919 |
+
results = {
|
| 920 |
+
'total_load': 0.0,
|
| 921 |
+
'load_per_area': 0.0,
|
| 922 |
+
'design_heat_loss': 0.0,
|
| 923 |
+
'safety_factor': 115.0,
|
| 924 |
+
'component_loads': {
|
| 925 |
+
'walls': 0.0,
|
| 926 |
+
'roof': 0.0,
|
| 927 |
+
'floor': 0.0,
|
| 928 |
+
'windows': 0.0,
|
| 929 |
+
'doors': 0.0,
|
| 930 |
+
'infiltration': 0.0,
|
| 931 |
+
'ventilation': 0.0
|
| 932 |
+
},
|
| 933 |
+
'detailed_loads': {
|
| 934 |
+
'walls': [],
|
| 935 |
+
'roofs': [],
|
| 936 |
+
'floors': [],
|
| 937 |
+
'windows': [],
|
| 938 |
+
'doors': [],
|
| 939 |
+
'infiltration': {'air_flow': 0.0, 'delta_t': 0.0, 'load': 0.0},
|
| 940 |
+
'ventilation': {'air_flow': 0.0, 'delta_t': 0.0, 'load': 0.0}
|
| 941 |
+
},
|
| 942 |
+
'building_info': building_info
|
| 943 |
+
}
|
| 944 |
+
return True, "No heating required (outdoor temp exceeds indoor temp).", results
|
| 945 |
+
|
| 946 |
+
# Format conditions
|
| 947 |
+
outdoor_conditions = {
|
| 948 |
+
'design_temperature': location['winter_design_temp'],
|
| 949 |
+
'design_relative_humidity': location['monthly_humidity'].get('Jan', 80.0),
|
| 950 |
+
'ground_temperature': ground_temperature,
|
| 951 |
+
'wind_speed': building_info.get('wind_speed', 4.0)
|
| 952 |
+
}
|
| 953 |
+
indoor_conditions = {
|
| 954 |
+
'temperature': indoor_temp,
|
| 955 |
+
'relative_humidity': building_info.get('indoor_rh', 40.0)
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
if st.session_state.get('debug_mode', False):
|
| 959 |
+
st.write("Debug: Heating Input State", {
|
| 960 |
+
'climate_id': climate_id,
|
| 961 |
+
'outdoor_conditions': outdoor_conditions,
|
| 962 |
+
'indoor_conditions': indoor_conditions,
|
| 963 |
+
'components': {k: len(v) for k, v in building_components.items()},
|
| 964 |
+
'internal_loads': {
|
| 965 |
+
'people': len(internal_loads.get('people', [])),
|
| 966 |
+
'lighting': len(internal_loads.get('lighting', [])),
|
| 967 |
+
'equipment': len(internal_loads.get('equipment', []))
|
| 968 |
+
},
|
| 969 |
+
'building_info': building_info
|
| 970 |
+
})
|
| 971 |
+
|
| 972 |
+
# NEW: Activity-level-based sensible gains
|
| 973 |
+
ACTIVITY_GAINS = {
|
| 974 |
+
'Seated/Resting': 70.0, # W/person
|
| 975 |
+
'Light Work': 85.0,
|
| 976 |
+
'Moderate Work': 100.0,
|
| 977 |
+
'Heavy Work': 150.0
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
# Format internal loads
|
| 981 |
+
formatted_internal_loads = {
|
| 982 |
+
'people': {
|
| 983 |
+
'number': sum(load['num_people'] for load in internal_loads.get('people', [])),
|
| 984 |
+
'sensible_gain': ACTIVITY_GAINS.get(
|
| 985 |
+
internal_loads.get('people', [{}])[0].get('activity_level', 'Seated/Resting'),
|
| 986 |
+
70.0
|
| 987 |
+
),
|
| 988 |
+
'operating_hours': f"{internal_loads.get('people', [{}])[0].get('hours_in_operation', 8)}:00-{internal_loads.get('people', [{}])[0].get('hours_in_operation', 8)+10}:00"
|
| 989 |
+
},
|
| 990 |
+
'lights': {
|
| 991 |
+
'power': sum(load['power'] for load in internal_loads.get('lighting', [])),
|
| 992 |
+
'use_factor': internal_loads.get('lighting', [{}])[0].get('usage_factor', 0.8),
|
| 993 |
+
'hours_operation': f"{internal_loads.get('lighting', [{}])[0].get('hours_in_operation', 8)}h"
|
| 994 |
+
},
|
| 995 |
+
'equipment': {
|
| 996 |
+
'power': sum(load['power'] for load in internal_loads.get('equipment', [])),
|
| 997 |
+
'use_factor': internal_loads.get('equipment', [{}])[0].get('usage_factor', 0.7),
|
| 998 |
+
'hours_operation': f"{internal_loads.get('equipment', [{}])[0].get('hours_in_operation', 8)}h"
|
| 999 |
+
},
|
| 1000 |
+
'infiltration': {
|
| 1001 |
+
'flow_rate': building_info.get('infiltration_rate', 0.05),
|
| 1002 |
+
'height': building_info.get('building_height', 3.0),
|
| 1003 |
+
'crack_length': building_info.get('crack_length', 10.0)
|
| 1004 |
+
},
|
| 1005 |
+
'ventilation': {
|
| 1006 |
+
'flow_rate': building_info.get('ventilation_rate', 0.1)
|
| 1007 |
+
},
|
| 1008 |
+
'usage_factor': 0.7,
|
| 1009 |
+
'operating_hours': building_info.get('operating_hours', '8:00-18:00')
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
# Calculate design loads
|
| 1013 |
+
design_loads = self.heating_calculator.calculate_design_heating_load(
|
| 1014 |
+
building_components=building_components,
|
| 1015 |
+
outdoor_conditions=outdoor_conditions,
|
| 1016 |
+
indoor_conditions=indoor_conditions,
|
| 1017 |
+
internal_loads=formatted_internal_loads
|
| 1018 |
+
)
|
| 1019 |
+
if not design_loads:
|
| 1020 |
+
return False, "Heating design loads calculation failed. Check input data.", {}
|
| 1021 |
+
|
| 1022 |
+
# Get summary
|
| 1023 |
+
summary = self.heating_calculator.calculate_heating_load_summary(design_loads)
|
| 1024 |
+
if not summary:
|
| 1025 |
+
return False, "Heating load summary calculation failed. Check input data.", {}
|
| 1026 |
+
|
| 1027 |
+
# Format results
|
| 1028 |
+
floor_area = building_info.get('floor_area', 100.0) or 100.0
|
| 1029 |
+
results = {
|
| 1030 |
+
'total_load': summary['total'] / 1000, # kW
|
| 1031 |
+
'load_per_area': summary['total'] / floor_area, # W/m²
|
| 1032 |
+
'design_heat_loss': summary['subtotal'] / 1000, # kW
|
| 1033 |
+
'safety_factor': summary['safety_factor'] * 100, # %
|
| 1034 |
+
'component_loads': {
|
| 1035 |
+
'walls': design_loads['walls'] / 1000,
|
| 1036 |
+
'roof': design_loads['roofs'] / 1000,
|
| 1037 |
+
'floor': design_loads['floors'] / 1000,
|
| 1038 |
+
'windows': design_loads['windows'] / 1000,
|
| 1039 |
+
'doors': design_loads['doors'] / 1000,
|
| 1040 |
+
'infiltration': (design_loads['infiltration_sensible'] + design_loads['infiltration_latent']) / 1000,
|
| 1041 |
+
'ventilation': (design_loads['ventilation_sensible'] + design_loads['ventilation_latent']) / 1000
|
| 1042 |
+
},
|
| 1043 |
+
'detailed_loads': {
|
| 1044 |
+
'walls': [],
|
| 1045 |
+
'roofs': [],
|
| 1046 |
+
'floors': [],
|
| 1047 |
+
'windows': [],
|
| 1048 |
+
'doors': [],
|
| 1049 |
+
'infiltration': {
|
| 1050 |
+
'air_flow': formatted_internal_loads['infiltration']['flow_rate'],
|
| 1051 |
+
'delta_t': indoor_conditions['temperature'] - outdoor_conditions['design_temperature'],
|
| 1052 |
+
'load': (design_loads['infiltration_sensible'] + design_loads['infiltration_latent']) / 1000
|
| 1053 |
+
},
|
| 1054 |
+
'ventilation': {
|
| 1055 |
+
'air_flow': formatted_internal_loads['ventilation']['flow_rate'],
|
| 1056 |
+
'delta_t': indoor_conditions['temperature'] - outdoor_conditions['design_temperature'],
|
| 1057 |
+
'load': (design_loads['ventilation_sensible'] + design_loads['ventilation_latent']) / 1000
|
| 1058 |
+
}
|
| 1059 |
+
},
|
| 1060 |
+
'building_info': building_info
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
# Populate detailed loads
|
| 1064 |
+
delta_t = indoor_conditions['temperature'] - outdoor_conditions['design_temperature']
|
| 1065 |
+
for wall in building_components.get('walls', []):
|
| 1066 |
+
load = self.heating_calculator.calculate_wall_heating_load(
|
| 1067 |
+
wall=wall,
|
| 1068 |
+
outdoor_temp=outdoor_conditions['design_temperature'],
|
| 1069 |
+
indoor_temp=indoor_conditions['temperature']
|
| 1070 |
+
)
|
| 1071 |
+
results['detailed_loads']['walls'].append({
|
| 1072 |
+
'name': wall.name,
|
| 1073 |
+
'orientation': wall.orientation.value,
|
| 1074 |
+
'area': wall.area,
|
| 1075 |
+
'u_value': wall.u_value,
|
| 1076 |
+
'delta_t': delta_t,
|
| 1077 |
+
'load': load / 1000
|
| 1078 |
+
})
|
| 1079 |
+
|
| 1080 |
+
for roof in building_components.get('roofs', []):
|
| 1081 |
+
load = self.heating_calculator.calculate_roof_heating_load(
|
| 1082 |
+
roof=roof,
|
| 1083 |
+
outdoor_temp=outdoor_conditions['design_temperature'],
|
| 1084 |
+
indoor_temp=indoor_conditions['temperature']
|
| 1085 |
+
)
|
| 1086 |
+
results['detailed_loads']['roofs'].append({
|
| 1087 |
+
'name': roof.name,
|
| 1088 |
+
'orientation': roof.orientation.value,
|
| 1089 |
+
'area': roof.area,
|
| 1090 |
+
'u_value': roof.u_value,
|
| 1091 |
+
'delta_t': delta_t,
|
| 1092 |
+
'load': load / 1000
|
| 1093 |
+
})
|
| 1094 |
+
|
| 1095 |
+
for floor in building_components.get('floors', []):
|
| 1096 |
+
load = self.heating_calculator.calculate_floor_heating_load(
|
| 1097 |
+
floor=floor,
|
| 1098 |
+
ground_temp=outdoor_conditions['ground_temperature'],
|
| 1099 |
+
indoor_temp=indoor_conditions['temperature']
|
| 1100 |
+
)
|
| 1101 |
+
results['detailed_loads']['floors'].append({
|
| 1102 |
+
'name': floor.name,
|
| 1103 |
+
'area': floor.area,
|
| 1104 |
+
'u_value': floor.u_value,
|
| 1105 |
+
'delta_t': indoor_conditions['temperature'] - outdoor_conditions['ground_temperature'],
|
| 1106 |
+
'load': load / 1000
|
| 1107 |
+
})
|
| 1108 |
+
|
| 1109 |
+
for window in building_components.get('windows', []):
|
| 1110 |
+
load = self.heating_calculator.calculate_window_heating_load(
|
| 1111 |
+
window=window,
|
| 1112 |
+
outdoor_temp=outdoor_conditions['design_temperature'],
|
| 1113 |
+
indoor_temp=indoor_conditions['temperature']
|
| 1114 |
+
)
|
| 1115 |
+
results['detailed_loads']['windows'].append({
|
| 1116 |
+
'name': window.name,
|
| 1117 |
+
'orientation': window.orientation.value,
|
| 1118 |
+
'area': window.area,
|
| 1119 |
+
'u_value': window.u_value,
|
| 1120 |
+
'delta_t': delta_t,
|
| 1121 |
+
'load': load / 1000
|
| 1122 |
+
})
|
| 1123 |
+
|
| 1124 |
+
for door in building_components.get('doors', []):
|
| 1125 |
+
load = self.heating_calculator.calculate_door_heating_load(
|
| 1126 |
+
door=door,
|
| 1127 |
+
outdoor_temp=outdoor_conditions['design_temperature'],
|
| 1128 |
+
indoor_temp=indoor_conditions['temperature']
|
| 1129 |
+
)
|
| 1130 |
+
results['detailed_loads']['doors'].append({
|
| 1131 |
+
'name': door.name,
|
| 1132 |
+
'orientation': door.orientation.value,
|
| 1133 |
+
'area': door.area,
|
| 1134 |
+
'u_value': door.u_value,
|
| 1135 |
+
'delta_t': delta_t,
|
| 1136 |
+
'load': load / 1000
|
| 1137 |
+
})
|
| 1138 |
+
|
| 1139 |
+
if st.session_state.get('debug_mode', False):
|
| 1140 |
+
st.write("Debug: Heating Results", {
|
| 1141 |
+
'total_load': results.get('total_load', 'N/A'),
|
| 1142 |
+
'component_loads': results.get('component_loads', 'N/A'),
|
| 1143 |
+
'detailed_loads': {k: len(v) if isinstance(v, list) else v for k, v in results.get('detailed_loads', {}).items()}
|
| 1144 |
+
})
|
| 1145 |
+
|
| 1146 |
+
return True, "Heating calculation completed.", results
|
| 1147 |
+
|
| 1148 |
+
except ValueError as ve:
|
| 1149 |
+
st.error(f"Input error in heating calculation: {str(ve)}")
|
| 1150 |
+
return False, f"Input error: {str(ve)}", {}
|
| 1151 |
+
except KeyError as ke:
|
| 1152 |
+
st.error(f"Missing data in heating calculation: {str(ke)}")
|
| 1153 |
+
return False, f"Missing data: {str(ke)}", {}
|
| 1154 |
+
except Exception as e:
|
| 1155 |
+
st.error(f"Unexpected error in heating calculation: {str(e)}")
|
| 1156 |
+
return False, f"Unexpected error: {str(e)}", {}
|
| 1157 |
+
|
| 1158 |
+
def display_calculation_results(self):
|
| 1159 |
+
st.title("Calculation Results")
|
| 1160 |
+
|
| 1161 |
+
col1, col2 = st.columns(2)
|
| 1162 |
+
with col1:
|
| 1163 |
+
calculate_button = st.button("Calculate Loads")
|
| 1164 |
+
with col2:
|
| 1165 |
+
st.session_state.debug_mode = st.checkbox("Debug Mode", value=st.session_state.get('debug_mode', False))
|
| 1166 |
+
|
| 1167 |
+
if calculate_button:
|
| 1168 |
+
# Reset results
|
| 1169 |
+
st.session_state.calculation_results = {'cooling': {}, 'heating': {}}
|
| 1170 |
+
|
| 1171 |
+
with st.spinner("Calculating loads..."):
|
| 1172 |
+
# Calculate cooling load
|
| 1173 |
+
cooling_success, cooling_message, cooling_results = self.calculate_cooling()
|
| 1174 |
+
if cooling_success:
|
| 1175 |
+
st.session_state.calculation_results['cooling'] = cooling_results
|
| 1176 |
+
st.success(cooling_message)
|
| 1177 |
+
else:
|
| 1178 |
+
st.error(cooling_message)
|
| 1179 |
+
|
| 1180 |
+
# Calculate heating load
|
| 1181 |
+
heating_success, heating_message, heating_results = self.calculate_heating()
|
| 1182 |
+
if heating_success:
|
| 1183 |
+
st.session_state.calculation_results['heating'] = heating_results
|
| 1184 |
+
st.success(heating_message)
|
| 1185 |
+
else:
|
| 1186 |
+
st.error(heating_message)
|
| 1187 |
+
|
| 1188 |
+
# Display results
|
| 1189 |
+
self.results_display.display_results(st.session_state)
|
| 1190 |
+
|
| 1191 |
+
# Navigation
|
| 1192 |
+
col1, col2 = st.columns(2)
|
| 1193 |
+
with col1:
|
| 1194 |
+
st.button(
|
| 1195 |
+
"Back to Internal Loads",
|
| 1196 |
+
on_click=lambda: setattr(st.session_state, "page", "Internal Loads")
|
| 1197 |
+
)
|
| 1198 |
+
with col2:
|
| 1199 |
+
st.button(
|
| 1200 |
+
"Continue to Export Data",
|
| 1201 |
+
on_click=lambda: setattr(st.session_state, "page", "Export Data")
|
| 1202 |
+
)
|
| 1203 |
+
|
| 1204 |
+
if __name__ == "__main__":
|
| 1205 |
+
app = HVACCalculator()
|
app/results_display.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ScenarioComparisonVisualization
|
| 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 = ScenarioComparisonVisualization()
|
| 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 |
+
location = f"{session_state['building_info']['city']}, {session_state['building_info']['country']}"
|
| 87 |
+
st.write(f"**Location:** {location}")
|
| 88 |
+
st.write(f"**Climate Zone:** {session_state['building_info'].get('climate_zone', 'N/A')}")
|
| 89 |
+
st.write(f"**Floor Area:** {session_state['building_info']['floor_area']} m²")
|
| 90 |
+
|
| 91 |
+
# Create columns for cooling and heating loads
|
| 92 |
+
col1, col2 = st.columns(2)
|
| 93 |
+
|
| 94 |
+
with col1:
|
| 95 |
+
st.write("### Cooling Load Results")
|
| 96 |
+
|
| 97 |
+
# Check if cooling results are available
|
| 98 |
+
if not results.get("cooling") or "total_load" not in results["cooling"]:
|
| 99 |
+
st.warning("Cooling load results are not available. Please check calculation inputs and try again.")
|
| 100 |
+
else:
|
| 101 |
+
# Display cooling load metrics
|
| 102 |
+
cooling_metrics = [
|
| 103 |
+
{"name": "Total Cooling Load", "value": results["cooling"]["total_load"], "unit": "kW"},
|
| 104 |
+
{"name": "Sensible Cooling Load", "value": results["cooling"]["sensible_load"], "unit": "kW"},
|
| 105 |
+
{"name": "Latent Cooling Load", "value": results["cooling"]["latent_load"], "unit": "kW"},
|
| 106 |
+
{"name": "Cooling Load per Area", "value": results["cooling"]["load_per_area"], "unit": "W/m²"}
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
for metric in cooling_metrics:
|
| 110 |
+
st.metric(
|
| 111 |
+
label=metric["name"],
|
| 112 |
+
value=f"{metric['value']:.2f} {metric['unit']}"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# Display cooling load pie chart
|
| 116 |
+
cooling_breakdown = {
|
| 117 |
+
"Walls": results["cooling"]["component_loads"]["walls"],
|
| 118 |
+
"Roof": results["cooling"]["component_loads"]["roof"],
|
| 119 |
+
"Windows": results["cooling"]["component_loads"]["windows"],
|
| 120 |
+
"Doors": results["cooling"]["component_loads"]["doors"],
|
| 121 |
+
"People": results["cooling"]["component_loads"]["people"],
|
| 122 |
+
"Lighting": results["cooling"]["component_loads"]["lighting"],
|
| 123 |
+
"Equipment": results["cooling"]["component_loads"]["equipment"],
|
| 124 |
+
"Infiltration": results["cooling"]["component_loads"]["infiltration"],
|
| 125 |
+
"Ventilation": results["cooling"]["component_loads"]["ventilation"]
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
fig = px.pie(
|
| 129 |
+
values=list(cooling_breakdown.values()),
|
| 130 |
+
names=list(cooling_breakdown.keys()),
|
| 131 |
+
title="Cooling Load Breakdown",
|
| 132 |
+
color_discrete_sequence=px.colors.qualitative.Pastel
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
fig.update_traces(textposition='inside', textinfo='percent+label')
|
| 136 |
+
fig.update_layout(uniformtext_minsize=12, uniformtext_mode='hide')
|
| 137 |
+
|
| 138 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 139 |
+
|
| 140 |
+
with col2:
|
| 141 |
+
st.write("### Heating Load Results")
|
| 142 |
+
|
| 143 |
+
# Check if heating results are available
|
| 144 |
+
if not results.get("heating") or "total_load" not in results["heating"]:
|
| 145 |
+
st.warning("Heating load results are not available. Please check calculation inputs and try again.")
|
| 146 |
+
else:
|
| 147 |
+
# Display heating load metrics
|
| 148 |
+
heating_metrics = [
|
| 149 |
+
{"name": "Total Heating Load", "value": results["heating"]["total_load"], "unit": "kW"},
|
| 150 |
+
{"name": "Heating Load per Area", "value": results["heating"]["load_per_area"], "unit": "W/m²"},
|
| 151 |
+
{"name": "Design Heat Loss", "value": results["heating"]["design_heat_loss"], "unit": "kW"},
|
| 152 |
+
{"name": "Safety Factor", "value": results["heating"]["safety_factor"], "unit": "%"}
|
| 153 |
+
]
|
| 154 |
+
|
| 155 |
+
for metric in heating_metrics:
|
| 156 |
+
st.metric(
|
| 157 |
+
label=metric["name"],
|
| 158 |
+
value=f"{metric['value']:.2f} {metric['unit']}"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Display heating load pie chart
|
| 162 |
+
heating_breakdown = {
|
| 163 |
+
"Walls": results["heating"]["component_loads"]["walls"],
|
| 164 |
+
"Roof": results["heating"]["component_loads"]["roof"],
|
| 165 |
+
"Floor": results["heating"]["component_loads"]["floor"],
|
| 166 |
+
"Windows": results["heating"]["component_loads"]["windows"],
|
| 167 |
+
"Doors": results["heating"]["component_loads"]["doors"],
|
| 168 |
+
"Infiltration": results["heating"]["component_loads"]["infiltration"],
|
| 169 |
+
"Ventilation": results["heating"]["component_loads"]["ventilation"]
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
fig = px.pie(
|
| 173 |
+
values=list(heating_breakdown.values()),
|
| 174 |
+
names=list(heating_breakdown.keys()),
|
| 175 |
+
title="Heating Load Breakdown",
|
| 176 |
+
color_discrete_sequence=px.colors.qualitative.Pastel
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
fig.update_traces(textposition='inside', textinfo='percent+label')
|
| 180 |
+
fig.update_layout(uniformtext_minsize=12, uniformtext_mode='hide')
|
| 181 |
+
|
| 182 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 183 |
+
|
| 184 |
+
# Display tabular results
|
| 185 |
+
st.subheader("Detailed Results")
|
| 186 |
+
|
| 187 |
+
# Create tabs for cooling and heating tables
|
| 188 |
+
tab1, tab2 = st.tabs(["Cooling Load Details", "Heating Load Details"])
|
| 189 |
+
|
| 190 |
+
with tab1:
|
| 191 |
+
if not results.get("cooling") or "detailed_loads" not in results["cooling"]:
|
| 192 |
+
st.warning("Cooling load details are not available.")
|
| 193 |
+
else:
|
| 194 |
+
# Create cooling load details table
|
| 195 |
+
cooling_details = []
|
| 196 |
+
|
| 197 |
+
# Add envelope components
|
| 198 |
+
for wall in results["cooling"]["detailed_loads"]["walls"]:
|
| 199 |
+
cooling_details.append({
|
| 200 |
+
"Component Type": "Wall",
|
| 201 |
+
"Name": wall["name"],
|
| 202 |
+
"Orientation": wall["orientation"],
|
| 203 |
+
"Area (m²)": wall["area"],
|
| 204 |
+
"U-Value (W/m²·K)": wall["u_value"],
|
| 205 |
+
"CLTD (°C)": wall["cltd"],
|
| 206 |
+
"Load (kW)": wall["load"]
|
| 207 |
+
})
|
| 208 |
+
|
| 209 |
+
for roof in results["cooling"]["detailed_loads"]["roofs"]:
|
| 210 |
+
cooling_details.append({
|
| 211 |
+
"Component Type": "Roof",
|
| 212 |
+
"Name": roof["name"],
|
| 213 |
+
"Orientation": roof["orientation"],
|
| 214 |
+
"Area (m²)": roof["area"],
|
| 215 |
+
"U-Value (W/m²·K)": roof["u_value"],
|
| 216 |
+
"CLTD (°C)": roof["cltd"],
|
| 217 |
+
"Load (kW)": roof["load"]
|
| 218 |
+
})
|
| 219 |
+
|
| 220 |
+
for window in results["cooling"]["detailed_loads"]["windows"]:
|
| 221 |
+
cooling_details.append({
|
| 222 |
+
"Component Type": "Window",
|
| 223 |
+
"Name": window["name"],
|
| 224 |
+
"Orientation": window["orientation"],
|
| 225 |
+
"Area (m²)": window["area"],
|
| 226 |
+
"U-Value (W/m²·K)": window["u_value"],
|
| 227 |
+
"SHGC": window["shgc"],
|
| 228 |
+
"SCL (W/m²)": window["scl"],
|
| 229 |
+
"Load (kW)": window["load"]
|
| 230 |
+
})
|
| 231 |
+
|
| 232 |
+
for door in results["cooling"]["detailed_loads"]["doors"]:
|
| 233 |
+
cooling_details.append({
|
| 234 |
+
"Component Type": "Door",
|
| 235 |
+
"Name": door["name"],
|
| 236 |
+
"Orientation": door["orientation"],
|
| 237 |
+
"Area (m²)": door["area"],
|
| 238 |
+
"U-Value (W/m²·K)": door["u_value"],
|
| 239 |
+
"CLTD (°C)": door["cltd"],
|
| 240 |
+
"Load (kW)": door["load"]
|
| 241 |
+
})
|
| 242 |
+
|
| 243 |
+
# Add internal loads
|
| 244 |
+
for internal_load in results["cooling"]["detailed_loads"]["internal"]:
|
| 245 |
+
cooling_details.append({
|
| 246 |
+
"Component Type": internal_load["type"],
|
| 247 |
+
"Name": internal_load["name"],
|
| 248 |
+
"Quantity": internal_load["quantity"],
|
| 249 |
+
"Heat Gain (W)": internal_load["heat_gain"],
|
| 250 |
+
"CLF": internal_load["clf"],
|
| 251 |
+
"Load (kW)": internal_load["load"]
|
| 252 |
+
})
|
| 253 |
+
|
| 254 |
+
# Add infiltration and ventilation
|
| 255 |
+
cooling_details.append({
|
| 256 |
+
"Component Type": "Infiltration",
|
| 257 |
+
"Name": "Air Infiltration",
|
| 258 |
+
"Air Flow (m³/s)": results["cooling"]["detailed_loads"]["infiltration"]["air_flow"],
|
| 259 |
+
"Sensible Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["sensible_load"],
|
| 260 |
+
"Latent Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["latent_load"],
|
| 261 |
+
"Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["total_load"]
|
| 262 |
+
})
|
| 263 |
+
|
| 264 |
+
cooling_details.append({
|
| 265 |
+
"Component Type": "Ventilation",
|
| 266 |
+
"Name": "Fresh Air",
|
| 267 |
+
"Air Flow (m³/s)": results["cooling"]["detailed_loads"]["ventilation"]["air_flow"],
|
| 268 |
+
"Sensible Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["sensible_load"],
|
| 269 |
+
"Latent Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["latent_load"],
|
| 270 |
+
"Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["total_load"]
|
| 271 |
+
})
|
| 272 |
+
|
| 273 |
+
# Display cooling details table
|
| 274 |
+
cooling_df = pd.DataFrame(cooling_details)
|
| 275 |
+
st.dataframe(cooling_df, use_container_width=True)
|
| 276 |
+
|
| 277 |
+
with tab2:
|
| 278 |
+
if not results.get("heating") or "detailed_loads" not in results["heating"]:
|
| 279 |
+
st.warning("Heating load details are not available.")
|
| 280 |
+
else:
|
| 281 |
+
# Create heating load details table
|
| 282 |
+
heating_details = []
|
| 283 |
+
|
| 284 |
+
# Add envelope components
|
| 285 |
+
for wall in results["heating"]["detailed_loads"]["walls"]:
|
| 286 |
+
heating_details.append({
|
| 287 |
+
"Component Type": "Wall",
|
| 288 |
+
"Name": wall["name"],
|
| 289 |
+
"Orientation": wall["orientation"],
|
| 290 |
+
"Area (m²)": wall["area"],
|
| 291 |
+
"U-Value (W/m²·K)": wall["u_value"],
|
| 292 |
+
"Temperature Difference (°C)": wall["delta_t"],
|
| 293 |
+
"Load (kW)": wall["load"]
|
| 294 |
+
})
|
| 295 |
+
|
| 296 |
+
for roof in results["heating"]["detailed_loads"]["roofs"]:
|
| 297 |
+
heating_details.append({
|
| 298 |
+
"Component Type": "Roof",
|
| 299 |
+
"Name": roof["name"],
|
| 300 |
+
"Orientation": roof["orientation"],
|
| 301 |
+
"Area (m²)": roof["area"],
|
| 302 |
+
"U-Value (W/m²·K)": wall["u_value"],
|
| 303 |
+
"Temperature Difference (°C)": roof["delta_t"],
|
| 304 |
+
"Load (kW)": roof["load"]
|
| 305 |
+
})
|
| 306 |
+
|
| 307 |
+
for floor in results["heating"]["detailed_loads"]["floors"]:
|
| 308 |
+
heating_details.append({
|
| 309 |
+
"Component Type": "Floor",
|
| 310 |
+
"Name": floor["name"],
|
| 311 |
+
"Area (m²)": floor["area"],
|
| 312 |
+
"U-Value (W/m²·K)": floor["u_value"],
|
| 313 |
+
"Temperature Difference (°C)": floor["delta_t"],
|
| 314 |
+
"Load (kW)": floor["load"]
|
| 315 |
+
})
|
| 316 |
+
|
| 317 |
+
for window in results["heating"]["detailed_loads"]["windows"]:
|
| 318 |
+
heating_details.append({
|
| 319 |
+
"Component Type": "Window",
|
| 320 |
+
"Name": window["name"],
|
| 321 |
+
"Orientation": window["orientation"],
|
| 322 |
+
"Area (m²)": window["area"],
|
| 323 |
+
"U-Value (W/m²·K)": window["u_value"],
|
| 324 |
+
"Temperature Difference (°C)": window["delta_t"],
|
| 325 |
+
"Load (kW)": window["load"]
|
| 326 |
+
})
|
| 327 |
+
|
| 328 |
+
for door in results["heating"]["detailed_loads"]["doors"]:
|
| 329 |
+
heating_details.append({
|
| 330 |
+
"Component Type": "Door",
|
| 331 |
+
"Name": door["name"],
|
| 332 |
+
"Orientation": door["orientation"],
|
| 333 |
+
"Area (m²)": door["area"],
|
| 334 |
+
"U-Value (W/m²·K)": door["u_value"],
|
| 335 |
+
"Temperature Difference (°C)": door["delta_t"],
|
| 336 |
+
"Load (kW)": door["load"]
|
| 337 |
+
})
|
| 338 |
+
|
| 339 |
+
# Add infiltration and ventilation
|
| 340 |
+
heating_details.append({
|
| 341 |
+
"Component Type": "Infiltration",
|
| 342 |
+
"Name": "Air Infiltration",
|
| 343 |
+
"Air Flow (m³/s)": results["heating"]["detailed_loads"]["infiltration"]["air_flow"],
|
| 344 |
+
"Temperature Difference (°C)": results["heating"]["detailed_loads"]["infiltration"]["delta_t"],
|
| 345 |
+
"Load (kW)": results["heating"]["detailed_loads"]["infiltration"]["load"]
|
| 346 |
+
})
|
| 347 |
+
|
| 348 |
+
heating_details.append({
|
| 349 |
+
"Component Type": "Ventilation",
|
| 350 |
+
"Name": "Fresh Air",
|
| 351 |
+
"Air Flow (m³/s)": results["heating"]["detailed_loads"]["ventilation"]["air_flow"],
|
| 352 |
+
"Temperature Difference (°C)": results["heating"]["detailed_loads"]["ventilation"]["delta_t"],
|
| 353 |
+
"Load (kW)": results["heating"]["detailed_loads"]["ventilation"]["load"]
|
| 354 |
+
})
|
| 355 |
+
|
| 356 |
+
# Display heating details table
|
| 357 |
+
heating_df = pd.DataFrame(heating_details)
|
| 358 |
+
st.dataframe(heating_df, use_container_width=True)
|
| 359 |
+
|
| 360 |
+
# Add download buttons for results
|
| 361 |
+
st.subheader("Download Results")
|
| 362 |
+
|
| 363 |
+
col1, col2 = st.columns(2)
|
| 364 |
+
|
| 365 |
+
with col1:
|
| 366 |
+
if results.get("cooling") and "detailed_loads" in results["cooling"]:
|
| 367 |
+
if st.button("Download Cooling Load Results (CSV)"):
|
| 368 |
+
cooling_csv = cooling_df.to_csv(index=False)
|
| 369 |
+
st.download_button(
|
| 370 |
+
label="Download CSV",
|
| 371 |
+
data=cooling_csv,
|
| 372 |
+
file_name=f"cooling_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 373 |
+
mime="text/csv"
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
with col2:
|
| 377 |
+
if results.get("heating") and "detailed_loads" in results["heating"]:
|
| 378 |
+
if st.button("Download Heating Load Results (CSV)"):
|
| 379 |
+
heating_csv = heating_df.to_csv(index=False)
|
| 380 |
+
st.download_button(
|
| 381 |
+
label="Download CSV",
|
| 382 |
+
data=heating_csv,
|
| 383 |
+
file_name=f"heating_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
| 384 |
+
mime="text/csv"
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
# Add button to download full report
|
| 388 |
+
if st.button("Generate Full Report (Excel)"):
|
| 389 |
+
st.info("Excel report generation will be implemented in the Export module.")
|
| 390 |
+
|
| 391 |
+
def _display_component_breakdown(self, session_state: Dict[str, Any]) -> None:
|
| 392 |
+
"""
|
| 393 |
+
Display component breakdown visualization.
|
| 394 |
+
|
| 395 |
+
Args:
|
| 396 |
+
session_state: Streamlit session state containing calculation results
|
| 397 |
+
"""
|
| 398 |
+
st.subheader("Component Breakdown")
|
| 399 |
+
|
| 400 |
+
if not session_state["calculation_results"].get("cooling") and not session_state["calculation_results"].get("heating"):
|
| 401 |
+
st.warning("No component breakdown data available.")
|
| 402 |
+
return
|
| 403 |
+
|
| 404 |
+
# Try to use component visualization module
|
| 405 |
+
try:
|
| 406 |
+
self.component_visualization.display_component_breakdown(
|
| 407 |
+
session_state["calculation_results"],
|
| 408 |
+
session_state["components"]
|
| 409 |
+
)
|
| 410 |
+
except AttributeError:
|
| 411 |
+
# Fallback visualization if display_component_breakdown is not available
|
| 412 |
+
st.info("Component visualization module not fully implemented. Displaying default breakdown.")
|
| 413 |
+
|
| 414 |
+
results = session_state["calculation_results"]
|
| 415 |
+
|
| 416 |
+
# Cooling load bar chart
|
| 417 |
+
if results.get("cooling"):
|
| 418 |
+
cooling_breakdown = {
|
| 419 |
+
"Walls": results["cooling"]["component_loads"]["walls"],
|
| 420 |
+
"Roof": results["cooling"]["component_loads"]["roof"],
|
| 421 |
+
"Windows": results["cooling"]["component_loads"]["windows"],
|
| 422 |
+
"Doors": results["cooling"]["component_loads"]["doors"],
|
| 423 |
+
"People": results["cooling"]["component_loads"]["people"],
|
| 424 |
+
"Lighting": results["cooling"]["component_loads"]["lighting"],
|
| 425 |
+
"Equipment": results["cooling"]["component_loads"]["equipment"],
|
| 426 |
+
"Infiltration": results["cooling"]["component_loads"]["infiltration"],
|
| 427 |
+
"Ventilation": results["cooling"]["component_loads"]["ventilation"]
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
fig_cooling = px.bar(
|
| 431 |
+
x=list(cooling_breakdown.keys()),
|
| 432 |
+
y=list(cooling_breakdown.values()),
|
| 433 |
+
title="Cooling Load by Component",
|
| 434 |
+
labels={"x": "Component", "y": "Load (kW)"},
|
| 435 |
+
color_discrete_sequence=px.colors.qualitative.Pastel
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
fig_cooling.update_layout(showlegend=False)
|
| 439 |
+
st.plotly_chart(fig_cooling, use_container_width=True)
|
| 440 |
+
|
| 441 |
+
# Heating load bar chart
|
| 442 |
+
if results.get("heating"):
|
| 443 |
+
heating_breakdown = {
|
| 444 |
+
"Walls": results["heating"]["component_loads"]["walls"],
|
| 445 |
+
"Roof": results["heating"]["component_loads"]["roof"],
|
| 446 |
+
"Floor": results["heating"]["component_loads"]["floor"],
|
| 447 |
+
"Windows": results["heating"]["component_loads"]["windows"],
|
| 448 |
+
"Doors": results["heating"]["component_loads"]["doors"],
|
| 449 |
+
"Infiltration": results["heating"]["component_loads"]["infiltration"],
|
| 450 |
+
"Ventilation": results["heating"]["component_loads"]["ventilation"]
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
fig_heating = px.bar(
|
| 454 |
+
x=list(heating_breakdown.keys()),
|
| 455 |
+
y=list(heating_breakdown.values()),
|
| 456 |
+
title="Heating Load by Component",
|
| 457 |
+
labels={"x": "Component", "y": "Load (kW)"},
|
| 458 |
+
color_discrete_sequence=px.colors.qualitative.Pastel
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
fig_heating.update_layout(showlegend=False)
|
| 462 |
+
st.plotly_chart(fig_heating, use_container_width=True)
|
| 463 |
+
|
| 464 |
+
def _display_psychrometric_analysis(self, session_state: Dict[str, Any]) -> None:
|
| 465 |
+
"""
|
| 466 |
+
Display psychrometric analysis visualization.
|
| 467 |
+
|
| 468 |
+
Args:
|
| 469 |
+
session_state: Streamlit session state containing calculation results
|
| 470 |
+
"""
|
| 471 |
+
st.subheader("Psychrometric Analysis")
|
| 472 |
+
|
| 473 |
+
if not session_state["calculation_results"].get("cooling"):
|
| 474 |
+
st.warning("Psychrometric analysis requires cooling load results.")
|
| 475 |
+
return
|
| 476 |
+
|
| 477 |
+
# Use psychrometric visualization module
|
| 478 |
+
self.psychrometric_visualization.display_psychrometric_chart(
|
| 479 |
+
session_state["calculation_results"],
|
| 480 |
+
session_state["building_info"]
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
def _display_time_analysis(self, session_state: Dict[str, Any]) -> None:
|
| 484 |
+
"""
|
| 485 |
+
Display time-based analysis visualization.
|
| 486 |
+
|
| 487 |
+
Args:
|
| 488 |
+
session_state: Streamlit session state containing calculation results
|
| 489 |
+
"""
|
| 490 |
+
st.subheader("Time Analysis")
|
| 491 |
+
|
| 492 |
+
if not session_state["calculation_results"].get("cooling"):
|
| 493 |
+
st.warning("Time analysis requires cooling load results.")
|
| 494 |
+
return
|
| 495 |
+
|
| 496 |
+
# Use time-based visualization module
|
| 497 |
+
self.time_based_visualization.display_time_analysis(
|
| 498 |
+
session_state["calculation_results"]
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
def _display_scenario_comparison(self, session_state: Dict[str, Any]) -> None:
|
| 502 |
+
"""
|
| 503 |
+
Display scenario comparison visualization.
|
| 504 |
+
|
| 505 |
+
Args:
|
| 506 |
+
session_state: Streamlit session state containing calculation results
|
| 507 |
+
"""
|
| 508 |
+
st.subheader("Scenario Comparison")
|
| 509 |
+
|
| 510 |
+
# Check if there are saved scenarios
|
| 511 |
+
if "saved_scenarios" not in session_state or not session_state["saved_scenarios"]:
|
| 512 |
+
st.info("No saved scenarios available for comparison. Save the current results as a scenario to enable comparison.")
|
| 513 |
+
|
| 514 |
+
# Add button to save current results as a scenario
|
| 515 |
+
scenario_name = st.text_input("Scenario Name", value="Baseline")
|
| 516 |
+
|
| 517 |
+
if st.button("Save Current Results as Scenario"):
|
| 518 |
+
if "saved_scenarios" not in session_state:
|
| 519 |
+
session_state["saved_scenarios"] = {}
|
| 520 |
+
|
| 521 |
+
# Save current results as a scenario
|
| 522 |
+
session_state["saved_scenarios"][scenario_name] = {
|
| 523 |
+
"results": session_state["calculation_results"],
|
| 524 |
+
"building_info": session_state["building_info"],
|
| 525 |
+
"components": session_state["components"],
|
| 526 |
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
st.success(f"Scenario '{scenario_name}' saved successfully!")
|
| 530 |
+
st.rerun()
|
| 531 |
+
else:
|
| 532 |
+
# Use scenario comparison module
|
| 533 |
+
self.scenario_comparison.display_scenario_comparison(
|
| 534 |
+
session_state["calculation_results"],
|
| 535 |
+
session_state["saved_scenarios"]
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
# Add button to save current results as a new scenario
|
| 539 |
+
st.write("### Save Current Results as New Scenario")
|
| 540 |
+
|
| 541 |
+
scenario_name = st.text_input("Scenario Name", value="Scenario " + str(len(session_state["saved_scenarios"]) + 1))
|
| 542 |
+
|
| 543 |
+
if st.button("Save Current Results as Scenario"):
|
| 544 |
+
# Save current results as a scenario
|
| 545 |
+
session_state["saved_scenarios"][scenario_name] = {
|
| 546 |
+
"results": session_state["calculation_results"],
|
| 547 |
+
"building_info": session_state["building_info"],
|
| 548 |
+
"components": session_state["components"],
|
| 549 |
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
st.success(f"Scenario '{scenario_name}' saved successfully!")
|
| 553 |
+
st.rerun()
|
| 554 |
+
|
| 555 |
+
# Add button to delete a scenario
|
| 556 |
+
st.write("### Delete Scenario")
|
| 557 |
+
|
| 558 |
+
scenario_to_delete = st.selectbox(
|
| 559 |
+
"Select Scenario to Delete",
|
| 560 |
+
options=list(session_state["saved_scenarios"].keys())
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
if st.button("Delete Selected Scenario"):
|
| 564 |
+
# Delete selected scenario
|
| 565 |
+
del session_state["saved_scenarios"][scenario_to_delete]
|
| 566 |
+
|
| 567 |
+
st.success(f"Scenario '{scenario_to_delete}' deleted successfully!")
|
| 568 |
+
st.rerun()
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
# Create a singleton instance
|
| 572 |
+
results_display = ResultsDisplay()
|
| 573 |
+
|
| 574 |
+
# Example usage
|
| 575 |
+
if __name__ == "__main__":
|
| 576 |
+
import streamlit as st
|
| 577 |
+
|
| 578 |
+
# Initialize session state with dummy data for testing
|
| 579 |
+
if "calculation_results" not in st.session_state:
|
| 580 |
+
st.session_state["calculation_results"] = {
|
| 581 |
+
"cooling": {
|
| 582 |
+
"total_load": 25.5,
|
| 583 |
+
"sensible_load": 20.0,
|
| 584 |
+
"latent_load": 5.5,
|
| 585 |
+
"load_per_area": 85.0,
|
| 586 |
+
"component_loads": {
|
| 587 |
+
"walls": 5.0,
|
| 588 |
+
"roof": 3.0,
|
| 589 |
+
"windows": 8.0,
|
| 590 |
+
"doors": 1.0,
|
| 591 |
+
"people": 2.5,
|
| 592 |
+
"lighting": 2.0,
|
| 593 |
+
"equipment": 1.5,
|
| 594 |
+
"infiltration": 1.0,
|
| 595 |
+
"ventilation": 1.5
|
| 596 |
+
},
|
| 597 |
+
"detailed_loads": {
|
| 598 |
+
"walls": [
|
| 599 |
+
{"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "cltd": 10.0, "load": 1.0}
|
| 600 |
+
],
|
| 601 |
+
"roofs": [
|
| 602 |
+
{"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "cltd": 15.0, "load": 3.0}
|
| 603 |
+
],
|
| 604 |
+
"windows": [
|
| 605 |
+
{"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "shgc": 0.7, "scl": 800.0, "load": 8.0}
|
| 606 |
+
],
|
| 607 |
+
"doors": [
|
| 608 |
+
{"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "cltd": 10.0, "load": 1.0}
|
| 609 |
+
],
|
| 610 |
+
"internal": [
|
| 611 |
+
{"type": "People", "name": "Occupants", "quantity": 10, "heat_gain": 250, "clf": 1.0, "load": 2.5},
|
| 612 |
+
{"type": "Lighting", "name": "General Lighting", "quantity": 1000, "heat_gain": 2000, "clf": 1.0, "load": 2.0},
|
| 613 |
+
{"type": "Equipment", "name": "Office Equipment", "quantity": 5, "heat_gain": 300, "clf": 1.0, "load": 1.5}
|
| 614 |
+
],
|
| 615 |
+
"infiltration": {
|
| 616 |
+
"air_flow": 0.05,
|
| 617 |
+
"sensible_load": 0.8,
|
| 618 |
+
"latent_load": 0.2,
|
| 619 |
+
"total_load": 1.0
|
| 620 |
+
},
|
| 621 |
+
"ventilation": {
|
| 622 |
+
"air_flow": 0.1,
|
| 623 |
+
"sensible_load": 1.0,
|
| 624 |
+
"latent_load": 0.5,
|
| 625 |
+
"total_load": 1.5
|
| 626 |
+
}
|
| 627 |
+
}
|
| 628 |
+
},
|
| 629 |
+
"heating": {
|
| 630 |
+
"total_load": 30.0,
|
| 631 |
+
"load_per_area": 100.0,
|
| 632 |
+
"design_heat_loss": 27.0,
|
| 633 |
+
"safety_factor": 10.0,
|
| 634 |
+
"component_loads": {
|
| 635 |
+
"walls": 8.0,
|
| 636 |
+
"roof": 5.0,
|
| 637 |
+
"floor": 4.0,
|
| 638 |
+
"windows": 7.0,
|
| 639 |
+
"doors": 1.0,
|
| 640 |
+
"infiltration": 2.0,
|
| 641 |
+
"ventilation": 3.0
|
| 642 |
+
},
|
| 643 |
+
"detailed_loads": {
|
| 644 |
+
"walls": [
|
| 645 |
+
{"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "delta_t": 25.0, "load": 8.0}
|
| 646 |
+
],
|
| 647 |
+
"roofs": [
|
| 648 |
+
{"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "delta_t": 25.0, "load": 5.0}
|
| 649 |
+
],
|
| 650 |
+
"floors": [
|
| 651 |
+
{"name": "Ground Floor", "area": 100.0, "u_value": 0.4, "delta_t": 10.0, "load": 4.0}
|
| 652 |
+
],
|
| 653 |
+
"windows": [
|
| 654 |
+
{"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "delta_t": 25.0, "load": 7.0}
|
| 655 |
+
],
|
| 656 |
+
"doors": [
|
| 657 |
+
{"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "delta_t": 25.0, "load": 1.0}
|
| 658 |
+
],
|
| 659 |
+
"infiltration": {
|
| 660 |
+
"air_flow": 0.05,
|
| 661 |
+
"delta_t": 25.0,
|
| 662 |
+
"load": 2.0
|
| 663 |
+
},
|
| 664 |
+
"ventilation": {
|
| 665 |
+
"air_flow": 0.1,
|
| 666 |
+
"delta_t": 25.0,
|
| 667 |
+
"load": 3.0
|
| 668 |
+
}
|
| 669 |
+
}
|
| 670 |
+
}
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
# Display results
|
| 674 |
+
results_display.display_results(st.session_state)
|
data/ashrae_tables.py
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ASHRAE tables module for HVAC Load Calculator.
|
| 3 |
+
Integrates CLTD, SCL, CLF tables, cooling load calculations, climatic corrections, and visualization.
|
| 4 |
+
Combines data from original ashrae_tables.py and enhanced versions with ashrae_tables (3).py.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import numpy as np
|
| 10 |
+
import os
|
| 11 |
+
import matplotlib.pyplot as plt
|
| 12 |
+
from enum import Enum
|
| 13 |
+
|
| 14 |
+
# Define paths
|
| 15 |
+
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 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 |
+
class RoofGroup(Enum):
|
| 29 |
+
"""Enumeration for ASHRAE roof groups."""
|
| 30 |
+
A = "A" # Light construction
|
| 31 |
+
B = "B"
|
| 32 |
+
C = "C"
|
| 33 |
+
D = "D"
|
| 34 |
+
E = "E"
|
| 35 |
+
F = "F"
|
| 36 |
+
G = "G" # Heavy construction
|
| 37 |
+
|
| 38 |
+
class Orientation(Enum):
|
| 39 |
+
"""Enumeration for building component orientations."""
|
| 40 |
+
N = "North"
|
| 41 |
+
NE = "Northeast"
|
| 42 |
+
E = "East"
|
| 43 |
+
SE = "Southeast"
|
| 44 |
+
S = "South"
|
| 45 |
+
SW = "Southwest"
|
| 46 |
+
W = "West"
|
| 47 |
+
NW = "Northwest"
|
| 48 |
+
HOR = "Horizontal" # For roofs and floors
|
| 49 |
+
|
| 50 |
+
class ASHRAETables:
|
| 51 |
+
"""Class for managing ASHRAE tables for load calculations."""
|
| 52 |
+
|
| 53 |
+
def __init__(self):
|
| 54 |
+
"""Initialize ASHRAE tables."""
|
| 55 |
+
# Load tables
|
| 56 |
+
self.cltd_wall = self._load_cltd_wall_table()
|
| 57 |
+
self.cltd_roof = self._load_cltd_roof_table()
|
| 58 |
+
self.scl = self._load_scl_table()
|
| 59 |
+
self.clf_lights = self._load_clf_lights_table()
|
| 60 |
+
self.clf_people = self._load_clf_people_table()
|
| 61 |
+
self.clf_equipment = self._load_clf_equipment_table()
|
| 62 |
+
self.heat_gain = self._load_heat_gain_table()
|
| 63 |
+
|
| 64 |
+
# Load correction factors
|
| 65 |
+
self.latitude_correction = self._load_latitude_correction()
|
| 66 |
+
self.color_correction = self._load_color_correction()
|
| 67 |
+
self.month_correction = self._load_month_correction()
|
| 68 |
+
|
| 69 |
+
# Load thermal properties and roof classifications
|
| 70 |
+
self.thermal_properties = self._load_thermal_properties()
|
| 71 |
+
self.roof_classifications = self._load_roof_classifications()
|
| 72 |
+
|
| 73 |
+
def _validate_cltd_inputs(self, group: str, orientation: str, hour: int, latitude: str, month: str, color: str, is_wall: bool = True) -> Tuple[bool, str]:
|
| 74 |
+
"""Validate inputs for CLTD calculations."""
|
| 75 |
+
valid_groups = [e.value for e in WallGroup] if is_wall else [e.value for e in RoofGroup]
|
| 76 |
+
valid_orientations = [e.value for e in Orientation]
|
| 77 |
+
valid_latitudes = ['24N', '32N', '40N', '48N', '56N']
|
| 78 |
+
valid_months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
| 79 |
+
valid_colors = ['Dark', 'Medium', 'Light']
|
| 80 |
+
|
| 81 |
+
if group not in valid_groups:
|
| 82 |
+
return False, f"Invalid {'wall' if is_wall else 'roof'} group: {group}. Valid groups: {valid_groups}"
|
| 83 |
+
if orientation not in valid_orientations:
|
| 84 |
+
return False, f"Invalid orientation: {orientation}. Valid orientations: {valid_orientations}"
|
| 85 |
+
if hour not in range(24):
|
| 86 |
+
return False, "Hour must be between 0 and 23."
|
| 87 |
+
|
| 88 |
+
# Handle numeric latitude values and ensure comprehensive mapping
|
| 89 |
+
if latitude not in valid_latitudes:
|
| 90 |
+
# Try to convert numeric latitude to standard format
|
| 91 |
+
try:
|
| 92 |
+
# First, handle string representations that might contain direction indicators
|
| 93 |
+
if isinstance(latitude, str):
|
| 94 |
+
# Extract numeric part, removing 'N' or 'S'
|
| 95 |
+
lat_str = latitude.upper().strip()
|
| 96 |
+
num_part = ''.join(c for c in lat_str if c.isdigit() or c == '.')
|
| 97 |
+
lat_val = float(num_part)
|
| 98 |
+
|
| 99 |
+
# Adjust for southern hemisphere if needed
|
| 100 |
+
if 'S' in lat_str:
|
| 101 |
+
lat_val = -lat_val
|
| 102 |
+
else:
|
| 103 |
+
# Handle direct numeric input
|
| 104 |
+
lat_val = float(latitude)
|
| 105 |
+
|
| 106 |
+
# Take absolute value for mapping purposes
|
| 107 |
+
abs_lat = abs(lat_val)
|
| 108 |
+
|
| 109 |
+
# Map to the closest standard latitude
|
| 110 |
+
if abs_lat < 28:
|
| 111 |
+
mapped_latitude = '24N'
|
| 112 |
+
elif abs_lat < 36:
|
| 113 |
+
mapped_latitude = '32N'
|
| 114 |
+
elif abs_lat < 44:
|
| 115 |
+
mapped_latitude = '40N'
|
| 116 |
+
elif abs_lat < 52:
|
| 117 |
+
mapped_latitude = '48N'
|
| 118 |
+
else:
|
| 119 |
+
mapped_latitude = '56N'
|
| 120 |
+
|
| 121 |
+
# Use the mapped latitude for validation
|
| 122 |
+
latitude = mapped_latitude
|
| 123 |
+
|
| 124 |
+
except (ValueError, TypeError):
|
| 125 |
+
return False, f"Invalid latitude: {latitude}. Valid latitudes: {valid_latitudes}"
|
| 126 |
+
|
| 127 |
+
if latitude not in valid_latitudes:
|
| 128 |
+
return False, f"Invalid latitude: {latitude}. Valid latitudes: {valid_latitudes}"
|
| 129 |
+
|
| 130 |
+
if month not in valid_months:
|
| 131 |
+
return False, f"Invalid month: {month}. Valid months: {valid_months}"
|
| 132 |
+
if color not in valid_colors:
|
| 133 |
+
return False, f"Invalid color: {color}. Valid colors: {valid_colors}"
|
| 134 |
+
return True, "Valid inputs."
|
| 135 |
+
|
| 136 |
+
def _load_cltd_wall_table(self) -> Dict[str, pd.DataFrame]:
|
| 137 |
+
"""
|
| 138 |
+
Load CLTD tables for walls at 24°N (July).
|
| 139 |
+
Returns: Dictionary of DataFrames with CLTD values for each wall group.
|
| 140 |
+
"""
|
| 141 |
+
hours = list(range(24))
|
| 142 |
+
# CLTD data for wall types 1-12 mapped to groups A-H
|
| 143 |
+
wall_data = {
|
| 144 |
+
"A": { # Type 1: Lightest construction
|
| 145 |
+
'N': [1, 0, -1, -2, -3, -2, 5, 13, 17, 18, 19, 22, 26, 28, 30, 32, 34, 34, 27, 17, 11, 7, 5, 3],
|
| 146 |
+
'NE': [1, 0, -1, -2, -3, 0, 17, 39, 51, 53, 48, 39, 32, 30, 30, 30, 30, 28, 24, 18, 13, 10, 7, 5],
|
| 147 |
+
'E': [1, 0, -1, -2, -3, 0, 18, 44, 59, 63, 59, 48, 36, 32, 31, 30, 32, 32, 29, 24, 19, 13, 10, 7],
|
| 148 |
+
'SE': [1, 0, -1, -2, -3, -2, 8, 25, 38, 44, 45, 42, 35, 32, 31, 30, 32, 32, 27, 24, 18, 13, 10, 7],
|
| 149 |
+
'S': [1, 0, -1, -2, -3, -3, -1, 3, 8, 12, 18, 24, 29, 31, 31, 30, 32, 32, 27, 23, 18, 13, 9, 7],
|
| 150 |
+
'SW': [1, 0, 1, 2, 3, 3, 1, 3, 8, 13, 17, 22, 27, 42, 59, 73, 30, 32, 27, 23, 18, 20, 12, 8],
|
| 151 |
+
'W': [2, 0, 2, 2, 3, 1, 3, 8, 13, 17, 22, 27, 42, 59, 73, 30, 32, 27, 23, 18, 20, 12, 8, 5],
|
| 152 |
+
'NW': [2, 0, 1, 2, 2, 3, 1, 3, 8, 13, 17, 22, 27, 42, 59, 73, 30, 32, 27, 23, 18, 20, 12, 8]
|
| 153 |
+
},
|
| 154 |
+
"B": { # Type 2
|
| 155 |
+
'N': [2, 1, 0, -1, -2, -1, 6, 14, 18, 19, 20, 23, 27, 29, 31, 33, 35, 35, 28, 18, 12, 8, 6, 4],
|
| 156 |
+
'NE': [2, 1, 0, -1, -2, 1, 18, 40, 52, 54, 49, 40, 33, 31, 31, 31, 31, 29, 25, 19, 14, 11, 8, 6],
|
| 157 |
+
'E': [2, 1, 0, -1, -2, 1, 19, 45, 60, 64, 60, 49, 37, 33, 32, 31, 33, 33, 30, 25, 20, 14, 11, 8],
|
| 158 |
+
'SE': [2, 1, 0, -1, -2, -1, 9, 26, 39, 45, 46, 43, 36, 33, 32, 31, 33, 33, 28, 25, 19, 14, 11, 8],
|
| 159 |
+
'S': [2, 1, 0, -1, -2, -2, 0, 4, 9, 13, 19, 25, 30, 32, 32, 31, 33, 33, 28, 24, 19, 14, 10, 8],
|
| 160 |
+
'SW': [2, 1, 2, 3, 4, 4, 2, 4, 9, 14, 18, 23, 28, 43, 60, 74, 31, 33, 28, 24, 19, 21, 13, 9],
|
| 161 |
+
'W': [3, 1, 3, 3, 4, 2, 4, 9, 14, 18, 23, 28, 43, 60, 74, 31, 33, 28, 24, 19, 21, 13, 9, 6],
|
| 162 |
+
'NW': [3, 1, 2, 3, 3, 4, 2, 4, 9, 14, 18, 23, 28, 43, 60, 74, 31, 33, 28, 24, 19, 21, 13, 9]
|
| 163 |
+
},
|
| 164 |
+
"C": { # Type 3
|
| 165 |
+
'N': [3, 2, 1, 0, -1, 0, 7, 15, 19, 20, 21, 24, 28, 30, 32, 34, 36, 36, 29, 19, 13, 9, 7, 5],
|
| 166 |
+
'NE': [3, 2, 1, 0, -1, 2, 19, 41, 53, 55, 50, 41, 34, 32, 32, 32, 32, 30, 26, 20, 15, 12, 9, 7],
|
| 167 |
+
'E': [3, 2, 1, 0, -1, 2, 20, 46, 61, 65, 61, 50, 38, 34, 33, 32, 34, 34, 31, 26, 21, 15, 12, 9],
|
| 168 |
+
'SE': [3, 2, 1, 0, -1, 0, 10, 27, 40, 46, 47, 44, 37, 34, 33, 32, 34, 34, 29, 26, 20, 15, 12, 9],
|
| 169 |
+
'S': [3, 2, 1, 0, -1, -1, 1, 5, 10, 14, 20, 26, 31, 33, 33, 32, 34, 34, 29, 25, 20, 15, 11, 9],
|
| 170 |
+
'SW': [3, 2, 3, 4, 5, 5, 3, 5, 10, 15, 19, 24, 29, 44, 61, 75, 32, 34, 29, 25, 20, 22, 14, 10],
|
| 171 |
+
'W': [4, 2, 4, 4, 5, 3, 5, 10, 15, 19, 24, 29, 44, 61, 75, 32, 34, 29, 25, 20, 22, 14, 10, 7],
|
| 172 |
+
'NW': [4, 2, 3, 4, 4, 5, 3, 5, 10, 15, 19, 24, 29, 44, 61, 75, 32, 34, 29, 25, 20, 22, 14, 10]
|
| 173 |
+
},
|
| 174 |
+
"D": { # Type 4
|
| 175 |
+
'N': [4, 3, 2, 1, 0, 1, 8, 16, 20, 21, 22, 25, 29, 31, 33, 35, 37, 37, 30, 20, 14, 10, 8, 6],
|
| 176 |
+
'NE': [4, 3, 2, 1, 0, 3, 20, 42, 54, 56, 51, 42, 35, 33, 33, 33, 33, 31, 27, 21, 16, 13, 10, 8],
|
| 177 |
+
'E': [4, 3, 2, 1, 0, 3, 21, 47, 62, 66, 62, 51, 39, 35, 34, 33, 35, 35, 32, 27, 22, 16, 13, 10],
|
| 178 |
+
'SE': [4, 3, 2, 1, 0, 1, 11, 28, 41, 47, 48, 45, 38, 35, 34, 33, 35, 35, 30, 27, 21, 16, 13, 10],
|
| 179 |
+
'S': [4, 3, 2, 1, 0, 0, 2, 6, 11, 15, 21, 27, 32, 34, 34, 33, 35, 35, 30, 26, 21, 16, 12, 10],
|
| 180 |
+
'SW': [4, 3, 4, 5, 6, 6, 4, 6, 11, 16, 20, 25, 30, 45, 62, 76, 33, 35, 30, 26, 21, 23, 15, 11],
|
| 181 |
+
'W': [5, 3, 5, 5, 6, 4, 6, 11, 16, 20, 25, 30, 45, 62, 76, 33, 35, 30, 26, 21, 23, 15, 11, 8],
|
| 182 |
+
'NW': [5, 3, 4, 5, 5, 6, 4, 6, 11, 16, 20, 25, 30, 45, 62, 76, 33, 35, 30, 26, 21, 23, 15, 11]
|
| 183 |
+
},
|
| 184 |
+
"E": { # Type 5
|
| 185 |
+
'N': [13, 11, 9, 7, 5, 3, 2, 3, 5, 7, 10, 12, 14, 16, 19, 21, 23, 25, 27, 27, 25, 22, 20, 16],
|
| 186 |
+
'NE': [13, 11, 8, 7, 5, 3, 3, 6, 12, 20, 26, 31, 33, 33, 32, 32, 32, 33, 31, 29, 27, 24, 21, 18],
|
| 187 |
+
'E': [14, 11, 9, 7, 5, 4, 3, 6, 13, 22, 31, 36, 39, 39, 39, 39, 39, 31, 31, 29, 26, 22, 19, 18],
|
| 188 |
+
'SE': [13, 10, 8, 6, 5, 3, 2, 4, 8, 14, 20, 25, 28, 30, 30, 30, 30, 30, 28, 26, 24, 21, 18, 16],
|
| 189 |
+
'S': [11, 9, 7, 6, 4, 3, 2, 1, 1, 3, 5, 7, 11, 14, 16, 20, 22, 23, 23, 23, 20, 18, 16, 14],
|
| 190 |
+
'SW': [18, 15, 12, 9, 7, 5, 3, 3, 3, 4, 5, 8, 11, 14, 16, 20, 26, 32, 33, 31, 41, 40, 36, 31],
|
| 191 |
+
'W': [23, 19, 15, 12, 9, 7, 5, 4, 4, 4, 6, 8, 11, 14, 16, 20, 28, 37, 35, 31, 51, 41, 41, 41],
|
| 192 |
+
'NW': [21, 17, 14, 11, 8, 6, 4, 3, 3, 4, 6, 8, 11, 14, 16, 20, 28, 37, 35, 31, 41, 41, 41, 41]
|
| 193 |
+
},
|
| 194 |
+
"F": { # Type 6
|
| 195 |
+
'N': [10, 8, 6, 4, 2, 1, 1, 2, 4, 6, 9, 11, 13, 15, 18, 20, 22, 24, 26, 26, 24, 21, 19, 15],
|
| 196 |
+
'NE': [10, 8, 6, 4, 2, 2, 2, 5, 11, 19, 25, 30, 32, 32, 31, 31, 31, 32, 30, 28, 26, 23, 20, 17],
|
| 197 |
+
'E': [11, 8, 6, 4, 2, 3, 2, 5, 12, 21, 30, 35, 38, 38, 38, 38, 38, 30, 30, 28, 25, 21, 18, 17],
|
| 198 |
+
'SE': [10, 7, 5, 3, 2, 2, 1, 3, 7, 13, 19, 24, 27, 29, 29, 29, 29, 29, 27, 25, 23, 20, 17, 15],
|
| 199 |
+
'S': [8, 6, 4, 3, 1, 2, 1, 0, 0, 2, 4, 6, 10, 13, 15, 19, 21, 22, 22, 22, 19, 17, 15, 13],
|
| 200 |
+
'SW': [15, 12, 9, 6, 4, 3, 2, 2, 2, 3, 4, 7, 10, 13, 15, 19, 25, 31, 32, 30, 40, 39, 35, 30],
|
| 201 |
+
'W': [20, 16, 12, 9, 6, 4, 3, 3, 3, 3, 5, 7, 10, 13, 15, 19, 27, 36, 34, 30, 50, 40, 40, 40],
|
| 202 |
+
'NW': [18, 14, 11, 8, 5, 4, 3, 2, 2, 3, 5, 7, 10, 13, 15, 19, 27, 36, 34, 30, 40, 40, 40, 40]
|
| 203 |
+
},
|
| 204 |
+
"G": { # Type 7
|
| 205 |
+
'N': [7, 5, 3, 1, -1, 0, 0, 1, 3, 5, 8, 10, 12, 14, 17, 19, 21, 23, 25, 25, 23, 20, 18, 14],
|
| 206 |
+
'NE': [7, 5, 3, 1, -1, 1, 1, 4, 10, 18, 24, 29, 31, 31, 30, 30, 30, 31, 29, 27, 25, 22, 19, 16],
|
| 207 |
+
'E': [8, 5, 3, 1, -1, 2, 1, 4, 11, 20, 29, 34, 37, 37, 37, 37, 37, 29, 29, 27, 24, 20, 17, 16],
|
| 208 |
+
'SE': [7, 4, 2, 0, -1, 1, 0, 2, 6, 12, 18, 23, 26, 28, 28, 28, 28, 28, 26, 24, 22, 19, 16, 14],
|
| 209 |
+
'S': [5, 3, 1, 0, -2, 1, 0, -1, -1, 1, 3, 5, 9, 12, 14, 18, 20, 21, 21, 21, 18, 16, 14, 12],
|
| 210 |
+
'SW': [12, 9, 6, 3, 1, 2, 1, 1, 1, 2, 3, 6, 9, 12, 14, 18, 24, 30, 31, 29, 39, 38, 34, 29],
|
| 211 |
+
'W': [17, 13, 9, 6, 3, 2, 2, 2, 2, 2, 4, 6, 9, 12, 14, 18, 26, 35, 33, 29, 49, 39, 39, 39],
|
| 212 |
+
'NW': [15, 11, 8, 5, 2, 3, 2, 1, 1, 2, 4, 6, 9, 12, 14, 18, 26, 35, 33, 29, 39, 39, 39, 39]
|
| 213 |
+
},
|
| 214 |
+
"H": { # Interpolated from types 8-12: Heaviest construction
|
| 215 |
+
'N': [4, 2, 0, -2, -4, -1, -1, 0, 2, 4, 7, 9, 11, 13, 16, 18, 20, 22, 24, 24, 22, 19, 17, 13],
|
| 216 |
+
'NE': [4, 2, 0, -2, -4, 0, 0, 3, 9, 17, 23, 28, 30, 30, 29, 29, 29, 30, 28, 26, 24, 21, 18, 15],
|
| 217 |
+
'E': [5, 2, 0, -2, -4, 1, 0, 3, 10, 19, 28, 33, 36, 36, 36, 36, 36, 28, 28, 26, 23, 19, 16, 15],
|
| 218 |
+
'SE': [4, 1, -1, -3, -4, 0, -1, 1, 5, 11, 17, 22, 25, 27, 27, 27, 27, 27, 25, 23, 21, 18, 15, 13],
|
| 219 |
+
'S': [2, 0, -2, -3, -5, 0, -1, -2, -2, 0, 2, 4, 8, 11, 13, 17, 19, 20, 20, 20, 17, 15, 13, 11],
|
| 220 |
+
'SW': [9, 6, 3, 0, -2, 1, 0, 0, 0, 1, 2, 5, 8, 11, 13, 17, 23, 29, 30, 28, 38, 37, 33, 28],
|
| 221 |
+
'W': [14, 10, 6, 3, 0, 1, 1, 1, 1, 1, 3, 5, 8, 11, 13, 17, 25, 34, 32, 28, 48, 38, 38, 38],
|
| 222 |
+
'NW': [12, 8, 5, 2, -1, 2, 1, 0, 0, 1, 3, 5, 8, 11, 13, 17, 25, 34, 32, 28, 38, 38, 38, 38]
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
wall_groups = {group: pd.DataFrame(data, index=hours) for group, data in wall_data.items()}
|
| 226 |
+
return wall_groups
|
| 227 |
+
|
| 228 |
+
def _load_cltd_roof_table(self) -> Dict[str, pd.DataFrame]:
|
| 229 |
+
"""
|
| 230 |
+
Load CLTD tables for roofs at 24°N, 36°N, 48°N (July).
|
| 231 |
+
Returns: Dictionary of DataFrames with CLTD values for each roof group and latitude.
|
| 232 |
+
"""
|
| 233 |
+
hours = list(range(24))
|
| 234 |
+
# CLTD data for roof types mapped to groups A-G across latitudes
|
| 235 |
+
roof_data = {
|
| 236 |
+
"24N": {
|
| 237 |
+
"A": [0, 4, 5, 6, 6, 3, 9, 16, 44, 62, 76, 87, 92, 92, 86, 74, 58, 39, 23, 14, 8, 4, 2, 0], # Type 1
|
| 238 |
+
"B": [12, 8, 5, 2, 0, -2, -2, 3, 11, 22, 35, 47, 59, 68, 74, 77, 74, 68, 58, 47, 37, 29, 22, 16], # Type 3
|
| 239 |
+
"C": [21, 16, 12, 8, 5, 3, 1, 1, 1, 10, 19, 20, 22, 23, 49, 49, 54, 58, 58, 56, 52, 47, 42, 37], # Type 5
|
| 240 |
+
"D": [31, 25, 20, 16, 12, 9, 6, 4, 3, 5, 10, 17, 26, 36, 46, 54, 61, 65, 66, 63, 58, 51, 44, 47], # Type 9
|
| 241 |
+
"E": [34, 31, 28, 25, 22, 20, 17, 16, 15, 19, 23, 28, 29, 32, 38, 38, 43, 43, 49, 49, 49, 46, 43, 40], # Type 13
|
| 242 |
+
"F": [35, 32, 30, 28, 25, 23, 21, 19, 20, 22, 23, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 44, 42], # Type 14
|
| 243 |
+
"G": [36, 33, 31, 29, 27, 25, 23, 21, 20, 22, 24, 25, 26, 27, 40, 41, 42, 42, 42, 47, 48, 48, 45, 43] # Interpolated
|
| 244 |
+
},
|
| 245 |
+
"36N": {
|
| 246 |
+
"A": [0, 2, 4, 5, 6, 6, 12, 28, 45, 61, 75, 84, 90, 90, 84, 79, 71, 62, 66, 59, 50, 42, 47, 0], # Type 1
|
| 247 |
+
"B": [12, 8, 5, 2, 0, -2, -1, 14, 13, 24, 25, 26, 27, 28, 38, 39, 40, 40, 43, 45, 46, 46, 43, 40], # Type 3
|
| 248 |
+
"C": [21, 16, 12, 8, 5, 3, 1, 12, 15, 12, 21, 22, 23, 32, 39, 40, 40, 40, 40, 45, 46, 46, 43, 40], # Type 5
|
| 249 |
+
"D": [32, 26, 21, 16, 13, 10, 8, 14, 17, 19, 20, 22, 23, 24, 39, 40, 40, 40, 40, 45, 46, 46, 43, 40], # Type 9
|
| 250 |
+
"E": [34, 31, 28, 25, 23, 20, 18, 16, 16, 20, 22, 22, 23, 24, 39, 39, 40, 40, 40, 45, 46, 46, 44, 42], # Type 13
|
| 251 |
+
"F": [35, 32, 30, 28, 25, 23, 21, 19, 20, 22, 23, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 44, 42], # Type 14
|
| 252 |
+
"G": [36, 33, 31, 29, 27, 25, 23, 21, 20, 22, 24, 25, 26, 27, 40, 41, 42, 42, 42, 47, 48, 48, 45, 43] # Interpolated
|
| 253 |
+
},
|
| 254 |
+
"48N": {
|
| 255 |
+
"A": [0, 2, 4, 5, 6, 5, 3, 15, 29, 44, 58, 69, 78, 83, 83, 79, 71, 59, 44, 49, 49, 49, 5, 2], # Type 1
|
| 256 |
+
"B": [12, 8, 5, 2, 0, -1, 1, 16, 16, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 43, 40], # Type 3
|
| 257 |
+
"C": [21, 16, 12, 8, 5, 3, 2, 16, 19, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 43, 40], # Type 5
|
| 258 |
+
"D": [31, 26, 21, 16, 12, 9, 6, 5, 5, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 43, 40], # Type 9
|
| 259 |
+
"E": [33, 30, 27, 25, 22, 20, 17, 16, 16, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 47, 48, 47, 45, 40], # Type 13
|
| 260 |
+
"F": [34, 32, 29, 27, 25, 23, 21, 20, 19, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 48, 48, 48, 43, 40], # Type 14
|
| 261 |
+
"G": [35, 33, 31, 29, 27, 25, 23, 21, 20, 22, 24, 25, 26, 27, 40, 41, 42, 42, 42, 48, 49, 49, 45, 43] # Interpolated
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
roof_groups = {}
|
| 265 |
+
for lat, groups in roof_data.items():
|
| 266 |
+
for group, data in groups.items():
|
| 267 |
+
roof_groups[f"{group}_{lat}"] = pd.DataFrame({"HOR": data}, index=hours)
|
| 268 |
+
return roof_groups
|
| 269 |
+
|
| 270 |
+
def _load_scl_table(self) -> Dict[str, pd.DataFrame]:
|
| 271 |
+
"""
|
| 272 |
+
Load SCL (Solar Cooling Load) tables for windows.
|
| 273 |
+
Returns: Dictionary of DataFrames with SCL values for each latitude/month.
|
| 274 |
+
"""
|
| 275 |
+
hours = list(range(24))
|
| 276 |
+
# Base SCL data for 40°N (July)
|
| 277 |
+
scl_40n_jul = {
|
| 278 |
+
"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],
|
| 279 |
+
"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],
|
| 280 |
+
"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],
|
| 281 |
+
"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],
|
| 282 |
+
"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],
|
| 283 |
+
"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],
|
| 284 |
+
"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],
|
| 285 |
+
"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],
|
| 286 |
+
"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]
|
| 287 |
+
}
|
| 288 |
+
scl_tables = {"40N_Jul": pd.DataFrame(scl_40n_jul, index=hours)}
|
| 289 |
+
latitudes = ["24N", "32N", "40N", "48N", "56N"]
|
| 290 |
+
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
| 291 |
+
for lat in latitudes:
|
| 292 |
+
for month in months:
|
| 293 |
+
key = f"{lat}_{month}"
|
| 294 |
+
if key == "40N_Jul":
|
| 295 |
+
continue
|
| 296 |
+
lat_factor = (40 - float(lat[:-1])) / 40
|
| 297 |
+
month_idx = months.index(month)
|
| 298 |
+
month_factor = 1 + (month_idx - 6) / 24
|
| 299 |
+
scl_data = {}
|
| 300 |
+
for orient in scl_40n_jul:
|
| 301 |
+
base_scl = scl_40n_jul[orient]
|
| 302 |
+
scl_data[orient] = [max(6, round(v * (1 - lat_factor * 0.2) * month_factor)) for v in base_scl]
|
| 303 |
+
scl_tables[key] = pd.DataFrame(scl_data, index=hours)
|
| 304 |
+
return scl_tables
|
| 305 |
+
|
| 306 |
+
def _load_clf_lights_table(self) -> pd.DataFrame:
|
| 307 |
+
"""
|
| 308 |
+
Load CLF (Cooling Load Factor) table for lights.
|
| 309 |
+
Returns: DataFrame with CLF values for lights by zone type and hours.
|
| 310 |
+
"""
|
| 311 |
+
hours = list(range(24))
|
| 312 |
+
clf_lights_data = {
|
| 313 |
+
"A_8h": [0.85, 0.92, 0.95, 0.95, 0.97, 0.97, 0.98, 0.13, 0.06, 0.04, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 314 |
+
"A_10h": [0.85, 0.93, 0.95, 0.97, 0.97, 0.98, 0.98, 0.98, 0.98, 0.98, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 315 |
+
"A_12h": [0.86, 0.93, 0.96, 0.97, 0.97, 0.98, 0.98, 0.98, 0.98, 0.98, 0.98, 0.98, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 316 |
+
"B_8h": [0.75, 0.85, 0.90, 0.93, 0.94, 0.95, 0.95, 0.95, 0.12, 0.08, 0.05, 0.04, 0.04, 0.03, 0.03, 0.03, 0.03, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 317 |
+
"B_10h": [0.75, 0.86, 0.91, 0.93, 0.94, 0.95, 0.95, 0.95, 0.96, 0.97, 0.24, 0.13, 0.08, 0.06, 0.05, 0.04, 0.04, 0.03, 0.03, 0.03, 0.03, 0.03, 0.02, 0.02],
|
| 318 |
+
"B_12h": [0.76, 0.86, 0.91, 0.93, 0.95, 0.95, 0.95, 0.95, 0.97, 0.97, 0.97, 0.97, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03],
|
| 319 |
+
"C_8h": [0.70, 0.80, 0.85, 0.88, 0.90, 0.92, 0.93, 0.94, 0.10, 0.07, 0.04, 0.03, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 320 |
+
"C_10h": [0.70, 0.81, 0.86, 0.89, 0.91, 0.92, 0.93, 0.94, 0.95, 0.96, 0.20, 0.11, 0.07, 0.05, 0.04, 0.03, 0.03, 0.02, 0.02, 0.02, 0.02, 0.02, 0.01, 0.01],
|
| 321 |
+
"C_12h": [0.71, 0.82, 0.87, 0.90, 0.92, 0.93, 0.94, 0.95, 0.96, 0.96, 0.96, 0.96, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 322 |
+
"D_8h": [0.65, 0.75, 0.80, 0.83, 0.85, 0.87, 0.88, 0.89, 0.08, 0.06, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 323 |
+
"D_10h": [0.65, 0.76, 0.81, 0.84, 0.86, 0.88, 0.89, 0.90, 0.91, 0.92, 0.16, 0.09, 0.06, 0.04, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 324 |
+
"D_12h": [0.66, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.92, 0.92, 0.92, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]
|
| 325 |
+
}
|
| 326 |
+
return pd.DataFrame(clf_lights_data, index=hours)
|
| 327 |
+
|
| 328 |
+
def _load_clf_people_table(self) -> pd.DataFrame:
|
| 329 |
+
"""
|
| 330 |
+
Load CLF (Cooling Load Factor) table for people.
|
| 331 |
+
Returns: DataFrame with CLF values for people by zone type and hours.
|
| 332 |
+
"""
|
| 333 |
+
hours = list(range(24))
|
| 334 |
+
clf_people_data = {
|
| 335 |
+
"A_2h": [0.75, 0.88, 0.18, 0.08, 0.04, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 336 |
+
"A_4h": [0.75, 0.88, 0.93, 0.95, 0.97, 0.10, 0.05, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 337 |
+
"A_6h": [0.75, 0.88, 0.93, 0.95, 0.97, 0.97, 0.33, 0.11, 0.06, 0.04, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 338 |
+
"B_2h": [0.65, 0.75, 0.81, 0.85, 0.89, 0.91, 0.93, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 339 |
+
"B_4h": [0.65, 0.75, 0.82, 0.87, 0.90, 0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 340 |
+
"B_6h": [0.65, 0.75, 0.82, 0.87, 0.90, 0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 341 |
+
"C_2h": [0.60, 0.70, 0.76, 0.80, 0.84, 0.86, 0.88, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 342 |
+
"C_4h": [0.60, 0.70, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 343 |
+
"C_6h": [0.60, 0.70, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 344 |
+
"D_2h": [0.55, 0.65, 0.71, 0.75, 0.79, 0.81, 0.83, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 345 |
+
"D_4h": [0.55, 0.65, 0.72, 0.77, 0.80, 0.82, 0.84, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 346 |
+
"D_6h": [0.55, 0.65, 0.72, 0.77, 0.80, 0.82, 0.84, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]
|
| 347 |
+
}
|
| 348 |
+
return pd.DataFrame(clf_people_data, index=hours)
|
| 349 |
+
|
| 350 |
+
def _load_clf_equipment_table(self) -> pd.DataFrame:
|
| 351 |
+
"""
|
| 352 |
+
Load CLF (Cooling Load Factor) table for equipment.
|
| 353 |
+
Returns: DataFrame with CLF values for equipment by zone type and hours.
|
| 354 |
+
"""
|
| 355 |
+
hours = list(range(24))
|
| 356 |
+
clf_equipment_data = {
|
| 357 |
+
"A_2h": [0.54, 0.83, 0.26, 0.11, 0.05, 0.03, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 358 |
+
"A_4h": [0.64, 0.83, 0.90, 0.93, 0.31, 0.14, 0.07, 0.04, 0.03, 0.03, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 359 |
+
"A_6h": [0.64, 0.83, 0.90, 0.93, 0.95, 0.95, 0.33, 0.11, 0.06, 0.04, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 360 |
+
"B_2h": [0.50, 0.75, 0.81, 0.85, 0.89, 0.91, 0.93, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 361 |
+
"B_4h": [0.50, 0.75, 0.82, 0.87, 0.90, 0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 362 |
+
"B_6h": [0.50, 0.75, 0.82, 0.87, 0.90, 0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
|
| 363 |
+
"C_2h": [0.46, 0.70, 0.76, 0.80, 0.84, 0.86, 0.88, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 364 |
+
"C_4h": [0.46, 0.70, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 365 |
+
"C_6h": [0.46, 0.70, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
|
| 366 |
+
"D_2h": [0.42, 0.65, 0.71, 0.75, 0.79, 0.81, 0.83, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 367 |
+
"D_4h": [0.42, 0.65, 0.72, 0.77, 0.80, 0.82, 0.84, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
|
| 368 |
+
"D_6h": [0.42, 0.65, 0.72, 0.77, 0.80, 0.82, 0.84, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]
|
| 369 |
+
}
|
| 370 |
+
return pd.DataFrame(clf_equipment_data, index=hours)
|
| 371 |
+
|
| 372 |
+
def _load_heat_gain_table(self) -> pd.DataFrame:
|
| 373 |
+
"""
|
| 374 |
+
Load heat gain table for internal sources.
|
| 375 |
+
Returns: DataFrame with heat gain values (Btu/h or Btu/h-ft²).
|
| 376 |
+
"""
|
| 377 |
+
data = {
|
| 378 |
+
"source": ["people_sensible", "people_latent", "lights", "equipment"],
|
| 379 |
+
"gain": [250, 200, 3.4, 500]
|
| 380 |
+
}
|
| 381 |
+
return pd.DataFrame(data)
|
| 382 |
+
|
| 383 |
+
def _load_thermal_properties(self) -> pd.DataFrame:
|
| 384 |
+
"""
|
| 385 |
+
Load thermal properties for building materials.
|
| 386 |
+
Returns: DataFrame with U-values, R-values, and density.
|
| 387 |
+
"""
|
| 388 |
+
data = {
|
| 389 |
+
"material": [
|
| 390 |
+
"Brick_4in", "Brick_8in", "Concrete_6in", "Concrete_12in",
|
| 391 |
+
"Wood_1in", "Wood_2in", "Insulation_1in", "Insulation_2in",
|
| 392 |
+
"Gypsum_0.5in", "Steel_1in"
|
| 393 |
+
],
|
| 394 |
+
"U_value": [0.45, 0.32, 0.51, 0.48, 0.12, 0.08, 0.03, 0.015, 0.32, 0.65], # Btu/h-ft²-°F
|
| 395 |
+
"R_value": [2.22, 3.13, 1.96, 2.08, 8.33, 12.5, 33.33, 66.67, 3.13, 1.54], # ft²-°F-h/Btu
|
| 396 |
+
"density": [120, 120, 140, 140, 35, 35, 1.5, 1.5, 40, 490] # lb/ft³
|
| 397 |
+
}
|
| 398 |
+
return pd.DataFrame(data)
|
| 399 |
+
|
| 400 |
+
def _load_roof_classifications(self) -> pd.DataFrame:
|
| 401 |
+
"""
|
| 402 |
+
Load roof classification data.
|
| 403 |
+
Returns: DataFrame with roof type descriptions and properties.
|
| 404 |
+
"""
|
| 405 |
+
data = {
|
| 406 |
+
"type": [1, 2, 3, 4, 5, 8, 9, 10, 13, 14],
|
| 407 |
+
"description": [
|
| 408 |
+
"Light roof, no insulation", "Light roof, minimal insulation",
|
| 409 |
+
"Medium roof, R-10 insulation", "Medium roof, R-15 insulation",
|
| 410 |
+
"Heavy roof, R-20 insulation", "Heavy roof, R-25 insulation",
|
| 411 |
+
"Concrete slab, R-15 insulation", "Concrete slab, R-20 insulation",
|
| 412 |
+
"Metal deck, R-30 insulation", "Metal deck, R-35 insulation"
|
| 413 |
+
],
|
| 414 |
+
"U_value": [0.5, 0.4, 0.3, 0.25, 0.2, 0.15, 0.18, 0.14, 0.1, 0.08],
|
| 415 |
+
"mass": [10, 15, 50, 60, 100, 120, 150, 160, 80, 90] # lb/ft²
|
| 416 |
+
}
|
| 417 |
+
return pd.DataFrame(data)
|
| 418 |
+
|
| 419 |
+
def _load_latitude_correction(self) -> Dict[str, Dict[str, float]]:
|
| 420 |
+
"""
|
| 421 |
+
Load latitude correction factors for CLTD/SCL values.
|
| 422 |
+
Returns: Dictionary of correction factors for different latitudes and months.
|
| 423 |
+
"""
|
| 424 |
+
return {
|
| 425 |
+
"24N": {"Jan": -5.0, "Feb": -3.5, "Mar": -1.0, "Apr": 2.0, "May": 4.0, "Jun": 5.0, "Jul": 4.5, "Aug": 3.0, "Sep": 1.0, "Oct": -1.5, "Nov": -4.0, "Dec": -5.5},
|
| 426 |
+
"32N": {"Jan": -4.0, "Feb": -2.5, "Mar": 0.0, "Apr": 2.5, "May": 4.5, "Jun": 5.5, "Jul": 5.0, "Aug": 3.5, "Sep": 1.5, "Oct": -0.5, "Nov": -3.0, "Dec": -4.5},
|
| 427 |
+
"40N": {"Jan": -3.0, "Feb": -1.5, "Mar": 1.0, "Apr": 3.0, "May": 5.0, "Jun": 6.0, "Jul": 5.5, "Aug": 4.0, "Sep": 2.0, "Oct": 0.0, "Nov": -2.0, "Dec": -3.5},
|
| 428 |
+
"48N": {"Jan": -2.0, "Feb": -0.5, "Mar": 2.0, "Apr": 4.0, "May": 6.0, "Jun": 7.0, "Jul": 6.5, "Aug": 5.0, "Sep": 3.0, "Oct": 1.0, "Nov": -1.0, "Dec": -2.5},
|
| 429 |
+
"56N": {"Jan": -1.0, "Feb": 0.5, "Mar": 3.0, "Apr": 5.0, "May": 7.0, "Jun": 8.0, "Jul": 7.5, "Aug": 6.0, "Sep": 4.0, "Oct": 2.0, "Nov": 0.0, "Dec": -1.5}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
def _load_color_correction(self) -> Dict[str, float]:
|
| 433 |
+
"""
|
| 434 |
+
Load color correction factors for CLTD values.
|
| 435 |
+
Returns: Dictionary of correction factors for different colors.
|
| 436 |
+
"""
|
| 437 |
+
return {"Dark": 0.0, "Medium": -1.0, "Light": -2.0}
|
| 438 |
+
|
| 439 |
+
def _load_month_correction(self) -> Dict[str, float]:
|
| 440 |
+
"""
|
| 441 |
+
Load month correction factors for CLTD values.
|
| 442 |
+
Returns: Dictionary of correction factors for different months.
|
| 443 |
+
"""
|
| 444 |
+
return {
|
| 445 |
+
"Jan": -6.0, "Feb": -5.0, "Mar": -3.0, "Apr": -1.0, "May": 1.0,
|
| 446 |
+
"Jun": 2.0, "Jul": 2.0, "Aug": 2.0, "Sep": 1.0, "Oct": -1.0,
|
| 447 |
+
"Nov": -3.0, "Dec": -5.0
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
def _apply_climatic_corrections(self, cltd: float, latitude: str, month: str, color: str, outdoor_temp: float, indoor_temp: float) -> float:
|
| 451 |
+
"""
|
| 452 |
+
Apply climatic corrections to CLTD values based on latitude, month, color, and temperature.
|
| 453 |
+
|
| 454 |
+
Args:
|
| 455 |
+
cltd (float): Base CLTD value.
|
| 456 |
+
latitude (str): Latitude (e.g., '32N').
|
| 457 |
+
month (str): Month (e.g., 'Jul').
|
| 458 |
+
color (str): Surface color ('Dark', 'Medium', 'Light').
|
| 459 |
+
outdoor_temp (float): Outdoor design temperature (°C).
|
| 460 |
+
indoor_temp (float): Indoor design temperature (°C).
|
| 461 |
+
|
| 462 |
+
Returns:
|
| 463 |
+
float: Corrected CLTD value (°C).
|
| 464 |
+
"""
|
| 465 |
+
try:
|
| 466 |
+
# Convert temperatures to °F for ASHRAE corrections
|
| 467 |
+
outdoor_temp_f = outdoor_temp * 9/5 + 32
|
| 468 |
+
indoor_temp_f = indoor_temp * 9/5 + 32
|
| 469 |
+
|
| 470 |
+
# Get correction factors
|
| 471 |
+
lat_corr = self.latitude_correction.get(latitude, {}).get(month, 0.0)
|
| 472 |
+
month_corr = self.month_correction.get(month, 0.0)
|
| 473 |
+
color_corr = self.color_correction.get(color, 0.0)
|
| 474 |
+
|
| 475 |
+
# Apply temperature difference correction (ASHRAE CLTD correction formula)
|
| 476 |
+
temp_diff = outdoor_temp_f - indoor_temp_f
|
| 477 |
+
design_temp_diff = 85 - 78 # ASHRAE base conditions: 85°F outdoor, 78°F indoor
|
| 478 |
+
temp_corr = (temp_diff - design_temp_diff) * 0.5556 # Convert °F to °C
|
| 479 |
+
|
| 480 |
+
# Total correction
|
| 481 |
+
corrected_cltd = cltd + lat_corr + month_corr + color_corr + temp_corr
|
| 482 |
+
|
| 483 |
+
# Ensure non-negative CLTD
|
| 484 |
+
return max(0.0, corrected_cltd)
|
| 485 |
+
except Exception as e:
|
| 486 |
+
raise ValueError(f"Error applying climatic corrections: {str(e)}")
|
| 487 |
+
|
| 488 |
+
def get_cltd_wall(self, wall_group: str, orientation: str, hour: int) -> float:
|
| 489 |
+
"""Get CLTD value for a wall."""
|
| 490 |
+
if wall_group not in self.cltd_wall:
|
| 491 |
+
raise ValueError(f"Invalid wall group: {wall_group}")
|
| 492 |
+
orientation_map = {e.value: e.name for e in Orientation}
|
| 493 |
+
orientation_abbr = orientation_map.get(orientation, orientation)
|
| 494 |
+
if orientation_abbr not in self.cltd_wall[wall_group].columns:
|
| 495 |
+
raise ValueError(f"Invalid orientation: {orientation}")
|
| 496 |
+
if hour not in self.cltd_wall[wall_group].index:
|
| 497 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 498 |
+
return float(self.cltd_wall[wall_group].loc[hour, orientation_abbr])
|
| 499 |
+
|
| 500 |
+
def get_cltd_roof(self, roof_group: str, latitude: str, hour: int) -> float:
|
| 501 |
+
"""Get CLTD value for a roof."""
|
| 502 |
+
# Map latitude to standard format before forming the key
|
| 503 |
+
valid_latitudes = ['24N', '36N', '48N']
|
| 504 |
+
|
| 505 |
+
# Handle numeric or non-standard latitude values
|
| 506 |
+
if latitude not in valid_latitudes:
|
| 507 |
+
# Try to convert to standard format
|
| 508 |
+
try:
|
| 509 |
+
# First, handle string representations that might contain direction indicators
|
| 510 |
+
if isinstance(latitude, str):
|
| 511 |
+
# Extract numeric part, removing 'N' or 'S'
|
| 512 |
+
lat_str = latitude.upper().strip()
|
| 513 |
+
num_part = ''.join(c for c in lat_str if c.isdigit() or c == '.')
|
| 514 |
+
lat_val = float(num_part)
|
| 515 |
+
|
| 516 |
+
# Adjust for southern hemisphere if needed
|
| 517 |
+
if 'S' in lat_str:
|
| 518 |
+
lat_val = -lat_val
|
| 519 |
+
else:
|
| 520 |
+
# Handle direct numeric input
|
| 521 |
+
lat_val = float(latitude)
|
| 522 |
+
|
| 523 |
+
# Take absolute value for mapping purposes
|
| 524 |
+
abs_lat = abs(lat_val)
|
| 525 |
+
|
| 526 |
+
# Map to the closest standard latitude for roof data
|
| 527 |
+
if abs_lat < 30:
|
| 528 |
+
latitude = '24N'
|
| 529 |
+
elif abs_lat < 42:
|
| 530 |
+
latitude = '36N'
|
| 531 |
+
else:
|
| 532 |
+
latitude = '48N'
|
| 533 |
+
|
| 534 |
+
except (ValueError, TypeError):
|
| 535 |
+
raise ValueError(f"Invalid latitude format: {latitude}")
|
| 536 |
+
|
| 537 |
+
key = f"{roof_group}_{latitude}"
|
| 538 |
+
if key not in self.cltd_roof:
|
| 539 |
+
raise ValueError(f"Invalid roof group or latitude: {key}")
|
| 540 |
+
if hour not in self.cltd_roof[key].index:
|
| 541 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 542 |
+
return float(self.cltd_roof[key].loc[hour, "HOR"])
|
| 543 |
+
|
| 544 |
+
def get_scl(self, latitude: str, month: str, orientation: str, hour: int) -> float:
|
| 545 |
+
"""Get SCL value for a window."""
|
| 546 |
+
# Map latitude to standard format before forming the key
|
| 547 |
+
valid_latitudes = ['24N', '32N', '40N', '48N', '56N']
|
| 548 |
+
|
| 549 |
+
# Handle numeric or non-standard latitude values
|
| 550 |
+
if latitude not in valid_latitudes:
|
| 551 |
+
# Try to convert to standard format
|
| 552 |
+
try:
|
| 553 |
+
# First, handle string representations that might contain direction indicators
|
| 554 |
+
if isinstance(latitude, str):
|
| 555 |
+
# Extract numeric part, removing 'N' or 'S'
|
| 556 |
+
lat_str = latitude.upper().strip()
|
| 557 |
+
num_part = ''.join(c for c in lat_str if c.isdigit() or c == '.')
|
| 558 |
+
lat_val = float(num_part)
|
| 559 |
+
|
| 560 |
+
# Adjust for southern hemisphere if needed
|
| 561 |
+
if 'S' in lat_str:
|
| 562 |
+
lat_val = -lat_val
|
| 563 |
+
else:
|
| 564 |
+
# Handle direct numeric input
|
| 565 |
+
lat_val = float(latitude)
|
| 566 |
+
|
| 567 |
+
# Take absolute value for mapping purposes
|
| 568 |
+
abs_lat = abs(lat_val)
|
| 569 |
+
|
| 570 |
+
# Map to the closest standard latitude for SCL data
|
| 571 |
+
if abs_lat < 28:
|
| 572 |
+
latitude = '24N'
|
| 573 |
+
elif abs_lat < 36:
|
| 574 |
+
latitude = '32N'
|
| 575 |
+
elif abs_lat < 44:
|
| 576 |
+
latitude = '40N'
|
| 577 |
+
elif abs_lat < 52:
|
| 578 |
+
latitude = '48N'
|
| 579 |
+
else:
|
| 580 |
+
latitude = '56N'
|
| 581 |
+
|
| 582 |
+
except (ValueError, TypeError):
|
| 583 |
+
raise ValueError(f"Invalid latitude format: {latitude}")
|
| 584 |
+
|
| 585 |
+
key = f"{latitude}_{month}"
|
| 586 |
+
if key not in self.scl:
|
| 587 |
+
raise ValueError(f"Invalid latitude or month: {key}")
|
| 588 |
+
orientation_map = {e.value: e.name for e in Orientation}
|
| 589 |
+
orientation_abbr = orientation_map.get(orientation, orientation)
|
| 590 |
+
if orientation_abbr not in self.scl[key].columns:
|
| 591 |
+
raise ValueError(f"Invalid orientation: {orientation}")
|
| 592 |
+
if hour not in self.scl[key].index:
|
| 593 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 594 |
+
return float(self.scl[key].loc[hour, orientation_abbr])
|
| 595 |
+
|
| 596 |
+
def get_clf_lights(self, zone_type: str, hours_on: str, hour: int) -> float:
|
| 597 |
+
"""Get CLF value for lights."""
|
| 598 |
+
key = f"{zone_type}_{hours_on}"
|
| 599 |
+
if key not in self.clf_lights.columns:
|
| 600 |
+
raise ValueError(f"Invalid zone type or hours: {key}")
|
| 601 |
+
if hour not in self.clf_lights.index:
|
| 602 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 603 |
+
return float(self.clf_lights.loc[hour, key])
|
| 604 |
+
|
| 605 |
+
def get_clf_people(self, zone_type: str, hours_occupied: str, hour: int) -> float:
|
| 606 |
+
"""Get CLF value for people."""
|
| 607 |
+
key = f"{zone_type}_{hours_occupied}"
|
| 608 |
+
if key not in self.clf_people.columns:
|
| 609 |
+
raise ValueError(f"Invalid zone type or hours: {key}")
|
| 610 |
+
if hour not in self.clf_people.index:
|
| 611 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 612 |
+
return float(self.clf_people.loc[hour, key])
|
| 613 |
+
|
| 614 |
+
def get_clf_equipment(self, zone_type: str, hours_operated: str, hour: int) -> float:
|
| 615 |
+
"""Get CLF value for equipment."""
|
| 616 |
+
key = f"{zone_type}_{hours_operated}"
|
| 617 |
+
if key not in self.clf_equipment.columns:
|
| 618 |
+
raise ValueError(f"Invalid zone type or hours: {key}")
|
| 619 |
+
if hour not in self.clf_equipment.index:
|
| 620 |
+
raise ValueError(f"Invalid hour: {hour}")
|
| 621 |
+
return float(self.clf_equipment.loc[hour, key])
|
| 622 |
+
|
| 623 |
+
def get_thermal_property(self, material: str, property_type: str) -> float:
|
| 624 |
+
"""
|
| 625 |
+
Get thermal property for a material.
|
| 626 |
+
|
| 627 |
+
Args:
|
| 628 |
+
material (str): Material name (e.g., 'Brick_4in').
|
| 629 |
+
property_type (str): Property to retrieve ('U_value', 'R_value', 'density').
|
| 630 |
+
|
| 631 |
+
Returns:
|
| 632 |
+
float: Value of the specified thermal property.
|
| 633 |
+
|
| 634 |
+
Raises:
|
| 635 |
+
ValueError: If material or property_type is invalid.
|
| 636 |
+
"""
|
| 637 |
+
if material not in self.thermal_properties['material'].values:
|
| 638 |
+
raise ValueError(f"Invalid material: {material}")
|
| 639 |
+
if property_type not in ['U_value', 'R_value', 'density']:
|
| 640 |
+
raise ValueError(f"Invalid property type: {property_type}")
|
| 641 |
+
return float(self.thermal_properties.loc[self.thermal_properties['material'] == material, property_type].iloc[0])
|
| 642 |
+
|
| 643 |
+
def get_heat_gain(self, source: str) -> float:
|
| 644 |
+
"""
|
| 645 |
+
Get heat gain value for an internal source.
|
| 646 |
+
|
| 647 |
+
Args:
|
| 648 |
+
source (str): Source type ('people_sensible', 'people_latent', 'lights', 'equipment').
|
| 649 |
+
|
| 650 |
+
Returns:
|
| 651 |
+
float: Heat gain value (Btu/h or Btu/h-ft²).
|
| 652 |
+
|
| 653 |
+
Raises:
|
| 654 |
+
ValueError: If source is invalid.
|
| 655 |
+
"""
|
| 656 |
+
if source not in self.heat_gain['source'].values:
|
| 657 |
+
raise ValueError(f"Invalid source: {source}")
|
| 658 |
+
return float(self.heat_gain.loc[self.heat_gain['source'] == source, 'gain'].iloc[0])
|
| 659 |
+
|
| 660 |
+
def plot_cooling_load(self, cooling_loads: List[float], title: str = "Cooling Load Profile", filename: str = "cooling_load.png") -> None:
|
| 661 |
+
"""
|
| 662 |
+
Plot the cooling load profile over 24 hours.
|
| 663 |
+
|
| 664 |
+
Args:
|
| 665 |
+
cooling_loads (List[float]): List of cooling load values for each hour.
|
| 666 |
+
title (str): Plot title.
|
| 667 |
+
filename (str): Output filename for the plot.
|
| 668 |
+
"""
|
| 669 |
+
if len(cooling_loads) != 24:
|
| 670 |
+
raise ValueError("Cooling loads must contain 24 hourly values")
|
| 671 |
+
|
| 672 |
+
plt.figure(figsize=(10, 6))
|
| 673 |
+
hours = list(range(24))
|
| 674 |
+
plt.plot(hours, cooling_loads, marker='o', linestyle='-', color='b')
|
| 675 |
+
plt.title(title)
|
| 676 |
+
plt.xlabel("Hour of Day")
|
| 677 |
+
plt.ylabel("Cooling Load (Btu/h)")
|
| 678 |
+
plt.grid(True)
|
| 679 |
+
plt.xticks(hours)
|
| 680 |
+
plt.savefig(filename)
|
| 681 |
+
plt.close()
|
| 682 |
+
|
| 683 |
+
def calculate_corrected_cltd_wall(self, wall_group: str, orientation: str, hour: int, latitude: str, month: str, color: str, outdoor_temp: float, indoor_temp: float) -> float:
|
| 684 |
+
"""
|
| 685 |
+
Calculate corrected CLTD for a wall with climatic corrections.
|
| 686 |
+
|
| 687 |
+
Args:
|
| 688 |
+
wall_group (str): Wall group (e.g., 'A', 'B', ..., 'H').
|
| 689 |
+
orientation (str): Wall orientation (e.g., 'North', 'East', etc.).
|
| 690 |
+
hour (int): Hour of the day (0-23).
|
| 691 |
+
latitude (str): Latitude (e.g., '32N').
|
| 692 |
+
month (str): Month (e.g., 'Jul').
|
| 693 |
+
color (str): Surface color ('Dark', 'Medium', 'Light').
|
| 694 |
+
outdoor_temp (float): Outdoor design temperature (°C).
|
| 695 |
+
indoor_temp (float): Indoor design temperature (°C).
|
| 696 |
+
|
| 697 |
+
Returns:
|
| 698 |
+
float: Corrected CLTD value (°C).
|
| 699 |
+
|
| 700 |
+
Raises:
|
| 701 |
+
ValueError: If inputs are invalid or correction fails.
|
| 702 |
+
"""
|
| 703 |
+
valid, message = self._validate_cltd_inputs(wall_group, orientation, hour, latitude, month, color, is_wall=True)
|
| 704 |
+
if not valid:
|
| 705 |
+
raise ValueError(message)
|
| 706 |
+
try:
|
| 707 |
+
# Get base CLTD
|
| 708 |
+
base_cltd = self.get_cltd_wall(wall_group, orientation, hour)
|
| 709 |
+
# Apply climatic corrections
|
| 710 |
+
corrected_cltd = self._apply_climatic_corrections(base_cltd, latitude, month, color, outdoor_temp, indoor_temp)
|
| 711 |
+
return corrected_cltd
|
| 712 |
+
except Exception as e:
|
| 713 |
+
raise ValueError(f"Error calculating corrected CLTD for wall: {str(e)}")
|
| 714 |
+
|
| 715 |
+
def calculate_corrected_cltd_roof(self, roof_group: str, latitude: str, hour: int, month: str, color: str, outdoor_temp: float, indoor_temp: float) -> float:
|
| 716 |
+
"""
|
| 717 |
+
Calculate corrected CLTD for a roof with climatic corrections.
|
| 718 |
+
|
| 719 |
+
Args:
|
| 720 |
+
roof_group (str): Roof group (e.g., 'A', 'B', ..., 'G').
|
| 721 |
+
latitude (str): Latitude (e.g., '24N', '36N', '48N').
|
| 722 |
+
hour (int): Hour of the day (0-23).
|
| 723 |
+
month (str): Month (e.g., 'Jul').
|
| 724 |
+
color (str): Surface color ('Dark', 'Medium', 'Light').
|
| 725 |
+
outdoor_temp (float): Outdoor design temperature (°C).
|
| 726 |
+
indoor_temp (float): Indoor design temperature (°C).
|
| 727 |
+
|
| 728 |
+
Returns:
|
| 729 |
+
float: Corrected CLTD value (°C).
|
| 730 |
+
|
| 731 |
+
Raises:
|
| 732 |
+
ValueError: If inputs are invalid or correction fails.
|
| 733 |
+
"""
|
| 734 |
+
valid, message = self._validate_cltd_inputs(roof_group, 'Horizontal', hour, latitude, month, color, is_wall=False)
|
| 735 |
+
if not valid:
|
| 736 |
+
raise ValueError(message)
|
| 737 |
+
try:
|
| 738 |
+
# Get base CLTD
|
| 739 |
+
base_cltd = self.get_cltd_roof(roof_group, latitude, hour)
|
| 740 |
+
# Apply climatic corrections
|
| 741 |
+
corrected_cltd = self._apply_climatic_corrections(base_cltd, latitude, month, color, outdoor_temp, indoor_temp)
|
| 742 |
+
return corrected_cltd
|
| 743 |
+
except Exception as e:
|
| 744 |
+
raise ValueError(f"Error calculating corrected CLTD for roof: {str(e)}")
|
data/building_components.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.2.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import List, Dict, Optional, Union
|
| 10 |
+
import numpy as np
|
| 11 |
+
from data.drapery import Drapery
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Orientation(Enum):
|
| 15 |
+
"""Enumeration for building component orientations."""
|
| 16 |
+
NORTH = "NORTH"
|
| 17 |
+
NORTHEAST = "NORTHEAST"
|
| 18 |
+
EAST = "EAST"
|
| 19 |
+
SOUTHEAST = "SOUTHEAST"
|
| 20 |
+
SOUTH = "SOUTH"
|
| 21 |
+
SOUTHWEST = "SOUTHWEST"
|
| 22 |
+
WEST = "WEST"
|
| 23 |
+
NORTHWEST = "NORTHWEST"
|
| 24 |
+
HORIZONTAL = "HORIZONTAL" # For roofs and floors
|
| 25 |
+
NOT_APPLICABLE = "N/A" # For components without orientation
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ComponentType(Enum):
|
| 29 |
+
"""Enumeration for building component types."""
|
| 30 |
+
WALL = "WALL"
|
| 31 |
+
ROOF = "ROOF"
|
| 32 |
+
FLOOR = "FLOOR"
|
| 33 |
+
WINDOW = "WINDOW"
|
| 34 |
+
DOOR = "DOOR"
|
| 35 |
+
SKYLIGHT = "SKYLIGHT"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class MaterialLayer:
|
| 39 |
+
"""Class representing a single material layer in a building component."""
|
| 40 |
+
|
| 41 |
+
def __init__(self, name: str, thickness: float, conductivity: float,
|
| 42 |
+
density: float = None, specific_heat: float = None):
|
| 43 |
+
"""
|
| 44 |
+
Initialize a material layer.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
name: Name of the material
|
| 48 |
+
thickness: Thickness of the layer in meters
|
| 49 |
+
conductivity: Thermal conductivity in W/(m·K)
|
| 50 |
+
density: Density in kg/m³ (optional)
|
| 51 |
+
specific_heat: Specific heat capacity in J/(kg·K) (optional)
|
| 52 |
+
"""
|
| 53 |
+
self.name = name
|
| 54 |
+
self.thickness = thickness # m
|
| 55 |
+
self.conductivity = conductivity # W/(m·K)
|
| 56 |
+
self.density = density # kg/m³
|
| 57 |
+
self.specific_heat = specific_heat # J/(kg·K)
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def r_value(self) -> float:
|
| 61 |
+
"""Calculate the thermal resistance (R-value) of the layer in m²·K/W."""
|
| 62 |
+
if self.conductivity == 0:
|
| 63 |
+
return float('inf') # Avoid division by zero
|
| 64 |
+
return self.thickness / self.conductivity
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def thermal_mass(self) -> Optional[float]:
|
| 68 |
+
"""Calculate the thermal mass of the layer in J/(m²·K)."""
|
| 69 |
+
if self.density is None or self.specific_heat is None:
|
| 70 |
+
return None
|
| 71 |
+
return self.thickness * self.density * self.specific_heat
|
| 72 |
+
|
| 73 |
+
def to_dict(self) -> Dict:
|
| 74 |
+
"""Convert the material layer to a dictionary."""
|
| 75 |
+
return {
|
| 76 |
+
"name": self.name,
|
| 77 |
+
"thickness": self.thickness,
|
| 78 |
+
"conductivity": self.conductivity,
|
| 79 |
+
"density": self.density,
|
| 80 |
+
"specific_heat": self.specific_heat,
|
| 81 |
+
"r_value": self.r_value,
|
| 82 |
+
"thermal_mass": self.thermal_mass
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@dataclass
|
| 87 |
+
class BuildingComponent:
|
| 88 |
+
"""Base class for all building components."""
|
| 89 |
+
|
| 90 |
+
id: str
|
| 91 |
+
name: str
|
| 92 |
+
component_type: ComponentType
|
| 93 |
+
u_value: float # W/(m²·K)
|
| 94 |
+
area: float # m²
|
| 95 |
+
orientation: Orientation = Orientation.NOT_APPLICABLE
|
| 96 |
+
color: str = "Medium" # Light, Medium, Dark
|
| 97 |
+
material_layers: List[MaterialLayer] = field(default_factory=list)
|
| 98 |
+
|
| 99 |
+
def __post_init__(self):
|
| 100 |
+
"""Validate component data after initialization."""
|
| 101 |
+
if self.area <= 0:
|
| 102 |
+
raise ValueError("Area must be greater than zero")
|
| 103 |
+
if self.u_value < 0:
|
| 104 |
+
raise ValueError("U-value cannot be negative")
|
| 105 |
+
|
| 106 |
+
@property
|
| 107 |
+
def r_value(self) -> float:
|
| 108 |
+
"""Calculate the total thermal resistance (R-value) in m²·K/W."""
|
| 109 |
+
return 1 / self.u_value if self.u_value > 0 else float('inf')
|
| 110 |
+
|
| 111 |
+
@property
|
| 112 |
+
def total_r_value_from_layers(self) -> Optional[float]:
|
| 113 |
+
"""Calculate the total R-value from material layers if available."""
|
| 114 |
+
if not self.material_layers:
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
# Add surface resistances (interior and exterior)
|
| 118 |
+
r_si = 0.13 # m²·K/W (interior surface resistance)
|
| 119 |
+
r_se = 0.04 # m²·K/W (exterior surface resistance)
|
| 120 |
+
|
| 121 |
+
# Sum the R-values of all layers
|
| 122 |
+
r_layers = sum(layer.r_value for layer in self.material_layers)
|
| 123 |
+
|
| 124 |
+
return r_si + r_layers + r_se
|
| 125 |
+
|
| 126 |
+
@property
|
| 127 |
+
def calculated_u_value(self) -> Optional[float]:
|
| 128 |
+
"""Calculate U-value from material layers if available."""
|
| 129 |
+
total_r = self.total_r_value_from_layers
|
| 130 |
+
if total_r is None or total_r == 0:
|
| 131 |
+
return None
|
| 132 |
+
return 1 / total_r
|
| 133 |
+
|
| 134 |
+
def heat_transfer_rate(self, delta_t: float) -> float:
|
| 135 |
+
"""
|
| 136 |
+
Calculate heat transfer rate through the component.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
delta_t: Temperature difference across the component in K or °C
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Heat transfer rate in Watts
|
| 143 |
+
"""
|
| 144 |
+
return self.u_value * self.area * delta_t
|
| 145 |
+
|
| 146 |
+
def to_dict(self) -> Dict:
|
| 147 |
+
"""Convert the building component to a dictionary."""
|
| 148 |
+
return {
|
| 149 |
+
"id": self.id,
|
| 150 |
+
"name": self.name,
|
| 151 |
+
"component_type": self.component_type.value,
|
| 152 |
+
"u_value": self.u_value,
|
| 153 |
+
"area": self.area,
|
| 154 |
+
"orientation": self.orientation.value,
|
| 155 |
+
"color": self.color,
|
| 156 |
+
"r_value": self.r_value,
|
| 157 |
+
"material_layers": [layer.to_dict() for layer in self.material_layers],
|
| 158 |
+
"calculated_u_value": self.calculated_u_value,
|
| 159 |
+
"total_r_value_from_layers": self.total_r_value_from_layers
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@dataclass
|
| 164 |
+
class Wall(BuildingComponent):
|
| 165 |
+
"""Class representing a wall component."""
|
| 166 |
+
|
| 167 |
+
VALID_WALL_GROUPS = {"A", "B", "C", "D", "E", "F", "G", "H"} # ASHRAE wall groups for CLTD
|
| 168 |
+
|
| 169 |
+
has_sun_exposure: bool = True
|
| 170 |
+
wall_type: str = "Custom" # Brick, Concrete, Wood Frame, etc.
|
| 171 |
+
wall_group: str = "A" # ASHRAE wall group (A, B, C, D, E, F, G, H)
|
| 172 |
+
gross_area: float = None # m² (before subtracting windows/doors)
|
| 173 |
+
net_area: float = None # m² (after subtracting windows/doors)
|
| 174 |
+
windows: List[str] = field(default_factory=list) # List of window IDs
|
| 175 |
+
doors: List[str] = field(default_factory=list) # List of door IDs
|
| 176 |
+
|
| 177 |
+
def __post_init__(self):
|
| 178 |
+
"""Initialize wall-specific attributes."""
|
| 179 |
+
super().__post_init__()
|
| 180 |
+
self.component_type = ComponentType.WALL
|
| 181 |
+
|
| 182 |
+
# Validate wall_group
|
| 183 |
+
if self.wall_group not in self.VALID_WALL_GROUPS:
|
| 184 |
+
raise ValueError(f"Invalid wall_group: {self.wall_group}. Must be one of {self.VALID_WALL_GROUPS}")
|
| 185 |
+
|
| 186 |
+
# Set net area equal to area if not specified
|
| 187 |
+
if self.net_area is None:
|
| 188 |
+
self.net_area = self.area
|
| 189 |
+
|
| 190 |
+
# Set gross area equal to net area if not specified
|
| 191 |
+
if self.gross_area is None:
|
| 192 |
+
self.gross_area = self.net_area
|
| 193 |
+
|
| 194 |
+
def update_net_area(self, window_areas: Dict[str, float], door_areas: Dict[str, float]):
|
| 195 |
+
"""
|
| 196 |
+
Update the net wall area by subtracting windows and doors.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
window_areas: Dictionary mapping window IDs to areas
|
| 200 |
+
door_areas: Dictionary mapping door IDs to areas
|
| 201 |
+
"""
|
| 202 |
+
total_window_area = sum(window_areas.get(window_id, 0) for window_id in self.windows)
|
| 203 |
+
total_door_area = sum(door_areas.get(door_id, 0) for door_id in self.doors)
|
| 204 |
+
|
| 205 |
+
self.net_area = self.gross_area - total_window_area - total_door_area
|
| 206 |
+
self.area = self.net_area # Update the main area property
|
| 207 |
+
|
| 208 |
+
if self.net_area <= 0:
|
| 209 |
+
raise ValueError("Net wall area cannot be negative or zero")
|
| 210 |
+
|
| 211 |
+
def to_dict(self) -> Dict:
|
| 212 |
+
"""Convert the wall to a dictionary."""
|
| 213 |
+
wall_dict = super().to_dict()
|
| 214 |
+
wall_dict.update({
|
| 215 |
+
"has_sun_exposure": self.has_sun_exposure,
|
| 216 |
+
"wall_type": self.wall_type,
|
| 217 |
+
"wall_group": self.wall_group,
|
| 218 |
+
"gross_area": self.gross_area,
|
| 219 |
+
"net_area": self.net_area,
|
| 220 |
+
"windows": self.windows,
|
| 221 |
+
"doors": self.doors
|
| 222 |
+
})
|
| 223 |
+
return wall_dict
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@dataclass
|
| 227 |
+
class Roof(BuildingComponent):
|
| 228 |
+
"""Class representing a roof component."""
|
| 229 |
+
|
| 230 |
+
VALID_ROOF_GROUPS = {"A", "B", "C", "D", "E", "F", "G"} # ASHRAE roof groups for CLTD
|
| 231 |
+
|
| 232 |
+
roof_type: str = "Custom" # Flat, Pitched, etc.
|
| 233 |
+
roof_group: str = "A" # ASHRAE roof group
|
| 234 |
+
pitch: float = 0.0 # Roof pitch in degrees
|
| 235 |
+
has_suspended_ceiling: bool = False
|
| 236 |
+
ceiling_plenum_height: float = 0.0 # m
|
| 237 |
+
|
| 238 |
+
def __post_init__(self):
|
| 239 |
+
"""Initialize roof-specific attributes."""
|
| 240 |
+
super().__post_init__()
|
| 241 |
+
self.component_type = ComponentType.ROOF
|
| 242 |
+
self.orientation = Orientation.HORIZONTAL
|
| 243 |
+
|
| 244 |
+
# Validate roof_group
|
| 245 |
+
if self.roof_group not in self.VALID_ROOF_GROUPS:
|
| 246 |
+
raise ValueError(f"Invalid roof_group: {self.roof_group}. Must be one of {self.VALID_ROOF_GROUPS}")
|
| 247 |
+
|
| 248 |
+
def to_dict(self) -> Dict:
|
| 249 |
+
"""Convert the roof to a dictionary."""
|
| 250 |
+
roof_dict = super().to_dict()
|
| 251 |
+
roof_dict.update({
|
| 252 |
+
"roof_type": self.roof_type,
|
| 253 |
+
"roof_group": self.roof_group,
|
| 254 |
+
"pitch": self.pitch,
|
| 255 |
+
"has_suspended_ceiling": self.has_suspended_ceiling,
|
| 256 |
+
"ceiling_plenum_height": self.ceiling_plenum_height
|
| 257 |
+
})
|
| 258 |
+
return roof_dict
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@dataclass
|
| 262 |
+
class Floor(BuildingComponent):
|
| 263 |
+
"""Class representing a floor component."""
|
| 264 |
+
|
| 265 |
+
floor_type: str = "Custom" # Slab-on-grade, Raised, etc.
|
| 266 |
+
is_ground_contact: bool = False
|
| 267 |
+
perimeter_length: float = 0.0 # m (for slab-on-grade floors)
|
| 268 |
+
insulated: bool = False # Added to indicate insulation status
|
| 269 |
+
ground_temperature_c: float = None # Added for ground temperature in °C
|
| 270 |
+
|
| 271 |
+
def __post_init__(self):
|
| 272 |
+
"""Initialize floor-specific attributes."""
|
| 273 |
+
super().__post_init__()
|
| 274 |
+
self.component_type = ComponentType.FLOOR
|
| 275 |
+
self.orientation = Orientation.HORIZONTAL
|
| 276 |
+
|
| 277 |
+
def to_dict(self) -> Dict:
|
| 278 |
+
"""Convert the floor to a dictionary."""
|
| 279 |
+
floor_dict = super().to_dict()
|
| 280 |
+
floor_dict.update({
|
| 281 |
+
"floor_type": self.floor_type,
|
| 282 |
+
"is_ground_contact": self.is_ground_contact,
|
| 283 |
+
"perimeter_length": self.perimeter_length,
|
| 284 |
+
"insulated": self.insulated,
|
| 285 |
+
"ground_temperature_c": self.ground_temperature_c
|
| 286 |
+
})
|
| 287 |
+
return floor_dict
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
@dataclass
|
| 291 |
+
class Fenestration(BuildingComponent):
|
| 292 |
+
"""Base class for fenestration components (windows, doors, skylights)."""
|
| 293 |
+
|
| 294 |
+
shgc: float = 0.7 # Solar Heat Gain Coefficient
|
| 295 |
+
vt: float = 0.7 # Visible Transmittance
|
| 296 |
+
frame_type: str = "Aluminum" # Aluminum, Wood, Vinyl, etc.
|
| 297 |
+
frame_width: float = 0.05 # m
|
| 298 |
+
has_shading: bool = False
|
| 299 |
+
shading_type: str = None # Internal, External, Between-glass
|
| 300 |
+
shading_coefficient: float = 1.0 # 0-1 (1 = no shading)
|
| 301 |
+
|
| 302 |
+
def __post_init__(self):
|
| 303 |
+
"""Initialize fenestration-specific attributes."""
|
| 304 |
+
super().__post_init__()
|
| 305 |
+
|
| 306 |
+
if self.shgc < 0 or self.shgc > 1:
|
| 307 |
+
raise ValueError("SHGC must be between 0 and 1")
|
| 308 |
+
if self.vt < 0 or self.vt > 1:
|
| 309 |
+
raise ValueError("VT must be between 0 and 1")
|
| 310 |
+
if self.shading_coefficient < 0 or self.shading_coefficient > 1:
|
| 311 |
+
raise ValueError("Shading coefficient must be between 0 and 1")
|
| 312 |
+
|
| 313 |
+
@property
|
| 314 |
+
def effective_shgc(self) -> float:
|
| 315 |
+
"""Calculate the effective SHGC considering shading."""
|
| 316 |
+
return self.shgc * self.shading_coefficient
|
| 317 |
+
|
| 318 |
+
def to_dict(self) -> Dict:
|
| 319 |
+
"""Convert the fenestration to a dictionary."""
|
| 320 |
+
fenestration_dict = super().to_dict()
|
| 321 |
+
fenestration_dict.update({
|
| 322 |
+
"shgc": self.shgc,
|
| 323 |
+
"vt": self.vt,
|
| 324 |
+
"frame_type": self.frame_type,
|
| 325 |
+
"frame_width": self.frame_width,
|
| 326 |
+
"has_shading": self.has_shading,
|
| 327 |
+
"shading_type": self.shading_type,
|
| 328 |
+
"shading_coefficient": self.shading_coefficient,
|
| 329 |
+
"effective_shgc": self.effective_shgc
|
| 330 |
+
})
|
| 331 |
+
return fenestration_dict
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
@dataclass
|
| 335 |
+
class Window(Fenestration):
|
| 336 |
+
"""Class representing a window component."""
|
| 337 |
+
|
| 338 |
+
window_type: str = "Custom" # Single, Double, Triple glazed, etc.
|
| 339 |
+
glazing_layers: int = 2 # Number of glazing layers
|
| 340 |
+
gas_fill: str = "Air" # Air, Argon, Krypton, etc.
|
| 341 |
+
low_e_coating: bool = False
|
| 342 |
+
width: float = 1.0 # m
|
| 343 |
+
height: float = 1.0 # m
|
| 344 |
+
wall_id: str = None # ID of the wall containing this window
|
| 345 |
+
drapery: Optional[Drapery] = None # Drapery object
|
| 346 |
+
|
| 347 |
+
def __post_init__(self):
|
| 348 |
+
"""Initialize window-specific attributes."""
|
| 349 |
+
super().__post_init__()
|
| 350 |
+
self.component_type = ComponentType.WINDOW
|
| 351 |
+
|
| 352 |
+
# Calculate area from width and height if not provided
|
| 353 |
+
if self.area <= 0 and self.width > 0 and self.height > 0:
|
| 354 |
+
self.area = self.width * self.height
|
| 355 |
+
|
| 356 |
+
# Initialize drapery if not provided
|
| 357 |
+
if self.drapery is None:
|
| 358 |
+
self.drapery = Drapery(enabled=False)
|
| 359 |
+
|
| 360 |
+
@classmethod
|
| 361 |
+
def from_classification(cls, id: str, name: str, u_value: float, area: float,
|
| 362 |
+
shgc: float, orientation: Orientation, wall_id: str,
|
| 363 |
+
drapery_classification: str, fullness: float = 1.0, **kwargs) -> 'Window':
|
| 364 |
+
"""
|
| 365 |
+
Create window object with drapery from ASHRAE classification.
|
| 366 |
+
|
| 367 |
+
Args:
|
| 368 |
+
id: Unique identifier
|
| 369 |
+
name: Window name
|
| 370 |
+
u_value: Window U-value in W/m²K
|
| 371 |
+
area: Window area in m²
|
| 372 |
+
shgc: Solar Heat Gain Coefficient (0-1)
|
| 373 |
+
orientation: Window orientation
|
| 374 |
+
wall_id: ID of the wall containing this window
|
| 375 |
+
drapery_classification: ASHRAE drapery classification (e.g., ID, IM, IIL)
|
| 376 |
+
fullness: Fullness factor (0-2)
|
| 377 |
+
**kwargs: Additional arguments for Window attributes
|
| 378 |
+
|
| 379 |
+
Returns:
|
| 380 |
+
Window object
|
| 381 |
+
"""
|
| 382 |
+
drapery = Drapery.from_classification(drapery_classification, fullness)
|
| 383 |
+
return cls(
|
| 384 |
+
id=id,
|
| 385 |
+
name=name,
|
| 386 |
+
component_type=ComponentType.WINDOW,
|
| 387 |
+
u_value=u_value,
|
| 388 |
+
area=area,
|
| 389 |
+
shgc=shgc,
|
| 390 |
+
orientation=orientation,
|
| 391 |
+
drapery=drapery,
|
| 392 |
+
wall_id=wall_id,
|
| 393 |
+
**kwargs
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
def get_effective_u_value(self) -> float:
|
| 397 |
+
"""Get effective U-value with drapery adjustment."""
|
| 398 |
+
if self.drapery and self.drapery.enabled:
|
| 399 |
+
return self.drapery.calculate_u_value_adjustment(self.u_value)
|
| 400 |
+
return self.u_value
|
| 401 |
+
|
| 402 |
+
def get_shading_coefficient(self) -> float:
|
| 403 |
+
"""Get shading coefficient with drapery."""
|
| 404 |
+
if self.drapery and self.drapery.enabled:
|
| 405 |
+
return self.drapery.calculate_shading_coefficient(self.shgc)
|
| 406 |
+
return self.shading_coefficient
|
| 407 |
+
|
| 408 |
+
def get_iac(self) -> float:
|
| 409 |
+
"""Get Interior Attenuation Coefficient with drapery."""
|
| 410 |
+
if self.drapery and self.drapery.enabled:
|
| 411 |
+
return self.drapery.calculate_iac(self.shgc)
|
| 412 |
+
return 1.0 # No attenuation
|
| 413 |
+
|
| 414 |
+
def to_dict(self) -> Dict:
|
| 415 |
+
"""Convert the window to a dictionary."""
|
| 416 |
+
window_dict = super().to_dict()
|
| 417 |
+
window_dict.update({
|
| 418 |
+
"window_type": self.window_type,
|
| 419 |
+
"glazing_layers": self.glazing_layers,
|
| 420 |
+
"gas_fill": self.gas_fill,
|
| 421 |
+
"low_e_coating": self.low_e_coating,
|
| 422 |
+
"width": self.width,
|
| 423 |
+
"height": self.height,
|
| 424 |
+
"wall_id": self.wall_id,
|
| 425 |
+
"drapery": self.drapery.to_dict() if self.drapery else None,
|
| 426 |
+
"drapery_classification": self.drapery.get_classification() if self.drapery and self.drapery.enabled else None
|
| 427 |
+
})
|
| 428 |
+
return window_dict
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
@dataclass
|
| 432 |
+
class Door(Fenestration):
|
| 433 |
+
"""Class representing a door component."""
|
| 434 |
+
|
| 435 |
+
door_type: str = "Custom" # Solid, Partially glazed, etc.
|
| 436 |
+
glazing_percentage: float = 0.0 # Percentage of door area that is glazed (0-100)
|
| 437 |
+
width: float = 0.9 # m
|
| 438 |
+
height: float = 2.1 # m
|
| 439 |
+
wall_id: str = None # ID of the wall containing this door
|
| 440 |
+
|
| 441 |
+
def __post_init__(self):
|
| 442 |
+
"""Initialize door-specific attributes."""
|
| 443 |
+
super().__post_init__()
|
| 444 |
+
self.component_type = ComponentType.DOOR
|
| 445 |
+
|
| 446 |
+
# Calculate area from width and height if not provided
|
| 447 |
+
if self.area <= 0 and self.width > 0 and self.height > 0:
|
| 448 |
+
self.area = self.width * self.height
|
| 449 |
+
|
| 450 |
+
if self.glazing_percentage < 0 or self.glazing_percentage > 100:
|
| 451 |
+
raise ValueError("Glazing percentage must be between 0 and 100")
|
| 452 |
+
|
| 453 |
+
@property
|
| 454 |
+
def glazing_area(self) -> float:
|
| 455 |
+
"""Calculate the glazed area of the door in m²."""
|
| 456 |
+
return self.area * (self.glazing_percentage / 100)
|
| 457 |
+
|
| 458 |
+
@property
|
| 459 |
+
def opaque_area(self) -> float:
|
| 460 |
+
"""Calculate the opaque area of the door in m²."""
|
| 461 |
+
return self.area - self.glazing_area
|
| 462 |
+
|
| 463 |
+
def to_dict(self) -> Dict:
|
| 464 |
+
"""Convert the door to a dictionary."""
|
| 465 |
+
door_dict = super().to_dict()
|
| 466 |
+
door_dict.update({
|
| 467 |
+
"door_type": self.door_type,
|
| 468 |
+
"glazing_percentage": self.glazing_percentage,
|
| 469 |
+
"width": self.width,
|
| 470 |
+
"height": self.height,
|
| 471 |
+
"wall_id": self.wall_id,
|
| 472 |
+
"glazing_area": self.glazing_area,
|
| 473 |
+
"opaque_area": self.opaque_area
|
| 474 |
+
})
|
| 475 |
+
return door_dict
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
@dataclass
|
| 479 |
+
class Skylight(Fenestration):
|
| 480 |
+
"""Class representing a skylight component."""
|
| 481 |
+
|
| 482 |
+
skylight_type: str = "Custom" # Flat, Domed, etc.
|
| 483 |
+
glazing_layers: int = 2 # Number of glazing layers
|
| 484 |
+
gas_fill: str = "Air" # Air, Argon, Krypton, etc.
|
| 485 |
+
low_e_coating: bool = False
|
| 486 |
+
width: float = 1.0 # m
|
| 487 |
+
length: float = 1.0 # m
|
| 488 |
+
roof_id: str = None # ID of the roof containing this skylight
|
| 489 |
+
|
| 490 |
+
def __post_init__(self):
|
| 491 |
+
"""Initialize skylight-specific attributes."""
|
| 492 |
+
super().__post_init__()
|
| 493 |
+
self.component_type = ComponentType.SKYLIGHT
|
| 494 |
+
self.orientation = Orientation.HORIZONTAL
|
| 495 |
+
|
| 496 |
+
# Calculate area from width and length if not provided
|
| 497 |
+
if self.area <= 0 and self.width > 0 and self.length > 0:
|
| 498 |
+
self.area = self.width * self.length
|
| 499 |
+
|
| 500 |
+
def to_dict(self) -> Dict:
|
| 501 |
+
"""Convert the skylight to a dictionary."""
|
| 502 |
+
skylight_dict = super().to_dict()
|
| 503 |
+
skylight_dict.update({
|
| 504 |
+
"skylight_type": self.skylight_type,
|
| 505 |
+
"glazing_layers": self.glazing_layers,
|
| 506 |
+
"gas_fill": self.gas_fill,
|
| 507 |
+
"low_e_coating": self.low_e_coating,
|
| 508 |
+
"width": self.width,
|
| 509 |
+
"length": self.length,
|
| 510 |
+
"roof_id": self.roof_id
|
| 511 |
+
})
|
| 512 |
+
return skylight_dict
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
class BuildingComponentFactory:
|
| 516 |
+
"""Factory class for creating building components."""
|
| 517 |
+
|
| 518 |
+
@staticmethod
|
| 519 |
+
def create_component(component_data: Dict) -> BuildingComponent:
|
| 520 |
+
"""
|
| 521 |
+
Create a building component from a dictionary of data.
|
| 522 |
+
|
| 523 |
+
Args:
|
| 524 |
+
component_data: Dictionary containing component data
|
| 525 |
+
|
| 526 |
+
Returns:
|
| 527 |
+
A BuildingComponent object of the appropriate type
|
| 528 |
+
"""
|
| 529 |
+
component_type = component_data.get("component_type")
|
| 530 |
+
|
| 531 |
+
# Convert string component_type to ComponentType enum
|
| 532 |
+
if isinstance(component_type, str):
|
| 533 |
+
component_type = ComponentType[component_type]
|
| 534 |
+
|
| 535 |
+
# Handle drapery for Window components
|
| 536 |
+
if component_type == ComponentType.WINDOW:
|
| 537 |
+
drapery_data = component_data.pop("drapery", None)
|
| 538 |
+
drapery_classification = component_data.pop("drapery_classification", None)
|
| 539 |
+
if drapery_classification:
|
| 540 |
+
fullness = drapery_data.get("fullness", 1.0) if drapery_data else 1.0
|
| 541 |
+
component_data["drapery"] = Drapery.from_classification(drapery_classification, fullness)
|
| 542 |
+
elif drapery_data:
|
| 543 |
+
component_data["drapery"] = Drapery.from_dict(drapery_data)
|
| 544 |
+
|
| 545 |
+
# Convert orientation to Orientation enum
|
| 546 |
+
if "orientation" in component_data and isinstance(component_data["orientation"], str):
|
| 547 |
+
component_data["orientation"] = Orientation[component_data["orientation"]]
|
| 548 |
+
|
| 549 |
+
# Convert material_layers to MaterialLayer objects
|
| 550 |
+
if "material_layers" in component_data:
|
| 551 |
+
component_data["material_layers"] = [
|
| 552 |
+
MaterialLayer(**layer) for layer in component_data["material_layers"]
|
| 553 |
+
]
|
| 554 |
+
|
| 555 |
+
if component_type == ComponentType.WALL:
|
| 556 |
+
return Wall(**component_data)
|
| 557 |
+
elif component_type == ComponentType.ROOF:
|
| 558 |
+
return Roof(**component_data)
|
| 559 |
+
elif component_type == ComponentType.FLOOR:
|
| 560 |
+
return Floor(**component_data)
|
| 561 |
+
elif component_type == ComponentType.WINDOW:
|
| 562 |
+
return Window(**component_data)
|
| 563 |
+
elif component_type == ComponentType.DOOR:
|
| 564 |
+
return Door(**component_data)
|
| 565 |
+
elif component_type == ComponentType.SKYLIGHT:
|
| 566 |
+
return Skylight(**component_data)
|
| 567 |
+
else:
|
| 568 |
+
raise ValueError(f"Unknown component type: {component_type}")
|
data/climate_data.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Author: Dr Majed Abuseif
|
| 6 |
+
Date: March 2025
|
| 7 |
+
Version: 1.0.0
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Dict, List, Any, Optional
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import numpy as np
|
| 13 |
+
import os
|
| 14 |
+
import json
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
import streamlit as st
|
| 17 |
+
import plotly.graph_objects as go
|
| 18 |
+
from io import StringIO
|
| 19 |
+
|
| 20 |
+
# Define paths
|
| 21 |
+
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class ClimateLocation:
|
| 25 |
+
"""Class representing a climate location with ASHRAE 169 data."""
|
| 26 |
+
|
| 27 |
+
id: str
|
| 28 |
+
country: str
|
| 29 |
+
state_province: str
|
| 30 |
+
city: str
|
| 31 |
+
latitude: float
|
| 32 |
+
longitude: float
|
| 33 |
+
elevation: float # meters
|
| 34 |
+
climate_zone: str
|
| 35 |
+
heating_degree_days: float # base 18°C
|
| 36 |
+
cooling_degree_days: float # base 18°C
|
| 37 |
+
winter_design_temp: float # 99.6% heating design temperature (°C)
|
| 38 |
+
summer_design_temp_db: float # 0.4% cooling design dry-bulb temperature (°C)
|
| 39 |
+
summer_design_temp_wb: float # 0.4% cooling design wet-bulb temperature (°C)
|
| 40 |
+
summer_daily_range: float # Mean daily temperature range in summer (°C)
|
| 41 |
+
monthly_temps: Dict[str, float] # Average monthly temperatures (°C)
|
| 42 |
+
monthly_humidity: Dict[str, float] # Average monthly relative humidity (%)
|
| 43 |
+
|
| 44 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 45 |
+
"""Convert the climate location to a dictionary."""
|
| 46 |
+
return {
|
| 47 |
+
"id": self.id,
|
| 48 |
+
"country": self.country,
|
| 49 |
+
"state_province": self.state_province,
|
| 50 |
+
"city": self.city,
|
| 51 |
+
"latitude": self.latitude,
|
| 52 |
+
"longitude": self.longitude,
|
| 53 |
+
"elevation": self.elevation,
|
| 54 |
+
"climate_zone": self.climate_zone,
|
| 55 |
+
"heating_degree_days": self.heating_degree_days,
|
| 56 |
+
"cooling_degree_days": self.cooling_degree_days,
|
| 57 |
+
"winter_design_temp": self.winter_design_temp,
|
| 58 |
+
"summer_design_temp_db": self.summer_design_temp_db,
|
| 59 |
+
"summer_design_temp_wb": self.summer_design_temp_wb,
|
| 60 |
+
"summer_daily_range": self.summer_daily_range,
|
| 61 |
+
"monthly_temps": self.monthly_temps,
|
| 62 |
+
"monthly_humidity": self.monthly_humidity
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
class ClimateData:
|
| 66 |
+
"""Class for managing ASHRAE 169 climate data."""
|
| 67 |
+
|
| 68 |
+
def __init__(self):
|
| 69 |
+
"""Initialize climate data."""
|
| 70 |
+
self.locations = {}
|
| 71 |
+
self.countries = []
|
| 72 |
+
self.country_states = {}
|
| 73 |
+
|
| 74 |
+
def _group_locations_by_country_state(self) -> Dict[str, Dict[str, List[str]]]:
|
| 75 |
+
"""Group locations by country and state/province."""
|
| 76 |
+
result = {}
|
| 77 |
+
for loc in self.locations.values():
|
| 78 |
+
if loc.country not in result:
|
| 79 |
+
result[loc.country] = {}
|
| 80 |
+
if loc.state_province not in result[loc.country]:
|
| 81 |
+
result[loc.country][loc.state_province] = []
|
| 82 |
+
result[loc.country][loc.state_province].append(loc.city)
|
| 83 |
+
for country in result:
|
| 84 |
+
for state in result[country]:
|
| 85 |
+
result[country][state] = sorted(result[country][state])
|
| 86 |
+
return result
|
| 87 |
+
|
| 88 |
+
def add_location(self, location: ClimateLocation):
|
| 89 |
+
"""Add a new location to the dictionary."""
|
| 90 |
+
self.locations[location.id] = location
|
| 91 |
+
self.countries = sorted(list(set(loc.country for loc in self.locations.values())))
|
| 92 |
+
self.country_states = self._group_locations_by_country_state()
|
| 93 |
+
|
| 94 |
+
def get_location_by_id(self, location_id: str, session_state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
| 95 |
+
"""Retrieve climate data by ID from session state or locations."""
|
| 96 |
+
if "climate_data" in session_state and session_state["climate_data"].get("id") == location_id:
|
| 97 |
+
return session_state["climate_data"]
|
| 98 |
+
if location_id in self.locations:
|
| 99 |
+
return self.locations[location_id].to_dict()
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
@staticmethod
|
| 103 |
+
def validate_climate_data(data: Dict[str, Any]) -> bool:
|
| 104 |
+
"""Validate climate data for required fields and ranges."""
|
| 105 |
+
required_fields = [
|
| 106 |
+
"id", "country", "city", "latitude", "longitude", "elevation",
|
| 107 |
+
"climate_zone", "heating_degree_days", "cooling_degree_days",
|
| 108 |
+
"winter_design_temp", "summer_design_temp_db", "summer_design_temp_wb",
|
| 109 |
+
"summer_daily_range", "monthly_temps", "monthly_humidity"
|
| 110 |
+
]
|
| 111 |
+
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
| 112 |
+
|
| 113 |
+
for field in required_fields:
|
| 114 |
+
if field not in data:
|
| 115 |
+
return False
|
| 116 |
+
|
| 117 |
+
if not (-90 <= data["latitude"] <= 90 and -180 <= data["longitude"] <= 180):
|
| 118 |
+
return False
|
| 119 |
+
if data["elevation"] < 0:
|
| 120 |
+
return False
|
| 121 |
+
if data["climate_zone"] not in ["0A", "0B", "1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"]:
|
| 122 |
+
return False
|
| 123 |
+
if not (data["heating_degree_days"] >= 0 and data["cooling_degree_days"] >= 0):
|
| 124 |
+
return False
|
| 125 |
+
if not (-50 <= data["winter_design_temp"] <= 20):
|
| 126 |
+
return False
|
| 127 |
+
if not (0 <= data["summer_design_temp_db"] <= 50 and 0 <= data["summer_design_temp_wb"] <= 40):
|
| 128 |
+
return False
|
| 129 |
+
if data["summer_daily_range"] < 0:
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
for month in month_names:
|
| 133 |
+
if month not in data["monthly_temps"] or month not in data["monthly_humidity"]:
|
| 134 |
+
return False
|
| 135 |
+
if not (-50 <= data["monthly_temps"][month] <= 50):
|
| 136 |
+
return False
|
| 137 |
+
if not (0 <= data["monthly_humidity"][month] <= 100):
|
| 138 |
+
return False
|
| 139 |
+
|
| 140 |
+
return True
|
| 141 |
+
|
| 142 |
+
@staticmethod
|
| 143 |
+
def calculate_wet_bulb(dry_bulb: np.ndarray, relative_humidity: np.ndarray) -> np.ndarray:
|
| 144 |
+
"""Calculate Wet Bulb Temperature using Stull (2011) approximation."""
|
| 145 |
+
db = np.array(dry_bulb, dtype=float)
|
| 146 |
+
rh = np.array(relative_humidity, dtype=float)
|
| 147 |
+
|
| 148 |
+
term1 = db * np.arctan(0.151977 * (rh + 8.313659)**0.5)
|
| 149 |
+
term2 = np.arctan(db + rh)
|
| 150 |
+
term3 = np.arctan(rh - 1.676331)
|
| 151 |
+
term4 = 0.00391838 * rh**1.5 * np.arctan(0.023101 * rh)
|
| 152 |
+
term5 = -4.686035
|
| 153 |
+
|
| 154 |
+
wet_bulb = term1 + term2 - term3 + term4 + term5
|
| 155 |
+
|
| 156 |
+
invalid_mask = (rh < 5) | (rh > 99) | (db < -20) | (db > 50) | np.isnan(db) | np.isnan(rh)
|
| 157 |
+
wet_bulb[invalid_mask] = np.nan
|
| 158 |
+
|
| 159 |
+
return wet_bulb
|
| 160 |
+
|
| 161 |
+
def display_climate_input(self, session_state: Dict[str, Any]):
|
| 162 |
+
"""Display form for manual input or EPW upload in Streamlit."""
|
| 163 |
+
st.title("Climate Data")
|
| 164 |
+
|
| 165 |
+
if not session_state.building_info.get("country") or not session_state.building_info.get("city"):
|
| 166 |
+
st.warning("Please enter country and city in Building Information first.")
|
| 167 |
+
st.button("Go to Building Information", on_click=lambda: setattr(session_state, "page", "Building Information"))
|
| 168 |
+
return
|
| 169 |
+
|
| 170 |
+
st.subheader(f"Location: {session_state.building_info['country']}, {session_state.building_info['city']}")
|
| 171 |
+
tab1, tab2 = st.tabs(["Manual Input", "Upload EPW File"])
|
| 172 |
+
|
| 173 |
+
# Manual Input Tab
|
| 174 |
+
with tab1:
|
| 175 |
+
with st.form("manual_climate_form"):
|
| 176 |
+
col1, col2 = st.columns(2)
|
| 177 |
+
with col1:
|
| 178 |
+
latitude = st.number_input(
|
| 179 |
+
"Latitude",
|
| 180 |
+
min_value=-90.0,
|
| 181 |
+
max_value=90.0,
|
| 182 |
+
value=0.0,
|
| 183 |
+
step=0.1,
|
| 184 |
+
help="Enter the latitude of the location in degrees (e.g., 64.1 for Reykjavik)"
|
| 185 |
+
)
|
| 186 |
+
longitude = st.number_input(
|
| 187 |
+
"Longitude",
|
| 188 |
+
min_value=-180.0,
|
| 189 |
+
max_value=180.0,
|
| 190 |
+
value=0.0,
|
| 191 |
+
step=0.1,
|
| 192 |
+
help="Enter the longitude of the location in degrees (e.g., -21.9 for Reykjavik)"
|
| 193 |
+
)
|
| 194 |
+
elevation = st.number_input(
|
| 195 |
+
"Elevation (m)",
|
| 196 |
+
min_value=0.0,
|
| 197 |
+
value=0.0,
|
| 198 |
+
step=10.0,
|
| 199 |
+
help="Enter the elevation of the location above sea level in meters"
|
| 200 |
+
)
|
| 201 |
+
climate_zone = st.selectbox(
|
| 202 |
+
"Climate Zone",
|
| 203 |
+
["0A", "0B", "1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"],
|
| 204 |
+
help="Select the ASHRAE climate zone for the location (e.g., 6A for cold, humid climates)"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
with col2:
|
| 208 |
+
hdd = st.number_input(
|
| 209 |
+
"Heating Degree Days (base 18°C)",
|
| 210 |
+
min_value=0.0,
|
| 211 |
+
value=0.0,
|
| 212 |
+
step=100.0,
|
| 213 |
+
help="Enter the annual heating degree days using an 18°C base temperature"
|
| 214 |
+
)
|
| 215 |
+
cdd = st.number_input(
|
| 216 |
+
"Cooling Degree Days (base 18°C)",
|
| 217 |
+
min_value=0.0,
|
| 218 |
+
value=0.0,
|
| 219 |
+
step=100.0,
|
| 220 |
+
help="Enter the annual cooling degree days using an 18°C base temperature"
|
| 221 |
+
)
|
| 222 |
+
winter_design_temp = st.number_input(
|
| 223 |
+
"Winter Design Temp (99.6%) (°C)",
|
| 224 |
+
min_value=-50.0,
|
| 225 |
+
max_value=20.0,
|
| 226 |
+
value=0.0,
|
| 227 |
+
step=0.5,
|
| 228 |
+
help="Enter the 99.6% winter design temperature in °C (extreme cold condition)"
|
| 229 |
+
)
|
| 230 |
+
summer_design_temp_db = st.number_input(
|
| 231 |
+
"Summer Design Temp DB (0.4%) (°C)",
|
| 232 |
+
min_value=0.0,
|
| 233 |
+
max_value=50.0,
|
| 234 |
+
value=35.0,
|
| 235 |
+
step=0.5,
|
| 236 |
+
help="Enter the 0.4% summer design dry-bulb temperature in °C (extreme hot condition)"
|
| 237 |
+
)
|
| 238 |
+
summer_design_temp_wb = st.number_input(
|
| 239 |
+
"Summer Design Temp WB (0.4%) (°C)",
|
| 240 |
+
min_value=0.0,
|
| 241 |
+
max_value=40.0,
|
| 242 |
+
value=25.0,
|
| 243 |
+
step=0.5,
|
| 244 |
+
help="Enter the 0.4% summer design wet-bulb temperature in °C (for humidity consideration)"
|
| 245 |
+
)
|
| 246 |
+
summer_daily_range = st.number_input(
|
| 247 |
+
"Summer Daily Range (°C)",
|
| 248 |
+
min_value=0.0,
|
| 249 |
+
value=5.0,
|
| 250 |
+
step=0.5,
|
| 251 |
+
help="Enter the average daily temperature range in summer in °C"
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
# Monthly Data with clear titles (no help added here)
|
| 255 |
+
monthly_temps = {}
|
| 256 |
+
monthly_humidity = {}
|
| 257 |
+
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
| 258 |
+
|
| 259 |
+
st.subheader("Monthly Temperatures")
|
| 260 |
+
col1, col2 = st.columns(2)
|
| 261 |
+
with col1:
|
| 262 |
+
for month in month_names[:6]:
|
| 263 |
+
monthly_temps[month] = st.number_input(f"{month} Temp (°C)", min_value=-50.0, max_value=50.0, value=20.0, step=0.5, key=f"temp_{month}")
|
| 264 |
+
with col2:
|
| 265 |
+
for month in month_names[6:]:
|
| 266 |
+
monthly_temps[month] = st.number_input(f"{month} Temp (°C)", min_value=-50.0, max_value=50.0, value=20.0, step=0.5, key=f"temp_{month}")
|
| 267 |
+
|
| 268 |
+
st.subheader("Monthly Humidity")
|
| 269 |
+
col1, col2 = st.columns(2)
|
| 270 |
+
with col1:
|
| 271 |
+
for month in month_names[:6]:
|
| 272 |
+
monthly_humidity[month] = st.number_input(f"{month} Humidity (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key=f"hum_{month}")
|
| 273 |
+
with col2:
|
| 274 |
+
for month in month_names[6:]:
|
| 275 |
+
monthly_humidity[month] = st.number_input(f"{month} Humidity (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key=f"hum_{month}")
|
| 276 |
+
|
| 277 |
+
if st.form_submit_button("Save Climate Data"):
|
| 278 |
+
try:
|
| 279 |
+
# Generate ID internally using country and city from session_state
|
| 280 |
+
generated_id = f"{session_state.building_info['country'][:2].upper()}-{session_state.building_info['city'][:3].upper()}"
|
| 281 |
+
location = ClimateLocation(
|
| 282 |
+
id=generated_id,
|
| 283 |
+
country=session_state.building_info["country"],
|
| 284 |
+
state_province="N/A", # Default since input removed
|
| 285 |
+
city=session_state.building_info["city"],
|
| 286 |
+
latitude=latitude,
|
| 287 |
+
longitude=longitude,
|
| 288 |
+
elevation=elevation,
|
| 289 |
+
climate_zone=climate_zone,
|
| 290 |
+
heating_degree_days=hdd,
|
| 291 |
+
cooling_degree_days=cdd,
|
| 292 |
+
winter_design_temp=winter_design_temp,
|
| 293 |
+
summer_design_temp_db=summer_design_temp_db,
|
| 294 |
+
summer_design_temp_wb=summer_design_temp_wb,
|
| 295 |
+
summer_daily_range=summer_daily_range,
|
| 296 |
+
monthly_temps=monthly_temps,
|
| 297 |
+
monthly_humidity=monthly_humidity
|
| 298 |
+
)
|
| 299 |
+
self.add_location(location)
|
| 300 |
+
climate_data_dict = location.to_dict()
|
| 301 |
+
if not self.validate_climate_data(climate_data_dict):
|
| 302 |
+
raise ValueError("Invalid climate data. Please check all inputs.")
|
| 303 |
+
session_state["climate_data"] = climate_data_dict # Save to session state
|
| 304 |
+
st.success("Climate data saved manually!")
|
| 305 |
+
st.write(f"Debug: Saved climate data for {location.city} (ID: {location.id}): {climate_data_dict}") # Debug
|
| 306 |
+
self.display_design_conditions(location)
|
| 307 |
+
self.visualize_data(location, epw_data=None)
|
| 308 |
+
except Exception as e:
|
| 309 |
+
st.error(f"Error saving climate data: {str(e)}. Please check inputs and try again.")
|
| 310 |
+
|
| 311 |
+
# EPW Upload Tab
|
| 312 |
+
with tab2:
|
| 313 |
+
uploaded_file = st.file_uploader("Upload EPW File", type=["epw"])
|
| 314 |
+
if uploaded_file:
|
| 315 |
+
try:
|
| 316 |
+
epw_content = uploaded_file.read().decode("utf-8")
|
| 317 |
+
epw_lines = epw_content.splitlines()
|
| 318 |
+
header = next(line for line in epw_lines if line.startswith("LOCATION"))
|
| 319 |
+
header_parts = header.split(",")
|
| 320 |
+
latitude = float(header_parts[6])
|
| 321 |
+
longitude = float(header_parts[7])
|
| 322 |
+
elevation = float(header_parts[8])
|
| 323 |
+
|
| 324 |
+
data_start_idx = next(i for i, line in enumerate(epw_lines) if line.startswith("DATA PERIODS")) + 1
|
| 325 |
+
epw_data = pd.read_csv(StringIO("\n".join(epw_lines[data_start_idx:])), header=None, dtype=str)
|
| 326 |
+
if len(epw_data) != 8760:
|
| 327 |
+
raise ValueError(f"EPW file has {len(epw_data)} records, expected 8760.")
|
| 328 |
+
|
| 329 |
+
for col in epw_data.columns:
|
| 330 |
+
epw_data[col] = pd.to_numeric(epw_data[col], errors='coerce')
|
| 331 |
+
|
| 332 |
+
months = epw_data[1].values # Month
|
| 333 |
+
dry_bulb = epw_data[6].values # Dry-bulb temperature (°C)
|
| 334 |
+
humidity = epw_data[8].values # Relative humidity (%)
|
| 335 |
+
pressure = epw_data[9].values # Atmospheric pressure (Pa)
|
| 336 |
+
|
| 337 |
+
wet_bulb = self.calculate_wet_bulb(dry_bulb, humidity)
|
| 338 |
+
|
| 339 |
+
if np.all(np.isnan(dry_bulb)) or np.all(np.isnan(humidity)) or np.all(np.isnan(wet_bulb)):
|
| 340 |
+
raise ValueError("Dry bulb, humidity, or calculated wet bulb data is entirely NaN.")
|
| 341 |
+
|
| 342 |
+
daily_temps = np.nanmean(dry_bulb.reshape(-1, 24), axis=1)
|
| 343 |
+
hdd = round(np.nansum(np.maximum(18 - daily_temps, 0)))
|
| 344 |
+
cdd = round(np.nansum(np.maximum(daily_temps - 18, 0)))
|
| 345 |
+
|
| 346 |
+
winter_design_temp = round(np.nanpercentile(dry_bulb, 0.4), 1)
|
| 347 |
+
summer_design_temp_db = round(np.nanpercentile(dry_bulb, 99.6), 1)
|
| 348 |
+
summer_design_temp_wb = round(np.nanpercentile(wet_bulb, 99.6), 1)
|
| 349 |
+
summer_mask = (months >= 6) & (months <= 8)
|
| 350 |
+
summer_temps = dry_bulb[summer_mask].reshape(-1, 24)
|
| 351 |
+
summer_daily_range = round(np.nanmean(np.nanmax(summer_temps, axis=1) - np.nanmin(summer_temps, axis=1)), 1)
|
| 352 |
+
|
| 353 |
+
monthly_temps = {}
|
| 354 |
+
monthly_humidity = {}
|
| 355 |
+
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
| 356 |
+
for i in range(1, 13):
|
| 357 |
+
month_mask = (months == i)
|
| 358 |
+
monthly_temps[month_names[i-1]] = round(np.nanmean(dry_bulb[month_mask]), 1)
|
| 359 |
+
monthly_humidity[month_names[i-1]] = round(np.nanmean(humidity[month_mask]), 1)
|
| 360 |
+
|
| 361 |
+
avg_humidity = np.nanmean(humidity)
|
| 362 |
+
climate_zone = self.assign_climate_zone(hdd, cdd, avg_humidity)
|
| 363 |
+
|
| 364 |
+
location = ClimateLocation(
|
| 365 |
+
id=f"{session_state.building_info['country'][:2].upper()}-{session_state.building_info['city'][:3].upper()}",
|
| 366 |
+
country=session_state.building_info["country"],
|
| 367 |
+
state_province="N/A",
|
| 368 |
+
city=session_state.building_info["city"],
|
| 369 |
+
latitude=latitude,
|
| 370 |
+
longitude=longitude,
|
| 371 |
+
elevation=elevation,
|
| 372 |
+
climate_zone=climate_zone,
|
| 373 |
+
heating_degree_days=hdd,
|
| 374 |
+
cooling_degree_days=cdd,
|
| 375 |
+
winter_design_temp=winter_design_temp,
|
| 376 |
+
summer_design_temp_db=summer_design_temp_db,
|
| 377 |
+
summer_design_temp_wb=summer_design_temp_wb,
|
| 378 |
+
summer_daily_range=summer_daily_range,
|
| 379 |
+
monthly_temps=monthly_temps,
|
| 380 |
+
monthly_humidity=monthly_humidity
|
| 381 |
+
)
|
| 382 |
+
self.add_location(location)
|
| 383 |
+
climate_data_dict = location.to_dict()
|
| 384 |
+
if not self.validate_climate_data(climate_data_dict):
|
| 385 |
+
raise ValueError("Invalid climate data extracted from EPW file.")
|
| 386 |
+
session_state["climate_data"] = climate_data_dict # Save to session state
|
| 387 |
+
st.success("Climate data extracted from EPW file with calculated Wet Bulb Temperature!")
|
| 388 |
+
st.write(f"Debug: Saved climate data for {location.city} (ID: {location.id}): {climate_data_dict}") # Debug
|
| 389 |
+
self.display_design_conditions(location)
|
| 390 |
+
self.visualize_data(location, epw_data=epw_data)
|
| 391 |
+
except Exception as e:
|
| 392 |
+
st.error(f"Error processing EPW file: {str(e)}. Ensure it has 8760 hourly records and correct format.")
|
| 393 |
+
|
| 394 |
+
col1, col2 = st.columns(2)
|
| 395 |
+
with col1:
|
| 396 |
+
st.button("Back to Building Information", on_click=lambda: setattr(session_state, "page", "Building Information"))
|
| 397 |
+
with col2:
|
| 398 |
+
if self.locations:
|
| 399 |
+
st.button("Continue to Building Components", on_click=lambda: setattr(session_state, "page", "Building Components"))
|
| 400 |
+
else:
|
| 401 |
+
st.button("Continue to Building Components", disabled=True)
|
| 402 |
+
|
| 403 |
+
# Display saved session state data (if any)
|
| 404 |
+
if "climate_data" in session_state and session_state["climate_data"]:
|
| 405 |
+
st.subheader("Saved Climate Data")
|
| 406 |
+
st.json(session_state["climate_data"]) # Display as JSON for clarity
|
| 407 |
+
|
| 408 |
+
def display_design_conditions(self, location: ClimateLocation):
|
| 409 |
+
"""Display a table of design conditions including additional parameters for HVAC calculations."""
|
| 410 |
+
st.subheader("Design Conditions for HVAC Calculations")
|
| 411 |
+
|
| 412 |
+
design_data = pd.DataFrame({
|
| 413 |
+
"Parameter": [
|
| 414 |
+
"Latitude",
|
| 415 |
+
"Longitude",
|
| 416 |
+
"Elevation (m)",
|
| 417 |
+
"Climate Zone",
|
| 418 |
+
"Heating Degree Days (base 18°C)",
|
| 419 |
+
"Cooling Degree Days (base 18°C)",
|
| 420 |
+
"Winter Design Temperature (99.6%)",
|
| 421 |
+
"Summer Design Dry-Bulb Temp (0.4%)",
|
| 422 |
+
"Summer Design Wet-Bulb Temp (0.4%)",
|
| 423 |
+
"Summer Daily Temperature Range"
|
| 424 |
+
],
|
| 425 |
+
"Value": [
|
| 426 |
+
f"{location.latitude}°",
|
| 427 |
+
f"{location.longitude}°",
|
| 428 |
+
f"{location.elevation} m",
|
| 429 |
+
location.climate_zone,
|
| 430 |
+
f"{location.heating_degree_days} HDD",
|
| 431 |
+
f"{location.cooling_degree_days} CDD",
|
| 432 |
+
f"{location.winter_design_temp} °C",
|
| 433 |
+
f"{location.summer_design_temp_db} °C",
|
| 434 |
+
f"{location.summer_design_temp_wb} °C",
|
| 435 |
+
f"{location.summer_daily_range} °C"
|
| 436 |
+
]
|
| 437 |
+
})
|
| 438 |
+
|
| 439 |
+
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
| 440 |
+
monthly_temp_data = pd.DataFrame({
|
| 441 |
+
"Parameter": [f"{month} Avg Temp" for month in month_names],
|
| 442 |
+
"Value": [f"{location.monthly_temps[month]} °C" for month in month_names]
|
| 443 |
+
})
|
| 444 |
+
|
| 445 |
+
monthly_humidity_data = pd.DataFrame({
|
| 446 |
+
"Parameter": [f"{month} Avg Humidity" for month in month_names],
|
| 447 |
+
"Value": [f"{location.monthly_humidity[month]} %" for month in month_names]
|
| 448 |
+
})
|
| 449 |
+
|
| 450 |
+
full_design_data = pd.concat([design_data, monthly_temp_data, monthly_humidity_data], ignore_index=True)
|
| 451 |
+
st.table(full_design_data)
|
| 452 |
+
|
| 453 |
+
@staticmethod
|
| 454 |
+
def assign_climate_zone(hdd: float, cdd: float, avg_humidity: float) -> str:
|
| 455 |
+
"""Assign ASHRAE 169 climate zone based on HDD, CDD, and humidity."""
|
| 456 |
+
if cdd > 10000:
|
| 457 |
+
return "0A" if avg_humidity > 60 else "0B"
|
| 458 |
+
elif cdd > 5000:
|
| 459 |
+
return "1A" if avg_humidity > 60 else "1B"
|
| 460 |
+
elif cdd > 2500:
|
| 461 |
+
return "2A" if avg_humidity > 60 else "2B"
|
| 462 |
+
elif hdd < 2000 and cdd > 1000:
|
| 463 |
+
return "3A" if avg_humidity > 60 else "3B" if avg_humidity < 40 else "3C"
|
| 464 |
+
elif hdd < 3000:
|
| 465 |
+
return "4A" if avg_humidity > 60 else "4B" if avg_humidity < 40 else "4C"
|
| 466 |
+
elif hdd < 4000:
|
| 467 |
+
return "5A" if avg_humidity > 60 else "5B" if avg_humidity < 40 else "5C"
|
| 468 |
+
elif hdd < 5000:
|
| 469 |
+
return "6A" if avg_humidity > 60 else "6B"
|
| 470 |
+
elif hdd < 7000:
|
| 471 |
+
return "7"
|
| 472 |
+
else:
|
| 473 |
+
return "8"
|
| 474 |
+
|
| 475 |
+
@staticmethod
|
| 476 |
+
def visualize_data(location: ClimateLocation, epw_data: Optional[pd.DataFrame] = None):
|
| 477 |
+
"""Visualize monthly temperature and humidity data."""
|
| 478 |
+
st.subheader("Monthly Climate Data Visualization")
|
| 479 |
+
|
| 480 |
+
months = list(range(1, 13))
|
| 481 |
+
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
| 482 |
+
temps_avg = [location.monthly_temps[m] for m in month_names]
|
| 483 |
+
humidity_avg = [location.monthly_humidity[m] for m in month_names]
|
| 484 |
+
|
| 485 |
+
fig_temp = go.Figure()
|
| 486 |
+
fig_temp.add_trace(go.Scatter(
|
| 487 |
+
x=months,
|
| 488 |
+
y=temps_avg,
|
| 489 |
+
mode='lines+markers',
|
| 490 |
+
name='Avg Temperature (°C)',
|
| 491 |
+
line=dict(color='red'),
|
| 492 |
+
marker=dict(size=8)
|
| 493 |
+
))
|
| 494 |
+
|
| 495 |
+
if epw_data is not None:
|
| 496 |
+
dry_bulb = epw_data[6].values
|
| 497 |
+
month_col = epw_data[1].values
|
| 498 |
+
temps_min = []
|
| 499 |
+
temps_max = []
|
| 500 |
+
for i in range(1, 13):
|
| 501 |
+
month_mask = (month_col == i)
|
| 502 |
+
temps_min.append(round(np.nanmin(dry_bulb[month_mask]), 1))
|
| 503 |
+
temps_max.append(round(np.nanmax(dry_bulb[month_mask]), 1))
|
| 504 |
+
fig_temp.add_trace(go.Scatter(
|
| 505 |
+
x=months,
|
| 506 |
+
y=temps_max,
|
| 507 |
+
mode='lines',
|
| 508 |
+
name='Max Temperature (°C)',
|
| 509 |
+
line=dict(color='red', dash='dash'),
|
| 510 |
+
opacity=0.5
|
| 511 |
+
))
|
| 512 |
+
fig_temp.add_trace(go.Scatter(
|
| 513 |
+
x=months,
|
| 514 |
+
y=temps_min,
|
| 515 |
+
mode='lines',
|
| 516 |
+
name='Min Temperature (°C)',
|
| 517 |
+
line=dict(color='red', dash='dash'),
|
| 518 |
+
opacity=0.5,
|
| 519 |
+
fill='tonexty',
|
| 520 |
+
fillcolor='rgba(255, 0, 0, 0.1)'
|
| 521 |
+
))
|
| 522 |
+
|
| 523 |
+
fig_temp.update_layout(
|
| 524 |
+
title='Monthly Temperatures',
|
| 525 |
+
xaxis_title='Month',
|
| 526 |
+
yaxis_title='Temperature (°C)',
|
| 527 |
+
xaxis=dict(tickmode='array', tickvals=months, ticktext=month_names),
|
| 528 |
+
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)
|
| 529 |
+
)
|
| 530 |
+
st.plotly_chart(fig_temp, use_container_width=True)
|
| 531 |
+
|
| 532 |
+
fig_hum = go.Figure()
|
| 533 |
+
fig_hum.add_trace(go.Scatter(
|
| 534 |
+
x=months,
|
| 535 |
+
y=humidity_avg,
|
| 536 |
+
mode='lines+markers',
|
| 537 |
+
name='Avg Humidity (%)',
|
| 538 |
+
line=dict(color='blue'),
|
| 539 |
+
marker=dict(size=8)
|
| 540 |
+
))
|
| 541 |
+
|
| 542 |
+
if epw_data is not None:
|
| 543 |
+
humidity = epw_data[8].values
|
| 544 |
+
month_col = epw_data[1].values
|
| 545 |
+
humidity_min = []
|
| 546 |
+
humidity_max = []
|
| 547 |
+
for i in range(1, 13):
|
| 548 |
+
month_mask = (month_col == i)
|
| 549 |
+
humidity_min.append(round(np.nanmin(humidity[month_mask]), 1))
|
| 550 |
+
humidity_max.append(round(np.nanmax(humidity[month_mask]), 1))
|
| 551 |
+
fig_hum.add_trace(go.Scatter(
|
| 552 |
+
x=months,
|
| 553 |
+
y=humidity_max,
|
| 554 |
+
mode='lines',
|
| 555 |
+
name='Max Humidity (%)',
|
| 556 |
+
line=dict(color='blue', dash='dash'),
|
| 557 |
+
opacity=0.5
|
| 558 |
+
))
|
| 559 |
+
fig_hum.add_trace(go.Scatter(
|
| 560 |
+
x=months,
|
| 561 |
+
y=humidity_min,
|
| 562 |
+
mode='lines',
|
| 563 |
+
name='Min Humidity (%)',
|
| 564 |
+
line=dict(color='blue', dash='dash'),
|
| 565 |
+
opacity=0.5,
|
| 566 |
+
fill='tonexty',
|
| 567 |
+
fillcolor='rgba(0, 0, 255, 0.1)'
|
| 568 |
+
))
|
| 569 |
+
|
| 570 |
+
fig_hum.update_layout(
|
| 571 |
+
title='Monthly Relative Humidity',
|
| 572 |
+
xaxis_title='Month',
|
| 573 |
+
yaxis_title='Relative Humidity (%)',
|
| 574 |
+
xaxis=dict(tickmode='array', tickvals=months, ticktext=month_names),
|
| 575 |
+
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)
|
| 576 |
+
)
|
| 577 |
+
st.plotly_chart(fig_hum, use_container_width=True)
|
| 578 |
+
|
| 579 |
+
def export_to_json(self, file_path: str) -> None:
|
| 580 |
+
"""Export all climate data to a JSON file."""
|
| 581 |
+
data = {loc_id: loc.to_dict() for loc_id, loc in self.locations.items()}
|
| 582 |
+
with open(file_path, 'w') as f:
|
| 583 |
+
json.dump(data, f, indent=4)
|
| 584 |
+
|
| 585 |
+
@classmethod
|
| 586 |
+
def from_json(cls, file_path: str) -> 'ClimateData':
|
| 587 |
+
"""Load climate data from a JSON file."""
|
| 588 |
+
with open(file_path, 'r') as f:
|
| 589 |
+
data = json.load(f)
|
| 590 |
+
climate_data = cls()
|
| 591 |
+
for loc_id, loc_dict in data.items():
|
| 592 |
+
location = ClimateLocation(**loc_dict)
|
| 593 |
+
climate_data.add_location(location)
|
| 594 |
+
return climate_data
|
| 595 |
+
|
| 596 |
+
if __name__ == "__main__":
|
| 597 |
+
climate_data = ClimateData()
|
| 598 |
+
session_state = {"building_info": {"country": "Iceland", "city": "Reyugalvik"}, "page": "Climate Data"}
|
| 599 |
+
climate_data.display_climate_input(session_state)
|
data/drapery.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Drapery module for HVAC Load Calculator.
|
| 3 |
+
This module provides classes and functions for handling drapery properties
|
| 4 |
+
and calculating their effects on window heat transfer.
|
| 5 |
+
|
| 6 |
+
Based on ASHRAE principles for drapery thermal characteristics.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Dict, Any, Optional, Tuple
|
| 10 |
+
from enum import Enum
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class DraperyOpenness(Enum):
|
| 14 |
+
"""Enum for drapery openness classification."""
|
| 15 |
+
OPEN = "Open (>25%)"
|
| 16 |
+
SEMI_OPEN = "Semi-open (7-25%)"
|
| 17 |
+
CLOSED = "Closed (0-7%)"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class DraperyColor(Enum):
|
| 21 |
+
"""Enum for drapery color/reflectance classification."""
|
| 22 |
+
DARK = "Dark (0-25%)"
|
| 23 |
+
MEDIUM = "Medium (25-50%)"
|
| 24 |
+
LIGHT = "Light (>50%)"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Drapery:
|
| 28 |
+
"""Class for drapery properties and calculations."""
|
| 29 |
+
|
| 30 |
+
def __init__(self,
|
| 31 |
+
openness: float = 0.05,
|
| 32 |
+
reflectance: float = 0.5,
|
| 33 |
+
transmittance: float = 0.3,
|
| 34 |
+
fullness: float = 1.0,
|
| 35 |
+
enabled: bool = True):
|
| 36 |
+
"""
|
| 37 |
+
Initialize drapery object.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
openness: Openness factor (0-1), fraction of fabric area that is open
|
| 41 |
+
reflectance: Reflectance factor (0-1), fraction of incident radiation reflected
|
| 42 |
+
transmittance: Transmittance factor (0-1), fraction of incident radiation transmitted
|
| 43 |
+
fullness: Fullness factor (0-2), ratio of fabric width to covered width
|
| 44 |
+
enabled: Whether the drapery is enabled/present
|
| 45 |
+
"""
|
| 46 |
+
self.openness = max(0.0, min(1.0, openness))
|
| 47 |
+
self.reflectance = max(0.0, min(1.0, reflectance))
|
| 48 |
+
self.transmittance = max(0.0, min(1.0, transmittance))
|
| 49 |
+
self.fullness = max(0.0, min(2.0, fullness))
|
| 50 |
+
self.enabled = enabled
|
| 51 |
+
|
| 52 |
+
# Calculate derived properties
|
| 53 |
+
self.absorptance = 1.0 - self.reflectance - self.transmittance
|
| 54 |
+
|
| 55 |
+
# Classify drapery based on openness and reflectance
|
| 56 |
+
self.openness_class = self._classify_openness(self.openness)
|
| 57 |
+
self.color_class = self._classify_color(self.reflectance)
|
| 58 |
+
|
| 59 |
+
@staticmethod
|
| 60 |
+
def _classify_openness(openness: float) -> DraperyOpenness:
|
| 61 |
+
"""Classify drapery based on openness factor."""
|
| 62 |
+
if openness > 0.25:
|
| 63 |
+
return DraperyOpenness.OPEN
|
| 64 |
+
elif openness > 0.07:
|
| 65 |
+
return DraperyOpenness.SEMI_OPEN
|
| 66 |
+
else:
|
| 67 |
+
return DraperyOpenness.CLOSED
|
| 68 |
+
|
| 69 |
+
@staticmethod
|
| 70 |
+
def _classify_color(reflectance: float) -> DraperyColor:
|
| 71 |
+
"""Classify drapery based on reflectance factor."""
|
| 72 |
+
if reflectance > 0.5:
|
| 73 |
+
return DraperyColor.LIGHT
|
| 74 |
+
elif reflectance > 0.25:
|
| 75 |
+
return DraperyColor.MEDIUM
|
| 76 |
+
else:
|
| 77 |
+
return DraperyColor.DARK
|
| 78 |
+
|
| 79 |
+
def get_classification(self) -> str:
|
| 80 |
+
"""Get drapery classification string."""
|
| 81 |
+
openness_map = {
|
| 82 |
+
DraperyOpenness.OPEN: "I",
|
| 83 |
+
DraperyOpenness.SEMI_OPEN: "II",
|
| 84 |
+
DraperyOpenness.CLOSED: "III"
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
color_map = {
|
| 88 |
+
DraperyColor.DARK: "D",
|
| 89 |
+
DraperyColor.MEDIUM: "M",
|
| 90 |
+
DraperyColor.LIGHT: "L"
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return f"{openness_map[self.openness_class]}{color_map[self.color_class]}"
|
| 94 |
+
|
| 95 |
+
def calculate_iac(self, glazing_shgc: float = 0.87) -> float:
|
| 96 |
+
"""
|
| 97 |
+
Calculate Interior Attenuation Coefficient (IAC) for the drapery.
|
| 98 |
+
|
| 99 |
+
The IAC represents the fraction of heat flow that enters the room
|
| 100 |
+
after being modified by the drapery.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
glazing_shgc: Solar Heat Gain Coefficient of the glazing (default: 0.87 for clear glass)
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
IAC value (0-1)
|
| 107 |
+
"""
|
| 108 |
+
if not self.enabled:
|
| 109 |
+
return 1.0 # No attenuation if drapery is not enabled
|
| 110 |
+
|
| 111 |
+
# Calculate base IAC for flat drapery (no fullness)
|
| 112 |
+
# This is based on the principles from the Keyes Universal Chart
|
| 113 |
+
# and ASHRAE's IAC calculation methods
|
| 114 |
+
|
| 115 |
+
# Calculate yarn reflectance (based on openness and reflectance)
|
| 116 |
+
if self.openness < 0.0001: # Prevent division by zero
|
| 117 |
+
yarn_reflectance = self.reflectance
|
| 118 |
+
else:
|
| 119 |
+
yarn_reflectance = self.reflectance / (1.0 - self.openness)
|
| 120 |
+
yarn_reflectance = min(1.0, yarn_reflectance) # Cap at 1.0
|
| 121 |
+
|
| 122 |
+
# Base IAC calculation using fabric properties
|
| 123 |
+
# This is a simplified version of the ASHWAT model calculations
|
| 124 |
+
base_iac = 1.0 - (1.0 - self.openness) * (1.0 - self.transmittance / (1.0 - self.openness)) * yarn_reflectance
|
| 125 |
+
|
| 126 |
+
# Adjust for fullness
|
| 127 |
+
# Fullness creates multiple reflections between adjacent fabric surfaces
|
| 128 |
+
if self.fullness <= 0.0:
|
| 129 |
+
fullness_factor = 1.0
|
| 130 |
+
else:
|
| 131 |
+
# Fullness effect increases with higher fullness values
|
| 132 |
+
# More fullness means more fabric area and more reflections
|
| 133 |
+
fullness_factor = 1.0 - 0.15 * (self.fullness / 2.0)
|
| 134 |
+
|
| 135 |
+
# Apply fullness adjustment
|
| 136 |
+
adjusted_iac = base_iac * fullness_factor
|
| 137 |
+
|
| 138 |
+
# Ensure IAC is within valid range
|
| 139 |
+
adjusted_iac = max(0.1, min(1.0, adjusted_iac))
|
| 140 |
+
|
| 141 |
+
return adjusted_iac
|
| 142 |
+
|
| 143 |
+
def calculate_shading_coefficient(self, glazing_shgc: float = 0.87) -> float:
|
| 144 |
+
"""
|
| 145 |
+
Calculate shading coefficient for the drapery.
|
| 146 |
+
|
| 147 |
+
The shading coefficient is the ratio of solar heat gain through
|
| 148 |
+
the window with the drapery to that of standard clear glass.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
glazing_shgc: Solar Heat Gain Coefficient of the glazing (default: 0.87 for clear glass)
|
| 152 |
+
|
| 153 |
+
Returns:
|
| 154 |
+
Shading coefficient (0-1)
|
| 155 |
+
"""
|
| 156 |
+
# Calculate IAC
|
| 157 |
+
iac = self.calculate_iac(glazing_shgc)
|
| 158 |
+
|
| 159 |
+
# Calculate shading coefficient
|
| 160 |
+
# SC = IAC * SHGC / 0.87
|
| 161 |
+
shading_coefficient = iac * glazing_shgc / 0.87
|
| 162 |
+
|
| 163 |
+
return shading_coefficient
|
| 164 |
+
|
| 165 |
+
def calculate_u_value_adjustment(self, base_u_value: float) -> float:
|
| 166 |
+
"""
|
| 167 |
+
Calculate U-value adjustment for the drapery.
|
| 168 |
+
|
| 169 |
+
The drapery adds thermal resistance to the window assembly,
|
| 170 |
+
reducing the overall U-value.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
base_u_value: Base U-value of the window without drapery (W/m²K)
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Adjusted U-value (W/m²K)
|
| 177 |
+
"""
|
| 178 |
+
if not self.enabled:
|
| 179 |
+
return base_u_value # No adjustment if drapery is not enabled
|
| 180 |
+
|
| 181 |
+
# Calculate additional thermal resistance based on drapery properties
|
| 182 |
+
# This is a simplified approach based on ASHRAE principles
|
| 183 |
+
|
| 184 |
+
# Base resistance from drapery
|
| 185 |
+
# More closed fabrics provide more resistance
|
| 186 |
+
base_resistance = 0.05 # m²K/W, typical for medium-weight drapery
|
| 187 |
+
|
| 188 |
+
# Adjust for openness (more closed = more resistance)
|
| 189 |
+
openness_factor = 1.0 - self.openness
|
| 190 |
+
|
| 191 |
+
# Adjust for fullness (more fullness = more resistance due to air gaps)
|
| 192 |
+
fullness_factor = 1.0 + 0.25 * self.fullness
|
| 193 |
+
|
| 194 |
+
# Calculate total additional resistance
|
| 195 |
+
additional_resistance = base_resistance * openness_factor * fullness_factor
|
| 196 |
+
|
| 197 |
+
# Convert base U-value to resistance
|
| 198 |
+
base_resistance = 1.0 / base_u_value
|
| 199 |
+
|
| 200 |
+
# Add drapery resistance
|
| 201 |
+
total_resistance = base_resistance + additional_resistance
|
| 202 |
+
|
| 203 |
+
# Convert back to U-value
|
| 204 |
+
adjusted_u_value = 1.0 / total_resistance
|
| 205 |
+
|
| 206 |
+
return adjusted_u_value
|
| 207 |
+
|
| 208 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 209 |
+
"""Convert drapery object to dictionary."""
|
| 210 |
+
return {
|
| 211 |
+
"openness": self.openness,
|
| 212 |
+
"reflectance": self.reflectance,
|
| 213 |
+
"transmittance": self.transmittance,
|
| 214 |
+
"fullness": self.fullness,
|
| 215 |
+
"enabled": self.enabled,
|
| 216 |
+
"absorptance": self.absorptance,
|
| 217 |
+
"openness_class": self.openness_class.value,
|
| 218 |
+
"color_class": self.color_class.value,
|
| 219 |
+
"classification": self.get_classification()
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
@classmethod
|
| 223 |
+
def from_dict(cls, data: Dict[str, Any]) -> 'Drapery':
|
| 224 |
+
"""Create drapery object from dictionary."""
|
| 225 |
+
return cls(
|
| 226 |
+
openness=data.get("openness", 0.05),
|
| 227 |
+
reflectance=data.get("reflectance", 0.5),
|
| 228 |
+
transmittance=data.get("transmittance", 0.3),
|
| 229 |
+
fullness=data.get("fullness", 1.0),
|
| 230 |
+
enabled=data.get("enabled", True)
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
@classmethod
|
| 234 |
+
def from_classification(cls, classification: str, fullness: float = 1.0) -> 'Drapery':
|
| 235 |
+
"""
|
| 236 |
+
Create drapery object from ASHRAE classification.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
classification: ASHRAE classification (ID, IM, IL, IID, IIM, IIL, IIID, IIIM, IIIL)
|
| 240 |
+
fullness: Fullness factor (0-2)
|
| 241 |
+
|
| 242 |
+
Returns:
|
| 243 |
+
Drapery object
|
| 244 |
+
"""
|
| 245 |
+
# Parse classification
|
| 246 |
+
if len(classification) < 2:
|
| 247 |
+
raise ValueError(f"Invalid classification: {classification}")
|
| 248 |
+
|
| 249 |
+
# Handle single-character openness class (I) vs two-character (II, III)
|
| 250 |
+
if classification.startswith("II") or classification.startswith("III"):
|
| 251 |
+
if classification.startswith("III"):
|
| 252 |
+
openness_class = "III"
|
| 253 |
+
color_class = classification[3] if len(classification) > 3 else ""
|
| 254 |
+
else: # II
|
| 255 |
+
openness_class = "II"
|
| 256 |
+
color_class = classification[2] if len(classification) > 2 else ""
|
| 257 |
+
else: # I
|
| 258 |
+
openness_class = "I"
|
| 259 |
+
color_class = classification[1] if len(classification) > 1 else ""
|
| 260 |
+
|
| 261 |
+
# Set default values
|
| 262 |
+
openness = 0.05
|
| 263 |
+
reflectance = 0.5
|
| 264 |
+
transmittance = 0.3
|
| 265 |
+
|
| 266 |
+
# Set openness based on class
|
| 267 |
+
if openness_class == "I":
|
| 268 |
+
openness = 0.3 # Open (>25%)
|
| 269 |
+
elif openness_class == "II":
|
| 270 |
+
openness = 0.15 # Semi-open (7-25%)
|
| 271 |
+
elif openness_class == "III":
|
| 272 |
+
openness = 0.03 # Closed (0-7%)
|
| 273 |
+
|
| 274 |
+
# Set reflectance and transmittance based on color class
|
| 275 |
+
if color_class == "D":
|
| 276 |
+
reflectance = 0.2 # Dark (0-25%)
|
| 277 |
+
transmittance = 0.05
|
| 278 |
+
elif color_class == "M":
|
| 279 |
+
reflectance = 0.4 # Medium (25-50%)
|
| 280 |
+
transmittance = 0.15
|
| 281 |
+
elif color_class == "L":
|
| 282 |
+
reflectance = 0.7 # Light (>50%)
|
| 283 |
+
transmittance = 0.2
|
| 284 |
+
|
| 285 |
+
return cls(
|
| 286 |
+
openness=openness,
|
| 287 |
+
reflectance=reflectance,
|
| 288 |
+
transmittance=transmittance,
|
| 289 |
+
fullness=fullness
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# Predefined drapery types based on ASHRAE classifications
|
| 294 |
+
PREDEFINED_DRAPERIES = {
|
| 295 |
+
"ID": Drapery.from_classification("ID"),
|
| 296 |
+
"IM": Drapery.from_classification("IM"),
|
| 297 |
+
"IL": Drapery.from_classification("IL"),
|
| 298 |
+
"IID": Drapery.from_classification("IID"),
|
| 299 |
+
"IIM": Drapery.from_classification("IIM"),
|
| 300 |
+
"IIL": Drapery.from_classification("IIL"),
|
| 301 |
+
"IIID": Drapery.from_classification("IIID"),
|
| 302 |
+
"IIIM": Drapery.from_classification("IIIM"),
|
| 303 |
+
"IIIL": Drapery.from_classification("IIIL")
|
| 304 |
+
}
|
data/reference_data.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
import logging
|
| 11 |
+
from uuid import uuid4
|
| 12 |
+
|
| 13 |
+
# Configure logging
|
| 14 |
+
logging.basicConfig(level=logging.INFO)
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# Define paths
|
| 18 |
+
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 19 |
+
DEFAULT_DATA_FILE = os.path.join(DATA_DIR, "reference_data.json")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ReferenceData:
|
| 23 |
+
"""Class for managing reference data for the HVAC calculator."""
|
| 24 |
+
|
| 25 |
+
def __init__(self):
|
| 26 |
+
"""Initialize reference data structures."""
|
| 27 |
+
self.materials = {}
|
| 28 |
+
self.wall_types = {}
|
| 29 |
+
self.roof_types = {}
|
| 30 |
+
self.floor_types = {}
|
| 31 |
+
self.window_types = {}
|
| 32 |
+
self.door_types = {}
|
| 33 |
+
self.internal_loads = {}
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
self._load_all_data()
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.error(f"Error initializing reference data: {str(e)}")
|
| 39 |
+
raise
|
| 40 |
+
|
| 41 |
+
def _load_all_data(self) -> None:
|
| 42 |
+
"""Load all reference data, attempting to load from JSON first."""
|
| 43 |
+
try:
|
| 44 |
+
if os.path.exists(DEFAULT_DATA_FILE):
|
| 45 |
+
self._load_from_json(DEFAULT_DATA_FILE)
|
| 46 |
+
else:
|
| 47 |
+
self._load_default_data()
|
| 48 |
+
self.export_to_json(DEFAULT_DATA_FILE)
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.error(f"Error loading reference data: {str(e)}")
|
| 51 |
+
self._load_default_data() # Fallback to default data
|
| 52 |
+
|
| 53 |
+
def _load_default_data(self) -> None:
|
| 54 |
+
"""Load default reference data."""
|
| 55 |
+
self.materials = self._load_materials()
|
| 56 |
+
self.wall_types = self._load_wall_types()
|
| 57 |
+
self.roof_types = self._load_roof_types()
|
| 58 |
+
self.floor_types = self._load_floor_types()
|
| 59 |
+
self.window_types = self._load_window_types()
|
| 60 |
+
self.door_types = self._load_door_types()
|
| 61 |
+
self.internal_loads = self._load_internal_loads()
|
| 62 |
+
|
| 63 |
+
def _load_materials(self) -> Dict[str, Dict[str, Any]]:
|
| 64 |
+
"""
|
| 65 |
+
Load material properties.
|
| 66 |
+
Returns:
|
| 67 |
+
Dictionary of material properties
|
| 68 |
+
"""
|
| 69 |
+
return {
|
| 70 |
+
"brick": {
|
| 71 |
+
"id": str(uuid4()),
|
| 72 |
+
"name": "Common Brick",
|
| 73 |
+
"conductivity": 0.72, # W/(m·K)
|
| 74 |
+
"density": 1920, # kg/m³
|
| 75 |
+
"specific_heat": 840, # J/(kg·K)
|
| 76 |
+
"typical_thickness": 0.1, # m
|
| 77 |
+
"emissivity": 0.9,
|
| 78 |
+
"solar_absorptance": 0.7
|
| 79 |
+
},
|
| 80 |
+
"concrete": {
|
| 81 |
+
"id": str(uuid4()),
|
| 82 |
+
"name": "Concrete",
|
| 83 |
+
"conductivity": 1.4, # W/(m·K)
|
| 84 |
+
"density": 2300, # kg/m³
|
| 85 |
+
"specific_heat": 880, # J/(kg·K)
|
| 86 |
+
"typical_thickness": 0.2, # m
|
| 87 |
+
"emissivity": 0.92,
|
| 88 |
+
"solar_absorptance": 0.65
|
| 89 |
+
},
|
| 90 |
+
"mineral_wool": {
|
| 91 |
+
"id": str(uuid4()),
|
| 92 |
+
"name": "Mineral Wool Insulation",
|
| 93 |
+
"conductivity": 0.04, # W/(m·K)
|
| 94 |
+
"density": 30, # kg/m³
|
| 95 |
+
"specific_heat": 840, # J/(kg·K)
|
| 96 |
+
"typical_thickness": 0.1, # m
|
| 97 |
+
"emissivity": 0.9,
|
| 98 |
+
"solar_absorptance": 0.6
|
| 99 |
+
},
|
| 100 |
+
# Additional materials
|
| 101 |
+
"polyurethane_foam": {
|
| 102 |
+
"id": str(uuid4()),
|
| 103 |
+
"name": "Polyurethane Foam",
|
| 104 |
+
"conductivity": 0.025, # W/(m·K)
|
| 105 |
+
"density": 40, # kg/m³
|
| 106 |
+
"specific_heat": 1500, # J/(kg·K)
|
| 107 |
+
"typical_thickness": 0.05, # m
|
| 108 |
+
"emissivity": 0.9,
|
| 109 |
+
"solar_absorptance": 0.6
|
| 110 |
+
},
|
| 111 |
+
"fiberglass_insulation": {
|
| 112 |
+
"id": str(uuid4()),
|
| 113 |
+
"name": "Fiberglass Insulation",
|
| 114 |
+
"conductivity": 0.045, # W/(m·K)
|
| 115 |
+
"density": 12, # kg/m³
|
| 116 |
+
"specific_heat": 850, # J/(kg·K)
|
| 117 |
+
"typical_thickness": 0.15, # m
|
| 118 |
+
"emissivity": 0.9,
|
| 119 |
+
"solar_absorptance": 0.6
|
| 120 |
+
},
|
| 121 |
+
"stucco": {
|
| 122 |
+
"id": str(uuid4()),
|
| 123 |
+
"name": "Stucco",
|
| 124 |
+
"conductivity": 0.7, # W/(m·K)
|
| 125 |
+
"density": 1850, # kg/m³
|
| 126 |
+
"specific_heat": 900, # J/(kg·K)
|
| 127 |
+
"typical_thickness": 0.025, # m
|
| 128 |
+
"emissivity": 0.92,
|
| 129 |
+
"solar_absorptance": 0.5
|
| 130 |
+
}
|
| 131 |
+
# Add more materials as needed
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
def _load_wall_types(self) -> Dict[str, Dict[str, Any]]:
|
| 135 |
+
"""
|
| 136 |
+
Load predefined wall types.
|
| 137 |
+
Returns:
|
| 138 |
+
Dictionary of wall types with properties
|
| 139 |
+
"""
|
| 140 |
+
return {
|
| 141 |
+
"brick_veneer_wood_frame": {
|
| 142 |
+
"id": str(uuid4()),
|
| 143 |
+
"name": "Brick Veneer with Wood Frame",
|
| 144 |
+
"description": "Brick veneer with wood frame, insulation, and gypsum board",
|
| 145 |
+
"u_value": 0.35, # W/(m²·K)
|
| 146 |
+
"wall_group": "B",
|
| 147 |
+
"layers": [
|
| 148 |
+
{"material": "brick", "thickness": 0.1},
|
| 149 |
+
{"material": "air_gap", "thickness": 0.025},
|
| 150 |
+
{"material": "wood", "thickness": 0.038},
|
| 151 |
+
{"material": "mineral_wool", "thickness": 0.089},
|
| 152 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 153 |
+
],
|
| 154 |
+
"thermal_mass": 180, # kg/m²
|
| 155 |
+
"color": "Medium"
|
| 156 |
+
},
|
| 157 |
+
"insulated_concrete_form": {
|
| 158 |
+
"id": str(uuid4()),
|
| 159 |
+
"name": "Insulated Concrete Form",
|
| 160 |
+
"description": "ICF with EPS insulation and concrete core",
|
| 161 |
+
"u_value": 0.25, # W/(m²·K)
|
| 162 |
+
"wall_group": "C",
|
| 163 |
+
"layers": [
|
| 164 |
+
{"material": "eps_insulation", "thickness": 0.05},
|
| 165 |
+
{"material": "concrete", "thickness": 0.15},
|
| 166 |
+
{"material": "eps_insulation", "thickness": 0.05},
|
| 167 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 168 |
+
],
|
| 169 |
+
"thermal_mass": 220, # kg/m²
|
| 170 |
+
"color": "Light"
|
| 171 |
+
},
|
| 172 |
+
# Additional wall types
|
| 173 |
+
"sip_panel": {
|
| 174 |
+
"id": str(uuid4()),
|
| 175 |
+
"name": "Structural Insulated Panel",
|
| 176 |
+
"description": "SIP with OSB and EPS core",
|
| 177 |
+
"u_value": 0.28, # W/(m²·K)
|
| 178 |
+
"wall_group": "A",
|
| 179 |
+
"layers": [
|
| 180 |
+
{"material": "wood", "thickness": 0.012},
|
| 181 |
+
{"material": "eps_insulation", "thickness": 0.15},
|
| 182 |
+
{"material": "wood", "thickness": 0.012},
|
| 183 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 184 |
+
],
|
| 185 |
+
"thermal_mass": 80, # kg/m²
|
| 186 |
+
"color": "Light"
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
def _load_roof_types(self) -> Dict[str, Dict[str, Any]]:
|
| 191 |
+
"""
|
| 192 |
+
Load predefined roof types.
|
| 193 |
+
Returns:
|
| 194 |
+
Dictionary of roof types with properties
|
| 195 |
+
"""
|
| 196 |
+
return {
|
| 197 |
+
"flat_roof_concrete": {
|
| 198 |
+
"id": str(uuid4()),
|
| 199 |
+
"name": "Flat Concrete Roof with Insulation",
|
| 200 |
+
"description": "Flat concrete roof with insulation and ceiling",
|
| 201 |
+
"u_value": 0.25, # W/(m²·K)
|
| 202 |
+
"roof_group": "B",
|
| 203 |
+
"layers": [
|
| 204 |
+
{"material": "concrete", "thickness": 0.15},
|
| 205 |
+
{"material": "eps_insulation", "thickness": 0.15},
|
| 206 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 207 |
+
],
|
| 208 |
+
"solar_absorptance": 0.7,
|
| 209 |
+
"emissivity": 0.9
|
| 210 |
+
},
|
| 211 |
+
"green_roof": {
|
| 212 |
+
"id": str(uuid4()),
|
| 213 |
+
"name": "Green Roof",
|
| 214 |
+
"description": "Vegetated roof with insulation and drainage",
|
| 215 |
+
"u_value": 0.22, # W/(m²·K)
|
| 216 |
+
"roof_group": "A",
|
| 217 |
+
"layers": [
|
| 218 |
+
{"material": "soil", "thickness": 0.1},
|
| 219 |
+
{"material": "eps_insulation", "thickness": 0.1},
|
| 220 |
+
{"material": "concrete", "thickness": 0.1},
|
| 221 |
+
{"material": "gypsum_board", "thickness": 0.0125}
|
| 222 |
+
],
|
| 223 |
+
"solar_absorptance": 0.5,
|
| 224 |
+
"emissivity": 0.95
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
def _load_floor_types(self) -> Dict[str, Dict[str, Any]]:
|
| 229 |
+
"""
|
| 230 |
+
Load predefined floor types.
|
| 231 |
+
Returns:
|
| 232 |
+
Dictionary of floor types with properties
|
| 233 |
+
"""
|
| 234 |
+
return {
|
| 235 |
+
"concrete_slab_on_grade": {
|
| 236 |
+
"id": str(uuid4()),
|
| 237 |
+
"name": "Concrete Slab on Grade",
|
| 238 |
+
"description": "Concrete slab on grade with insulation",
|
| 239 |
+
"u_value": 0.3, # W/(m²·K)
|
| 240 |
+
"is_ground_contact": True,
|
| 241 |
+
"layers": [
|
| 242 |
+
{"material": "concrete", "thickness": 0.1},
|
| 243 |
+
{"material": "eps_insulation", "thickness": 0.05}
|
| 244 |
+
],
|
| 245 |
+
"thermal_mass": 230 # kg/m²
|
| 246 |
+
},
|
| 247 |
+
"radiant_floor": {
|
| 248 |
+
"id": str(uuid4()),
|
| 249 |
+
"name": "Radiant Floor",
|
| 250 |
+
"description": "Concrete floor with radiant heating and insulation",
|
| 251 |
+
"u_value": 0.27, # W/(m²·K)
|
| 252 |
+
"is_ground_contact": True,
|
| 253 |
+
"layers": [
|
| 254 |
+
{"material": "tile", "thickness": 0.015},
|
| 255 |
+
{"material": "concrete", "thickness": 0.1},
|
| 256 |
+
{"material": "eps_insulation", "thickness": 0.075}
|
| 257 |
+
],
|
| 258 |
+
"thermal_mass": 240 # kg/m²
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
def _load_window_types(self) -> Dict[str, Dict[str, Any]]:
|
| 263 |
+
"""
|
| 264 |
+
Load predefined window types.
|
| 265 |
+
Returns:
|
| 266 |
+
Dictionary of window types with properties
|
| 267 |
+
"""
|
| 268 |
+
return {
|
| 269 |
+
"double_glazed_argon_low_e": {
|
| 270 |
+
"id": str(uuid4()),
|
| 271 |
+
"name": "Double Glazed with Argon and Low-E",
|
| 272 |
+
"description": "Double glazed window with argon fill, low-e coating, and vinyl frame",
|
| 273 |
+
"u_value": 1.4, # W/(m²·K)
|
| 274 |
+
"shgc": 0.4,
|
| 275 |
+
"vt": 0.7,
|
| 276 |
+
"glazing_layers": 2,
|
| 277 |
+
"gas_fill": "Argon",
|
| 278 |
+
"frame_type": "Vinyl",
|
| 279 |
+
"low_e_coating": True,
|
| 280 |
+
"frame_factor": 0.15
|
| 281 |
+
},
|
| 282 |
+
"electrochromic_window": {
|
| 283 |
+
"id": str(uuid4()),
|
| 284 |
+
"name": "Electrochromic Window",
|
| 285 |
+
"description": "Smart window with dynamic tinting",
|
| 286 |
+
"u_value": 1.2, # W/(m²·K)
|
| 287 |
+
"shgc": 0.35,
|
| 288 |
+
"vt": 0.65,
|
| 289 |
+
"glazing_layers": 2,
|
| 290 |
+
"gas_fill": "Argon",
|
| 291 |
+
"frame_type": "Fiberglass",
|
| 292 |
+
"low_e_coating": True,
|
| 293 |
+
"frame_factor": 0.12
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
def _load_door_types(self) -> Dict[str, Dict[str, Any]]:
|
| 298 |
+
"""
|
| 299 |
+
Load predefined door types.
|
| 300 |
+
Returns:
|
| 301 |
+
Dictionary of door types with properties
|
| 302 |
+
"""
|
| 303 |
+
return {
|
| 304 |
+
"insulated_steel_door": {
|
| 305 |
+
"id": str(uuid4()),
|
| 306 |
+
"name": "Insulated Steel Door",
|
| 307 |
+
"description": "Insulated steel door with no glazing",
|
| 308 |
+
"u_value": 1.2, # W/(m²·K)
|
| 309 |
+
"glazing_percentage": 0,
|
| 310 |
+
"door_type": "Solid",
|
| 311 |
+
"thermal_mass": 50 # kg/m²
|
| 312 |
+
},
|
| 313 |
+
"insulated_fiberglass_door": {
|
| 314 |
+
"id": str(uuid4()),
|
| 315 |
+
"name": "Insulated Fiberglass Door",
|
| 316 |
+
"description": "Insulated fiberglass door with small glazing",
|
| 317 |
+
"u_value": 1.5, # W/(m²·K)
|
| 318 |
+
"glazing_percentage": 10,
|
| 319 |
+
"door_type": "Partially glazed",
|
| 320 |
+
"shgc": 0.7,
|
| 321 |
+
"vt": 0.8,
|
| 322 |
+
"thermal_mass": 40 # kg/m²
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
def _load_internal_loads(self) -> Dict[str, Dict[str, Any]]:
|
| 327 |
+
"""
|
| 328 |
+
Load internal load data.
|
| 329 |
+
Returns:
|
| 330 |
+
Dictionary of internal load types with properties
|
| 331 |
+
"""
|
| 332 |
+
return {
|
| 333 |
+
"occupancy": {
|
| 334 |
+
"office_typing": {
|
| 335 |
+
"id": str(uuid4()),
|
| 336 |
+
"name": "Office Typing",
|
| 337 |
+
"sensible_heat": 75, # W per person
|
| 338 |
+
"latent_heat": 55, # W per person
|
| 339 |
+
"metabolic_rate": 1.2 # met
|
| 340 |
+
},
|
| 341 |
+
"retail_sales": {
|
| 342 |
+
"id": str(uuid4()),
|
| 343 |
+
"name": "Retail Sales",
|
| 344 |
+
"sensible_heat": 80, # W per person
|
| 345 |
+
"latent_heat": 70, # W per person
|
| 346 |
+
"metabolic_rate": 1.4 # met
|
| 347 |
+
}
|
| 348 |
+
},
|
| 349 |
+
"lighting": {
|
| 350 |
+
"led_high_efficiency": {
|
| 351 |
+
"id": str(uuid4()),
|
| 352 |
+
"name": "High Efficiency LED",
|
| 353 |
+
"power_density_range": [4, 8], # W/m²
|
| 354 |
+
"heat_to_space": 0.85,
|
| 355 |
+
"efficacy": 120 # lm/W
|
| 356 |
+
}
|
| 357 |
+
},
|
| 358 |
+
"equipment": {
|
| 359 |
+
"computer_workstation": {
|
| 360 |
+
"id": str(uuid4()),
|
| 361 |
+
"name": "Computer Workstation",
|
| 362 |
+
"power_density_range": [15, 25], # W/m²
|
| 363 |
+
"sensible_fraction": 0.95,
|
| 364 |
+
"latent_fraction": 0.05
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
def _load_from_json(self, file_path: str) -> None:
|
| 370 |
+
"""
|
| 371 |
+
Load reference data from JSON file.
|
| 372 |
+
Args:
|
| 373 |
+
file_path: Path to JSON file
|
| 374 |
+
"""
|
| 375 |
+
try:
|
| 376 |
+
with open(file_path, 'r') as f:
|
| 377 |
+
data = json.load(f)
|
| 378 |
+
self.materials = data.get("materials", self.materials)
|
| 379 |
+
self.wall_types = data.get("wall_types", self.wall_types)
|
| 380 |
+
self.roof_types = data.get("roof_types", self.roof_types)
|
| 381 |
+
self.floor_types = data.get("floor_types", self.floor_types)
|
| 382 |
+
self.window_types = data.get("window_types", self.window_types)
|
| 383 |
+
self.door_types = data.get("door_types", self.door_types)
|
| 384 |
+
self.internal_loads = data.get("internal_loads", self.internal_loads)
|
| 385 |
+
logger.info(f"Successfully loaded reference data from {file_path}")
|
| 386 |
+
except Exception as e:
|
| 387 |
+
logger.error(f"Error loading JSON data from {file_path}: {str(e)}")
|
| 388 |
+
raise
|
| 389 |
+
|
| 390 |
+
def export_to_json(self, file_path: str) -> None:
|
| 391 |
+
"""
|
| 392 |
+
Export all reference data to a JSON file.
|
| 393 |
+
Args:
|
| 394 |
+
file_path: Path to the output JSON file
|
| 395 |
+
"""
|
| 396 |
+
try:
|
| 397 |
+
data = {
|
| 398 |
+
"materials": self.materials,
|
| 399 |
+
"wall_types": self.wall_types,
|
| 400 |
+
"roof_types": self.roof_types,
|
| 401 |
+
"floor_types": self.floor_types,
|
| 402 |
+
"window_types": self.window_types,
|
| 403 |
+
"door_types": self.door_types,
|
| 404 |
+
"internal_loads": self.internal_loads
|
| 405 |
+
}
|
| 406 |
+
with open(file_path, 'w') as f:
|
| 407 |
+
json.dump(data, f, indent=4)
|
| 408 |
+
logger.info(f"Successfully exported reference data to {file_path}")
|
| 409 |
+
except Exception as e:
|
| 410 |
+
logger.error(f"Error exporting to JSON: {str(e)}")
|
| 411 |
+
raise
|
| 412 |
+
|
| 413 |
+
# Getter methods with validation
|
| 414 |
+
def get_material(self, material_id: str) -> Optional[Dict[str, Any]]:
|
| 415 |
+
return self.materials.get(material_id)
|
| 416 |
+
|
| 417 |
+
def get_wall_type(self, wall_type_id: str) -> Optional[Dict[str, Any]]:
|
| 418 |
+
return self.wall_types.get(wall_type_id)
|
| 419 |
+
|
| 420 |
+
def get_roof_type(self, roof_type_id: str) -> Optional[Dict[str, Any]]:
|
| 421 |
+
return self.roof_types.get(roof_type_id)
|
| 422 |
+
|
| 423 |
+
def get_floor_type(self, floor_type_id: str) -> Optional[Dict[str, Any]]:
|
| 424 |
+
return self.floor_types.get(floor_type_id)
|
| 425 |
+
|
| 426 |
+
def get_window_type(self, window_type_id: str) -> Optional[Dict[str, Any]]:
|
| 427 |
+
return self.window_types.get(window_type_id)
|
| 428 |
+
|
| 429 |
+
def get_door_type(self, door_type_id: str) -> Optional[Dict[str, Any]]:
|
| 430 |
+
return self.door_types.get(door_type_id)
|
| 431 |
+
|
| 432 |
+
def get_internal_load(self, load_type: str, load_id: str) -> Optional[Dict[str, Any]]:
|
| 433 |
+
return self.internal_loads.get(load_type, {}).get(load_id)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
# Singleton instance
|
| 437 |
+
try:
|
| 438 |
+
reference_data = ReferenceData()
|
| 439 |
+
except Exception as e:
|
| 440 |
+
logger.error(f"Failed to create ReferenceData instance: {str(e)}")
|
| 441 |
+
raise
|
| 442 |
+
|
| 443 |
+
if __name__ == "__main__":
|
| 444 |
+
try:
|
| 445 |
+
reference_data.export_to_json(DEFAULT_DATA_FILE)
|
| 446 |
+
except Exception as e:
|
| 447 |
+
logger.error(f"Error exporting default data: {str(e)}")
|
gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
requirements.txt
CHANGED
|
@@ -1,3 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
pandas
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit==1.28.0
|
| 2 |
+
pandas==2.0.3
|
| 3 |
+
numpy==1.24.3
|
| 4 |
+
plotly==5.18.0
|
| 5 |
+
matplotlib==3.7.2
|
| 6 |
+
openpyxl==3.1.2
|
| 7 |
+
xlsxwriter==3.1.2
|
| 8 |
+
pycountry==1.2
|
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=1.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=1.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=1.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=1.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=1.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,1029 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cooling load calculation module for HVAC Load Calculator.
|
| 3 |
+
Implements ASHRAE steady-state methods with Cooling Load Temperature Difference (CLTD).
|
| 4 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
|
| 5 |
+
|
| 6 |
+
Author: Dr Majed Abuseif
|
| 7 |
+
Date: April 2025
|
| 8 |
+
Version: 1.0.6
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 12 |
+
import numpy as np
|
| 13 |
+
import logging
|
| 14 |
+
from data.ashrae_tables import ASHRAETables
|
| 15 |
+
from utils.heat_transfer import HeatTransferCalculations
|
| 16 |
+
from utils.psychrometrics import Psychrometrics
|
| 17 |
+
from app.component_selection import Wall, Roof, Window, Door, Orientation
|
| 18 |
+
|
| 19 |
+
# Set up logging
|
| 20 |
+
logging.basicConfig(level=logging.INFO)
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
class CoolingLoadCalculator:
|
| 24 |
+
"""Class for cooling load calculations based on ASHRAE steady-state methods."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, debug_mode: bool = False):
|
| 27 |
+
"""
|
| 28 |
+
Initialize cooling load calculator.
|
| 29 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
debug_mode: Enable debug logging if True
|
| 33 |
+
"""
|
| 34 |
+
self.ashrae_tables = ASHRAETables()
|
| 35 |
+
self.heat_transfer = HeatTransferCalculations()
|
| 36 |
+
self.psychrometrics = Psychrometrics()
|
| 37 |
+
self.hours = list(range(24))
|
| 38 |
+
self.valid_latitudes = ['24N', '32N', '40N', '48N', '56N']
|
| 39 |
+
self.valid_months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
|
| 40 |
+
self.valid_wall_groups = ['A', 'B', 'C', 'D']
|
| 41 |
+
self.valid_roof_groups = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
|
| 42 |
+
self.debug_mode = debug_mode
|
| 43 |
+
if debug_mode:
|
| 44 |
+
logger.setLevel(logging.DEBUG)
|
| 45 |
+
|
| 46 |
+
def validate_latitude(self, latitude: Any) -> str:
|
| 47 |
+
"""
|
| 48 |
+
Validate and normalize latitude input.
|
| 49 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.2.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
latitude: Latitude input (str, float, or other)
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Valid latitude string ('24N', '32N', '40N', '48N', '56N')
|
| 56 |
+
"""
|
| 57 |
+
try:
|
| 58 |
+
if not isinstance(latitude, str):
|
| 59 |
+
try:
|
| 60 |
+
lat_val = float(latitude)
|
| 61 |
+
if lat_val <= 28:
|
| 62 |
+
return '24N'
|
| 63 |
+
elif lat_val <= 36:
|
| 64 |
+
return '32N'
|
| 65 |
+
elif lat_val <= 44:
|
| 66 |
+
return '40N'
|
| 67 |
+
elif lat_val <= 52:
|
| 68 |
+
return '48N'
|
| 69 |
+
else:
|
| 70 |
+
return '56N'
|
| 71 |
+
except (ValueError, TypeError):
|
| 72 |
+
latitude = str(latitude)
|
| 73 |
+
|
| 74 |
+
latitude = latitude.strip().upper()
|
| 75 |
+
if self.debug_mode:
|
| 76 |
+
logger.debug(f"Validating latitude: {latitude}")
|
| 77 |
+
|
| 78 |
+
if '_' in latitude:
|
| 79 |
+
parts = latitude.split('_')
|
| 80 |
+
if len(parts) > 1:
|
| 81 |
+
lat_part = parts[0]
|
| 82 |
+
if self.debug_mode:
|
| 83 |
+
logger.warning(f"Detected concatenated input: {latitude}. Using latitude={lat_part}")
|
| 84 |
+
latitude = lat_part
|
| 85 |
+
|
| 86 |
+
if '.' in latitude or any(c.isdigit() for c in latitude):
|
| 87 |
+
num_part = ''.join(c for c in latitude if c.isdigit() or c == '.')
|
| 88 |
+
try:
|
| 89 |
+
lat_val = float(num_part)
|
| 90 |
+
if lat_val <= 28:
|
| 91 |
+
mapped_latitude = '24N'
|
| 92 |
+
elif lat_val <= 36:
|
| 93 |
+
mapped_latitude = '32N'
|
| 94 |
+
elif lat_val <= 44:
|
| 95 |
+
mapped_latitude = '40N'
|
| 96 |
+
elif lat_val <= 52:
|
| 97 |
+
mapped_latitude = '48N'
|
| 98 |
+
else:
|
| 99 |
+
mapped_latitude = '56N'
|
| 100 |
+
if self.debug_mode:
|
| 101 |
+
logger.debug(f"Mapped numerical latitude {lat_val} to {mapped_latitude}")
|
| 102 |
+
return mapped_latitude
|
| 103 |
+
except ValueError:
|
| 104 |
+
if self.debug_mode:
|
| 105 |
+
logger.warning(f"Cannot parse numerical latitude: {latitude}. Defaulting to '32N'")
|
| 106 |
+
return '32N'
|
| 107 |
+
|
| 108 |
+
if latitude in self.valid_latitudes:
|
| 109 |
+
return latitude
|
| 110 |
+
|
| 111 |
+
if self.debug_mode:
|
| 112 |
+
logger.warning(f"Invalid latitude: {latitude}. Defaulting to '32N'")
|
| 113 |
+
return '32N'
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
if self.debug_mode:
|
| 117 |
+
logger.error(f"Error validating latitude {latitude}: {str(e)}")
|
| 118 |
+
return '32N'
|
| 119 |
+
|
| 120 |
+
def validate_month(self, month: Any) -> str:
|
| 121 |
+
"""
|
| 122 |
+
Validate and normalize month input.
|
| 123 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.2.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
month: Month input (str or other)
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
Valid month string in uppercase
|
| 130 |
+
"""
|
| 131 |
+
try:
|
| 132 |
+
if not isinstance(month, str):
|
| 133 |
+
month = str(month)
|
| 134 |
+
|
| 135 |
+
month_upper = month.strip().upper()
|
| 136 |
+
if month_upper not in self.valid_months:
|
| 137 |
+
if self.debug_mode:
|
| 138 |
+
logger.warning(f"Invalid month: {month}. Defaulting to 'JUL'")
|
| 139 |
+
return 'JUL'
|
| 140 |
+
return month_upper
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
if self.debug_mode:
|
| 144 |
+
logger.error(f"Error validating month {month}: {str(e)}")
|
| 145 |
+
return 'JUL'
|
| 146 |
+
|
| 147 |
+
def validate_hour(self, hour: Any) -> int:
|
| 148 |
+
"""
|
| 149 |
+
Validate and normalize hour input.
|
| 150 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.2.
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
hour: Hour input (int, float, or other)
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
Valid hour integer (0-23)
|
| 157 |
+
"""
|
| 158 |
+
try:
|
| 159 |
+
hour = int(float(str(hour)))
|
| 160 |
+
if not 0 <= hour <= 23:
|
| 161 |
+
if self.debug_mode:
|
| 162 |
+
logger.warning(f"Invalid hour: {hour}. Defaulting to 15")
|
| 163 |
+
return 15
|
| 164 |
+
return hour
|
| 165 |
+
except (ValueError, TypeError):
|
| 166 |
+
if self.debug_mode:
|
| 167 |
+
logger.warning(f"Invalid hour format: {hour}. Defaulting to 15")
|
| 168 |
+
return 15
|
| 169 |
+
|
| 170 |
+
def validate_conditions(self, outdoor_temp: float, indoor_temp: float,
|
| 171 |
+
outdoor_rh: float, indoor_rh: float) -> None:
|
| 172 |
+
"""
|
| 173 |
+
Validate temperature and relative humidity inputs.
|
| 174 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.2.
|
| 175 |
+
|
| 176 |
+
Args:
|
| 177 |
+
outdoor_temp: Outdoor temperature in °C
|
| 178 |
+
indoor_temp: Indoor temperature in °C
|
| 179 |
+
outdoor_rh: Outdoor relative humidity in %
|
| 180 |
+
indoor_rh: Indoor relative humidity in %
|
| 181 |
+
|
| 182 |
+
Raises:
|
| 183 |
+
ValueError: If inputs are invalid
|
| 184 |
+
"""
|
| 185 |
+
if not -50 <= outdoor_temp <= 60 or not -50 <= indoor_temp <= 60:
|
| 186 |
+
raise ValueError("Temperatures must be between -50°C and 60°C")
|
| 187 |
+
if not 0 <= outdoor_rh <= 100 or not 0 <= indoor_rh <= 100:
|
| 188 |
+
raise ValueError("Relative humidities must be between 0 and 100%")
|
| 189 |
+
if outdoor_temp - indoor_temp < 1:
|
| 190 |
+
raise ValueError("Outdoor temperature must be at least 1°C above indoor temperature for cooling")
|
| 191 |
+
|
| 192 |
+
def calculate_hourly_cooling_loads(
|
| 193 |
+
self,
|
| 194 |
+
building_components: Dict[str, List[Any]],
|
| 195 |
+
outdoor_conditions: Dict[str, Any],
|
| 196 |
+
indoor_conditions: Dict[str, Any],
|
| 197 |
+
internal_loads: Dict[str, Any],
|
| 198 |
+
building_volume: float,
|
| 199 |
+
p_atm: float = 101325
|
| 200 |
+
) -> Dict[str, Any]:
|
| 201 |
+
"""
|
| 202 |
+
Calculate hourly cooling loads for all components.
|
| 203 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
building_components: Dictionary of building components
|
| 207 |
+
outdoor_conditions: Outdoor weather conditions (temperature, relative_humidity, latitude, month)
|
| 208 |
+
indoor_conditions: Indoor design conditions (temperature, relative_humidity)
|
| 209 |
+
internal_loads: Internal heat gains (people, lights, equipment, infiltration, ventilation)
|
| 210 |
+
building_volume: Building volume in cubic meters
|
| 211 |
+
p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
Dictionary containing hourly cooling loads
|
| 215 |
+
"""
|
| 216 |
+
hourly_loads = {
|
| 217 |
+
'walls': {h: 0.0 for h in range(1, 25)},
|
| 218 |
+
'roofs': {h: 0.0 for h in range(1, 25)},
|
| 219 |
+
'windows_conduction': {h: 0.0 for h in range(1, 25)},
|
| 220 |
+
'windows_solar': {h: 0.0 for h in range(1, 25)},
|
| 221 |
+
'doors': {h: 0.0 for h in range(1, 25)},
|
| 222 |
+
'people_sensible': {h: 0.0 for h in range(1, 25)},
|
| 223 |
+
'people_latent': {h: 0.0 for h in range(1, 25)},
|
| 224 |
+
'lights': {h: 0.0 for h in range(1, 25)},
|
| 225 |
+
'equipment_sensible': {h: 0.0 for h in range(1, 25)},
|
| 226 |
+
'equipment_latent': {h: 0.0 for h in range(1, 25)},
|
| 227 |
+
'infiltration_sensible': {h: 0.0 for h in range(1, 25)},
|
| 228 |
+
'infiltration_latent': {h: 0.0 for h in range(1, 25)},
|
| 229 |
+
'ventilation_sensible': {h: 0.0 for h in range(1, 25)},
|
| 230 |
+
'ventilation_latent': {h: 0.0 for h in range(1, 25)}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
try:
|
| 234 |
+
# Validate conditions
|
| 235 |
+
self.validate_conditions(
|
| 236 |
+
outdoor_conditions['temperature'],
|
| 237 |
+
indoor_conditions['temperature'],
|
| 238 |
+
outdoor_conditions.get('relative_humidity', 50.0),
|
| 239 |
+
indoor_conditions.get('relative_humidity', 50.0)
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
latitude = self.validate_latitude(outdoor_conditions.get('latitude', '32N'))
|
| 243 |
+
month = self.validate_month(outdoor_conditions.get('month', 'JUL'))
|
| 244 |
+
if self.debug_mode:
|
| 245 |
+
logger.debug(f"calculate_hourly_cooling_loads: latitude={latitude}, month={month}, outdoor_conditions={outdoor_conditions}")
|
| 246 |
+
|
| 247 |
+
# Calculate loads for walls
|
| 248 |
+
for wall in building_components.get('walls', []):
|
| 249 |
+
for hour in range(24):
|
| 250 |
+
load = self.calculate_wall_cooling_load(
|
| 251 |
+
wall=wall,
|
| 252 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 253 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 254 |
+
month=month,
|
| 255 |
+
hour=hour,
|
| 256 |
+
latitude=latitude
|
| 257 |
+
)
|
| 258 |
+
hourly_loads['walls'][hour + 1] += load
|
| 259 |
+
|
| 260 |
+
# Calculate loads for roofs
|
| 261 |
+
for roof in building_components.get('roofs', []):
|
| 262 |
+
for hour in range(24):
|
| 263 |
+
load = self.calculate_roof_cooling_load(
|
| 264 |
+
roof=roof,
|
| 265 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 266 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 267 |
+
month=month,
|
| 268 |
+
hour=hour,
|
| 269 |
+
latitude=latitude
|
| 270 |
+
)
|
| 271 |
+
hourly_loads['roofs'][hour + 1] += load
|
| 272 |
+
|
| 273 |
+
# Calculate loads for windows
|
| 274 |
+
for window in building_components.get('windows', []):
|
| 275 |
+
for hour in range(24):
|
| 276 |
+
load_dict = self.calculate_window_cooling_load(
|
| 277 |
+
window=window,
|
| 278 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 279 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 280 |
+
month=month,
|
| 281 |
+
hour=hour,
|
| 282 |
+
latitude=latitude,
|
| 283 |
+
shading_coefficient=window.shading_coefficient
|
| 284 |
+
)
|
| 285 |
+
hourly_loads['windows_conduction'][hour + 1] += load_dict['conduction']
|
| 286 |
+
hourly_loads['windows_solar'][hour + 1] += load_dict['solar']
|
| 287 |
+
|
| 288 |
+
# Calculate loads for doors
|
| 289 |
+
for door in building_components.get('doors', []):
|
| 290 |
+
for hour in range(24):
|
| 291 |
+
load = self.calculate_door_cooling_load(
|
| 292 |
+
door=door,
|
| 293 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 294 |
+
indoor_temp=indoor_conditions['temperature']
|
| 295 |
+
)
|
| 296 |
+
hourly_loads['doors'][hour + 1] += load
|
| 297 |
+
|
| 298 |
+
# Calculate internal loads
|
| 299 |
+
for hour in range(24):
|
| 300 |
+
# People loads
|
| 301 |
+
people_load = self.calculate_people_cooling_load(
|
| 302 |
+
num_people=internal_loads['people']['number'],
|
| 303 |
+
activity_level=internal_loads['people']['activity_level'],
|
| 304 |
+
hour=hour
|
| 305 |
+
)
|
| 306 |
+
hourly_loads['people_sensible'][hour + 1] += people_load['sensible']
|
| 307 |
+
hourly_loads['people_latent'][hour + 1] += people_load['latent']
|
| 308 |
+
|
| 309 |
+
# Lighting loads
|
| 310 |
+
lights_load = self.calculate_lights_cooling_load(
|
| 311 |
+
power=internal_loads['lights']['power'],
|
| 312 |
+
use_factor=internal_loads['lights']['use_factor'],
|
| 313 |
+
special_allowance=internal_loads['lights']['special_allowance'],
|
| 314 |
+
hour=hour
|
| 315 |
+
)
|
| 316 |
+
hourly_loads['lights'][hour + 1] += lights_load
|
| 317 |
+
|
| 318 |
+
# Equipment loads
|
| 319 |
+
equipment_load = self.calculate_equipment_cooling_load(
|
| 320 |
+
power=internal_loads['equipment']['power'],
|
| 321 |
+
use_factor=internal_loads['equipment']['use_factor'],
|
| 322 |
+
radiation_factor=internal_loads['equipment']['radiation_factor'],
|
| 323 |
+
hour=hour
|
| 324 |
+
)
|
| 325 |
+
hourly_loads['equipment_sensible'][hour + 1] += equipment_load['sensible']
|
| 326 |
+
hourly_loads['equipment_latent'][hour + 1] += equipment_load['latent']
|
| 327 |
+
|
| 328 |
+
# Infiltration loads
|
| 329 |
+
infiltration_load = self.calculate_infiltration_cooling_load(
|
| 330 |
+
flow_rate=internal_loads['infiltration']['flow_rate'],
|
| 331 |
+
building_volume=building_volume,
|
| 332 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 333 |
+
outdoor_rh=outdoor_conditions['relative_humidity'],
|
| 334 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 335 |
+
indoor_rh=indoor_conditions['relative_humidity'],
|
| 336 |
+
p_atm=p_atm
|
| 337 |
+
)
|
| 338 |
+
hourly_loads['infiltration_sensible'][hour + 1] += infiltration_load['sensible']
|
| 339 |
+
hourly_loads['infiltration_latent'][hour + 1] += infiltration_load['latent']
|
| 340 |
+
|
| 341 |
+
# Ventilation loads
|
| 342 |
+
ventilation_load = self.calculate_ventilation_cooling_load(
|
| 343 |
+
flow_rate=internal_loads['ventilation']['flow_rate'],
|
| 344 |
+
outdoor_temp=outdoor_conditions['temperature'],
|
| 345 |
+
outdoor_rh=outdoor_conditions['relative_humidity'],
|
| 346 |
+
indoor_temp=indoor_conditions['temperature'],
|
| 347 |
+
indoor_rh=indoor_conditions['relative_humidity'],
|
| 348 |
+
p_atm=p_atm
|
| 349 |
+
)
|
| 350 |
+
hourly_loads['ventilation_sensible'][hour + 1] += ventilation_load['sensible']
|
| 351 |
+
hourly_loads['ventilation_latent'][hour + 1] += ventilation_load['latent']
|
| 352 |
+
|
| 353 |
+
return hourly_loads
|
| 354 |
+
|
| 355 |
+
except Exception as e:
|
| 356 |
+
if self.debug_mode:
|
| 357 |
+
logger.error(f"Error in calculate_hourly_cooling_loads: {str(e)}")
|
| 358 |
+
raise Exception(f"Error in calculate_hourly_cooling_loads: {str(e)}")
|
| 359 |
+
|
| 360 |
+
def calculate_design_cooling_load(self, hourly_loads: Dict[str, Any]) -> Dict[str, Any]:
|
| 361 |
+
"""
|
| 362 |
+
Calculate design cooling load based on peak hourly loads.
|
| 363 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
|
| 364 |
+
|
| 365 |
+
Args:
|
| 366 |
+
hourly_loads: Dictionary of hourly cooling loads
|
| 367 |
+
|
| 368 |
+
Returns:
|
| 369 |
+
Dictionary containing design cooling loads
|
| 370 |
+
"""
|
| 371 |
+
try:
|
| 372 |
+
design_loads = {}
|
| 373 |
+
total_loads = []
|
| 374 |
+
|
| 375 |
+
for hour in range(1, 25):
|
| 376 |
+
total_load = sum([
|
| 377 |
+
hourly_loads['walls'][hour],
|
| 378 |
+
hourly_loads['roofs'][hour],
|
| 379 |
+
hourly_loads['windows_conduction'][hour],
|
| 380 |
+
hourly_loads['windows_solar'][hour],
|
| 381 |
+
hourly_loads['doors'][hour],
|
| 382 |
+
hourly_loads['people_sensible'][hour],
|
| 383 |
+
hourly_loads['people_latent'][hour],
|
| 384 |
+
hourly_loads['lights'][hour],
|
| 385 |
+
hourly_loads['equipment_sensible'][hour],
|
| 386 |
+
hourly_loads['equipment_latent'][hour],
|
| 387 |
+
hourly_loads['infiltration_sensible'][hour],
|
| 388 |
+
hourly_loads['infiltration_latent'][hour],
|
| 389 |
+
hourly_loads['ventilation_sensible'][hour],
|
| 390 |
+
hourly_loads['ventilation_latent'][hour]
|
| 391 |
+
])
|
| 392 |
+
total_loads.append(total_load)
|
| 393 |
+
|
| 394 |
+
design_hour = range(1, 25)[np.argmax(total_loads)]
|
| 395 |
+
|
| 396 |
+
design_loads = {
|
| 397 |
+
'design_hour': design_hour,
|
| 398 |
+
'walls': hourly_loads['walls'][design_hour],
|
| 399 |
+
'roofs': hourly_loads['roofs'][design_hour],
|
| 400 |
+
'windows_conduction': hourly_loads['windows_conduction'][design_hour],
|
| 401 |
+
'windows_solar': hourly_loads['windows_solar'][design_hour],
|
| 402 |
+
'doors': hourly_loads['doors'][design_hour],
|
| 403 |
+
'people_sensible': hourly_loads['people_sensible'][design_hour],
|
| 404 |
+
'people_latent': hourly_loads['people_latent'][design_hour],
|
| 405 |
+
'lights': hourly_loads['lights'][design_hour],
|
| 406 |
+
'equipment_sensible': hourly_loads['equipment_sensible'][design_hour],
|
| 407 |
+
'equipment_latent': hourly_loads['equipment_latent'][design_hour],
|
| 408 |
+
'infiltration_sensible': hourly_loads['infiltration_sensible'][design_hour],
|
| 409 |
+
'infiltration_latent': hourly_loads['infiltration_latent'][design_hour],
|
| 410 |
+
'ventilation_sensible': hourly_loads['ventilation_sensible'][design_hour],
|
| 411 |
+
'ventilation_latent': hourly_loads['ventilation_latent'][design_hour]
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
return design_loads
|
| 415 |
+
|
| 416 |
+
except Exception as e:
|
| 417 |
+
if self.debug_mode:
|
| 418 |
+
logger.error(f"Error in calculate_design_cooling_load: {str(e)}")
|
| 419 |
+
raise Exception(f"Error in calculate_design_cooling_load: {str(e)}")
|
| 420 |
+
|
| 421 |
+
def calculate_cooling_load_summary(self, design_loads: Dict[str, Any]) -> Dict[str, float]:
|
| 422 |
+
"""
|
| 423 |
+
Calculate summary of cooling loads.
|
| 424 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
|
| 425 |
+
|
| 426 |
+
Args:
|
| 427 |
+
design_loads: Dictionary of design cooling loads
|
| 428 |
+
|
| 429 |
+
Returns:
|
| 430 |
+
Dictionary containing cooling load summary
|
| 431 |
+
"""
|
| 432 |
+
try:
|
| 433 |
+
total_sensible = (
|
| 434 |
+
design_loads['walls'] +
|
| 435 |
+
design_loads['roofs'] +
|
| 436 |
+
design_loads['windows_conduction'] +
|
| 437 |
+
design_loads['windows_solar'] +
|
| 438 |
+
design_loads['doors'] +
|
| 439 |
+
design_loads['people_sensible'] +
|
| 440 |
+
design_loads['lights'] +
|
| 441 |
+
design_loads['equipment_sensible'] +
|
| 442 |
+
design_loads['infiltration_sensible'] +
|
| 443 |
+
design_loads['ventilation_sensible']
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
total_latent = (
|
| 447 |
+
design_loads['people_latent'] +
|
| 448 |
+
design_loads['equipment_latent'] +
|
| 449 |
+
design_loads['infiltration_latent'] +
|
| 450 |
+
design_loads['ventilation_latent']
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
total = total_sensible + total_latent
|
| 454 |
+
|
| 455 |
+
return {
|
| 456 |
+
'total_sensible': total_sensible,
|
| 457 |
+
'total_latent': total_latent,
|
| 458 |
+
'total': total
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
except Exception as e:
|
| 462 |
+
if self.debug_mode:
|
| 463 |
+
logger.error(f"Error in calculate_cooling_load_summary: {str(e)}")
|
| 464 |
+
raise Exception(f"Error in calculate_cooling_load_summary: {str(e)}")
|
| 465 |
+
|
| 466 |
+
def calculate_wall_cooling_load(
|
| 467 |
+
self,
|
| 468 |
+
wall: Wall,
|
| 469 |
+
outdoor_temp: float,
|
| 470 |
+
indoor_temp: float,
|
| 471 |
+
month: str,
|
| 472 |
+
hour: int,
|
| 473 |
+
latitude: str
|
| 474 |
+
) -> float:
|
| 475 |
+
"""
|
| 476 |
+
Calculate cooling load for a wall using CLTD method.
|
| 477 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.10.
|
| 478 |
+
|
| 479 |
+
Args:
|
| 480 |
+
wall: Wall component
|
| 481 |
+
outdoor_temp: Outdoor temperature (°C)
|
| 482 |
+
indoor_temp: Indoor temperature (°C)
|
| 483 |
+
month: Design month
|
| 484 |
+
hour: Hour of the day
|
| 485 |
+
latitude: Latitude (e.g., '24N')
|
| 486 |
+
|
| 487 |
+
Returns:
|
| 488 |
+
Cooling load in Watts
|
| 489 |
+
"""
|
| 490 |
+
try:
|
| 491 |
+
latitude = self.validate_latitude(latitude)
|
| 492 |
+
month = self.validate_month(month)
|
| 493 |
+
hour = self.validate_hour(hour)
|
| 494 |
+
if self.debug_mode:
|
| 495 |
+
logger.debug(f"calculate_wall_cooling_load: latitude={latitude}, month={month}, hour={hour}, wall_group={wall.wall_group}, orientation={wall.orientation.value}")
|
| 496 |
+
|
| 497 |
+
wall_group = str(wall.wall_group).upper()
|
| 498 |
+
if wall_group not in self.valid_wall_groups:
|
| 499 |
+
numeric_map = {'1': 'A', '2': 'B', '3': 'C', '4': 'D'}
|
| 500 |
+
if wall_group in numeric_map:
|
| 501 |
+
wall_group = numeric_map[wall_group]
|
| 502 |
+
if self.debug_mode:
|
| 503 |
+
logger.info(f"Mapped wall_group {wall.wall_group} to {wall_group}")
|
| 504 |
+
else:
|
| 505 |
+
if self.debug_mode:
|
| 506 |
+
logger.warning(f"Invalid wall group: {wall_group}. Defaulting to 'A'")
|
| 507 |
+
wall_group = 'A'
|
| 508 |
+
|
| 509 |
+
try:
|
| 510 |
+
cltd = self.ashrae_tables.calculate_corrected_cltd_wall(
|
| 511 |
+
wall_group=wall_group,
|
| 512 |
+
orientation=wall.orientation.value,
|
| 513 |
+
hour=hour,
|
| 514 |
+
color='Dark',
|
| 515 |
+
month=month,
|
| 516 |
+
latitude=latitude,
|
| 517 |
+
indoor_temp=indoor_temp,
|
| 518 |
+
outdoor_temp=outdoor_temp
|
| 519 |
+
)
|
| 520 |
+
except Exception as e:
|
| 521 |
+
if self.debug_mode:
|
| 522 |
+
logger.error(f"calculate_corrected_cltd_wall failed for wall_group={wall_group}: {str(e)}")
|
| 523 |
+
logger.warning("Using default CLTD=8.0°C")
|
| 524 |
+
cltd = 8.0
|
| 525 |
+
|
| 526 |
+
load = wall.u_value * wall.area * cltd
|
| 527 |
+
if self.debug_mode:
|
| 528 |
+
logger.debug(f"Wall load: u_value={wall.u_value}, area={wall.area}, cltd={cltd}, load={load}")
|
| 529 |
+
return max(load, 0.0)
|
| 530 |
+
|
| 531 |
+
except Exception as e:
|
| 532 |
+
if self.debug_mode:
|
| 533 |
+
logger.error(f"Error in calculate_wall_cooling_load: {str(e)}")
|
| 534 |
+
raise Exception(f"Error in calculate_wall_cooling_load: {str(e)}")
|
| 535 |
+
|
| 536 |
+
def calculate_roof_cooling_load(
|
| 537 |
+
self,
|
| 538 |
+
roof: Roof,
|
| 539 |
+
outdoor_temp: float,
|
| 540 |
+
indoor_temp: float,
|
| 541 |
+
month: str,
|
| 542 |
+
hour: int,
|
| 543 |
+
latitude: str
|
| 544 |
+
) -> float:
|
| 545 |
+
"""
|
| 546 |
+
Calculate cooling load for a roof using CLTD method.
|
| 547 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.10.
|
| 548 |
+
|
| 549 |
+
Args:
|
| 550 |
+
roof: Roof component
|
| 551 |
+
outdoor_temp: Outdoor temperature (°C)
|
| 552 |
+
indoor_temp: Indoor temperature (°C)
|
| 553 |
+
month: Design month
|
| 554 |
+
hour: Hour of the day
|
| 555 |
+
latitude: Latitude (e.g., '24N')
|
| 556 |
+
|
| 557 |
+
Returns:
|
| 558 |
+
Cooling load in Watts
|
| 559 |
+
"""
|
| 560 |
+
try:
|
| 561 |
+
latitude = self.validate_latitude(latitude)
|
| 562 |
+
month = self.validate_month(month)
|
| 563 |
+
hour = self.validate_hour(hour)
|
| 564 |
+
if self.debug_mode:
|
| 565 |
+
logger.debug(f"calculate_roof_cooling_load: latitude={latitude}, month={month}, hour={hour}, roof_group={roof.roof_group}")
|
| 566 |
+
|
| 567 |
+
roof_group = str(roof.roof_group).upper()
|
| 568 |
+
if roof_group not in self.valid_roof_groups:
|
| 569 |
+
numeric_map = {'1': 'A', '2': 'B', '3': 'C', '4': 'D', '5': 'E', '6': 'F', '7': 'G', '8': 'G'}
|
| 570 |
+
if roof_group in numeric_map:
|
| 571 |
+
roof_group = numeric_map[roof_group]
|
| 572 |
+
if self.debug_mode:
|
| 573 |
+
logger.info(f"Mapped roof_group {roof.roof_group} to {roof_group}")
|
| 574 |
+
else:
|
| 575 |
+
if self.debug_mode:
|
| 576 |
+
logger.warning(f"Invalid roof group: {roof_group}. Defaulting to 'A'")
|
| 577 |
+
roof_group = 'A'
|
| 578 |
+
|
| 579 |
+
try:
|
| 580 |
+
cltd = self.ashrae_tables.calculate_corrected_cltd_roof(
|
| 581 |
+
roof_group=roof_group,
|
| 582 |
+
hour=hour,
|
| 583 |
+
color='Dark',
|
| 584 |
+
month=month,
|
| 585 |
+
latitude=latitude,
|
| 586 |
+
indoor_temp=indoor_temp,
|
| 587 |
+
outdoor_temp=outdoor_temp
|
| 588 |
+
)
|
| 589 |
+
except Exception as e:
|
| 590 |
+
if self.debug_mode:
|
| 591 |
+
logger.error(f"calculate_corrected_cltd_roof failed for roof_group={roof_group}: {str(e)}")
|
| 592 |
+
logger.warning("Using default CLTD=8.0°C")
|
| 593 |
+
cltd = 8.0
|
| 594 |
+
|
| 595 |
+
load = roof.u_value * roof.area * cltd
|
| 596 |
+
if self.debug_mode:
|
| 597 |
+
logger.debug(f"Roof load: u_value={roof.u_value}, area={roof.area}, cltd={cltd}, load={load}")
|
| 598 |
+
return max(load, 0.0)
|
| 599 |
+
|
| 600 |
+
except Exception as e:
|
| 601 |
+
if self.debug_mode:
|
| 602 |
+
logger.error(f"Error in calculate_roof_cooling_load: {str(e)}")
|
| 603 |
+
raise Exception(f"Error in calculate_roof_cooling_load: {str(e)}")
|
| 604 |
+
|
| 605 |
+
def calculate_window_cooling_load(
|
| 606 |
+
self,
|
| 607 |
+
window: Window,
|
| 608 |
+
outdoor_temp: float,
|
| 609 |
+
indoor_temp: float,
|
| 610 |
+
month: str,
|
| 611 |
+
hour: int,
|
| 612 |
+
latitude: str,
|
| 613 |
+
shading_coefficient: float
|
| 614 |
+
) -> Dict[str, float]:
|
| 615 |
+
"""
|
| 616 |
+
Calculate cooling load for a window (conduction and solar).
|
| 617 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.12-18.13.
|
| 618 |
+
|
| 619 |
+
Args:
|
| 620 |
+
window: Window component
|
| 621 |
+
outdoor_temp: Outdoor temperature (°C)
|
| 622 |
+
indoor_temp: Indoor temperature (°C)
|
| 623 |
+
month: Design month
|
| 624 |
+
hour: Hour of the day
|
| 625 |
+
latitude: Latitude (e.g., '24N')
|
| 626 |
+
shading_coefficient: Shading coefficient
|
| 627 |
+
|
| 628 |
+
Returns:
|
| 629 |
+
Dictionary with conduction, solar, and total loads in Watts
|
| 630 |
+
"""
|
| 631 |
+
try:
|
| 632 |
+
latitude = self.validate_latitude(latitude)
|
| 633 |
+
month = self.validate_month(month)
|
| 634 |
+
hour = self.validate_hour(hour)
|
| 635 |
+
if self.debug_mode:
|
| 636 |
+
logger.debug(f"calculate_window_cooling_load: latitude={latitude}, month={month}, hour={hour}, orientation={window.orientation.value}")
|
| 637 |
+
|
| 638 |
+
# Conduction load
|
| 639 |
+
cltd = outdoor_temp - indoor_temp
|
| 640 |
+
conduction_load = window.u_value * window.area * cltd
|
| 641 |
+
|
| 642 |
+
# Solar load
|
| 643 |
+
try:
|
| 644 |
+
scl = self.ashrae_tables.get_scl(
|
| 645 |
+
latitude=latitude,
|
| 646 |
+
month=month,
|
| 647 |
+
orientation=window.orientation.value,
|
| 648 |
+
hour=hour
|
| 649 |
+
)
|
| 650 |
+
except Exception as e:
|
| 651 |
+
if self.debug_mode:
|
| 652 |
+
logger.error(f"get_scl failed for latitude={latitude}, month={month}, orientation={window.orientation.value}: {str(e)}")
|
| 653 |
+
logger.warning("Using default SCL=100 W/m²")
|
| 654 |
+
scl = 100.0
|
| 655 |
+
|
| 656 |
+
solar_load = window.area * window.shgc * shading_coefficient * scl
|
| 657 |
+
|
| 658 |
+
total_load = conduction_load + solar_load
|
| 659 |
+
if self.debug_mode:
|
| 660 |
+
logger.debug(f"Window load: conduction={conduction_load}, solar={solar_load}, total={total_load}")
|
| 661 |
+
|
| 662 |
+
return {
|
| 663 |
+
'conduction': max(conduction_load, 0.0),
|
| 664 |
+
'solar': max(solar_load, 0.0),
|
| 665 |
+
'total': max(total_load, 0.0)
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
except Exception as e:
|
| 669 |
+
if self.debug_mode:
|
| 670 |
+
logger.error(f"Error in calculate_window_cooling_load: {str(e)}")
|
| 671 |
+
raise Exception(f"Error in calculate_window_cooling_load: {str(e)}")
|
| 672 |
+
|
| 673 |
+
def calculate_door_cooling_load(
|
| 674 |
+
self,
|
| 675 |
+
door: Door,
|
| 676 |
+
outdoor_temp: float,
|
| 677 |
+
indoor_temp: float
|
| 678 |
+
) -> float:
|
| 679 |
+
"""
|
| 680 |
+
Calculate cooling load for a door.
|
| 681 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
|
| 682 |
+
|
| 683 |
+
Args:
|
| 684 |
+
door: Door component
|
| 685 |
+
outdoor_temp: Outdoor temperature (°C)
|
| 686 |
+
indoor_temp: Indoor temperature (°C)
|
| 687 |
+
|
| 688 |
+
Returns:
|
| 689 |
+
Cooling load in Watts
|
| 690 |
+
"""
|
| 691 |
+
try:
|
| 692 |
+
if self.debug_mode:
|
| 693 |
+
logger.debug(f"calculate_door_cooling_load: u_value={door.u_value}, area={door.area}")
|
| 694 |
+
|
| 695 |
+
cltd = outdoor_temp - indoor_temp
|
| 696 |
+
load = door.u_value * door.area * cltd
|
| 697 |
+
if self.debug_mode:
|
| 698 |
+
logger.debug(f"Door load: cltd={cltd}, load={load}")
|
| 699 |
+
return max(load, 0.0)
|
| 700 |
+
|
| 701 |
+
except Exception as e:
|
| 702 |
+
if self.debug_mode:
|
| 703 |
+
logger.error(f"Error in calculate_door_cooling_load: {str(e)}")
|
| 704 |
+
raise Exception(f"Error in calculate_door_cooling_load: {str(e)}")
|
| 705 |
+
|
| 706 |
+
def calculate_people_cooling_load(
|
| 707 |
+
self,
|
| 708 |
+
num_people: int,
|
| 709 |
+
activity_level: str,
|
| 710 |
+
hour: int
|
| 711 |
+
) -> Dict[str, float]:
|
| 712 |
+
"""
|
| 713 |
+
Calculate cooling load from people.
|
| 714 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Table 18.4.
|
| 715 |
+
|
| 716 |
+
Args:
|
| 717 |
+
num_people: Number of people
|
| 718 |
+
activity_level: Activity level ('Seated/Resting', 'Light Work', etc.)
|
| 719 |
+
hour: Hour of the day
|
| 720 |
+
|
| 721 |
+
Returns:
|
| 722 |
+
Dictionary with sensible and latent loads in Watts
|
| 723 |
+
"""
|
| 724 |
+
try:
|
| 725 |
+
hour = self.validate_hour(hour)
|
| 726 |
+
if self.debug_mode:
|
| 727 |
+
logger.debug(f"calculate_people_cooling_load: num_people={num_people}, activity_level={activity_level}, hour={hour}")
|
| 728 |
+
|
| 729 |
+
heat_gains = {
|
| 730 |
+
'Seated/Resting': {'sensible': 70, 'latent': 45},
|
| 731 |
+
'Light Work': {'sensible': 85, 'latent': 65},
|
| 732 |
+
'Moderate Work': {'sensible': 100, 'latent': 100},
|
| 733 |
+
'Heavy Work': {'sensible': 145, 'latent': 170}
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
gains = heat_gains.get(activity_level, heat_gains['Seated/Resting'])
|
| 737 |
+
if activity_level not in heat_gains:
|
| 738 |
+
if self.debug_mode:
|
| 739 |
+
logger.warning(f"Invalid activity_level: {activity_level}. Defaulting to 'Seated/Resting'")
|
| 740 |
+
|
| 741 |
+
try:
|
| 742 |
+
clf = self.ashrae_tables.get_clf_people(
|
| 743 |
+
zone_type='A',
|
| 744 |
+
hours_occupied='6h',
|
| 745 |
+
hour=hour
|
| 746 |
+
)
|
| 747 |
+
except Exception as e:
|
| 748 |
+
if self.debug_mode:
|
| 749 |
+
logger.error(f"get_clf_people failed: {str(e)}")
|
| 750 |
+
logger.warning("Using default CLF=0.5")
|
| 751 |
+
clf = 0.5
|
| 752 |
+
|
| 753 |
+
sensible_load = num_people * gains['sensible'] * clf
|
| 754 |
+
latent_load = num_people * gains['latent']
|
| 755 |
+
if self.debug_mode:
|
| 756 |
+
logger.debug(f"People load: sensible={sensible_load}, latent={latent_load}, clf={clf}")
|
| 757 |
+
|
| 758 |
+
return {
|
| 759 |
+
'sensible': max(sensible_load, 0.0),
|
| 760 |
+
'latent': max(latent_load, 0.0)
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
except Exception as e:
|
| 764 |
+
if self.debug_mode:
|
| 765 |
+
logger.error(f"Error in calculate_people_cooling_load: {str(e)}")
|
| 766 |
+
raise Exception(f"Error in calculate_people_cooling_load: {str(e)}")
|
| 767 |
+
|
| 768 |
+
def calculate_lights_cooling_load(
|
| 769 |
+
self,
|
| 770 |
+
power: float,
|
| 771 |
+
use_factor: float,
|
| 772 |
+
special_allowance: float,
|
| 773 |
+
hour: int
|
| 774 |
+
) -> float:
|
| 775 |
+
"""
|
| 776 |
+
Calculate cooling load from lighting.
|
| 777 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Table 18.5.
|
| 778 |
+
|
| 779 |
+
Args:
|
| 780 |
+
power: Total lighting power (W)
|
| 781 |
+
use_factor: Usage factor (0.0 to 1.0)
|
| 782 |
+
special_allowance: Special allowance factor
|
| 783 |
+
hour: Hour of the day
|
| 784 |
+
|
| 785 |
+
Returns:
|
| 786 |
+
Cooling load in Watts
|
| 787 |
+
"""
|
| 788 |
+
try:
|
| 789 |
+
hour = self.validate_hour(hour)
|
| 790 |
+
if self.debug_mode:
|
| 791 |
+
logger.debug(f"calculate_lights_cooling_load: power={power}, use_factor={use_factor}, special_allowance={special_allowance}, hour={hour}")
|
| 792 |
+
|
| 793 |
+
try:
|
| 794 |
+
clf = self.ashrae_tables.get_clf_lights(
|
| 795 |
+
zone_type='A',
|
| 796 |
+
hours_occupied='6h',
|
| 797 |
+
hour=hour
|
| 798 |
+
)
|
| 799 |
+
except Exception as e:
|
| 800 |
+
if self.debug_mode:
|
| 801 |
+
logger.error(f"get_clf_lights failed: {str(e)}")
|
| 802 |
+
logger.warning("Using default CLF=0.8")
|
| 803 |
+
clf = 0.8
|
| 804 |
+
|
| 805 |
+
load = power * use_factor * special_allowance * clf
|
| 806 |
+
if self.debug_mode:
|
| 807 |
+
logger.debug(f"Lights load: clf={clf}, load={load}")
|
| 808 |
+
return max(load, 0.0)
|
| 809 |
+
|
| 810 |
+
except Exception as e:
|
| 811 |
+
if self.debug_mode:
|
| 812 |
+
logger.error(f"Error in calculate_lights_cooling_load: {str(e)}")
|
| 813 |
+
raise Exception(f"Error in calculate_lights_cooling_load: {str(e)}")
|
| 814 |
+
|
| 815 |
+
def calculate_equipment_cooling_load(
|
| 816 |
+
self,
|
| 817 |
+
power: float,
|
| 818 |
+
use_factor: float,
|
| 819 |
+
radiation_factor: float,
|
| 820 |
+
hour: int
|
| 821 |
+
) -> Dict[str, float]:
|
| 822 |
+
"""
|
| 823 |
+
Calculate cooling load from equipment.
|
| 824 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Table 18.6.
|
| 825 |
+
|
| 826 |
+
Args:
|
| 827 |
+
power: Total equipment power (W)
|
| 828 |
+
use_factor: Usage factor (0.0 to 1.0)
|
| 829 |
+
radiation_factor: Radiation factor (0.0 to 1.0)
|
| 830 |
+
hour: Hour of the day
|
| 831 |
+
|
| 832 |
+
Returns:
|
| 833 |
+
Dictionary with sensible and latent loads in Watts
|
| 834 |
+
"""
|
| 835 |
+
try:
|
| 836 |
+
hour = self.validate_hour(hour)
|
| 837 |
+
if self.debug_mode:
|
| 838 |
+
logger.debug(f"calculate_equipment_cooling_load: power={power}, use_factor={use_factor}, radiation_factor={radiation_factor}, hour={hour}")
|
| 839 |
+
|
| 840 |
+
try:
|
| 841 |
+
clf = self.ashrae_tables.get_clf_equipment(
|
| 842 |
+
zone_type='A',
|
| 843 |
+
hours_operated='6h',
|
| 844 |
+
hour=hour
|
| 845 |
+
)
|
| 846 |
+
except Exception as e:
|
| 847 |
+
if self.debug_mode:
|
| 848 |
+
logger.error(f"get_clf_equipment failed: {str(e)}")
|
| 849 |
+
logger.warning("Using default CLF=0.7")
|
| 850 |
+
clf = 0.7
|
| 851 |
+
|
| 852 |
+
sensible_load = power * use_factor * radiation_factor * clf
|
| 853 |
+
latent_load = power * use_factor * (1 - radiation_factor)
|
| 854 |
+
if self.debug_mode:
|
| 855 |
+
logger.debug(f"Equipment load: sensible={sensible_load}, latent={latent_load}, clf={clf}")
|
| 856 |
+
|
| 857 |
+
return {
|
| 858 |
+
'sensible': max(sensible_load, 0.0),
|
| 859 |
+
'latent': max(latent_load, 0.0)
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
except Exception as e:
|
| 863 |
+
if self.debug_mode:
|
| 864 |
+
logger.error(f"Error in calculate_equipment_cooling_load: {str(e)}")
|
| 865 |
+
raise Exception(f"Error in calculate_equipment_cooling_load: {str(e)}")
|
| 866 |
+
|
| 867 |
+
def calculate_infiltration_cooling_load(
|
| 868 |
+
self,
|
| 869 |
+
flow_rate: float,
|
| 870 |
+
building_volume: float,
|
| 871 |
+
outdoor_temp: float,
|
| 872 |
+
outdoor_rh: float,
|
| 873 |
+
indoor_temp: float,
|
| 874 |
+
indoor_rh: float,
|
| 875 |
+
p_atm: float = 101325
|
| 876 |
+
) -> Dict[str, float]:
|
| 877 |
+
"""
|
| 878 |
+
Calculate cooling load from infiltration.
|
| 879 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.5-18.6.
|
| 880 |
+
|
| 881 |
+
Args:
|
| 882 |
+
flow_rate: Infiltration flow rate (m³/s)
|
| 883 |
+
building_volume: Building volume (m³)
|
| 884 |
+
outdoor_temp: Outdoor temperature (°C)
|
| 885 |
+
outdoor_rh: Outdoor relative humidity (%)
|
| 886 |
+
indoor_temp: Indoor temperature (°C)
|
| 887 |
+
indoor_rh: Indoor relative humidity (%)
|
| 888 |
+
p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
|
| 889 |
+
|
| 890 |
+
Returns:
|
| 891 |
+
Dictionary with sensible and latent loads in Watts
|
| 892 |
+
"""
|
| 893 |
+
try:
|
| 894 |
+
if self.debug_mode:
|
| 895 |
+
logger.debug(f"calculate_infiltration_cooling_load: flow_rate={flow_rate}, building_volume={building_volume}, outdoor_temp={outdoor_temp}, indoor_temp={indoor_temp}")
|
| 896 |
+
|
| 897 |
+
self.validate_conditions(outdoor_temp, indoor_temp, outdoor_rh, indoor_rh)
|
| 898 |
+
if flow_rate < 0 or building_volume <= 0:
|
| 899 |
+
raise ValueError("Flow rate cannot be negative and building volume must be positive")
|
| 900 |
+
|
| 901 |
+
# Calculate air changes per hour (ACH)
|
| 902 |
+
ach = (flow_rate * 3600) / building_volume if building_volume > 0 else 0.5
|
| 903 |
+
if ach < 0:
|
| 904 |
+
if self.debug_mode:
|
| 905 |
+
logger.warning(f"Invalid ACH: {ach}. Defaulting to 0.5")
|
| 906 |
+
ach = 0.5
|
| 907 |
+
|
| 908 |
+
# Calculate humidity ratio difference
|
| 909 |
+
outdoor_w = self.heat_transfer.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh, p_atm)
|
| 910 |
+
indoor_w = self.heat_transfer.psychrometrics.humidity_ratio(indoor_temp, indoor_rh, p_atm)
|
| 911 |
+
delta_w = max(0, outdoor_w - indoor_w)
|
| 912 |
+
|
| 913 |
+
# Calculate sensible and latent loads using heat_transfer methods
|
| 914 |
+
sensible_load = self.heat_transfer.infiltration_heat_transfer(
|
| 915 |
+
flow_rate, outdoor_temp - indoor_temp, indoor_temp, indoor_rh, p_atm
|
| 916 |
+
)
|
| 917 |
+
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
|
| 918 |
+
flow_rate, delta_w, indoor_temp, indoor_rh, p_atm
|
| 919 |
+
)
|
| 920 |
+
|
| 921 |
+
if self.debug_mode:
|
| 922 |
+
logger.debug(f"Infiltration load: sensible={sensible_load}, latent={latent_load}, ach={ach}, outdoor_w={outdoor_w}, indoor_w={indoor_w}")
|
| 923 |
+
|
| 924 |
+
return {
|
| 925 |
+
'sensible': max(sensible_load, 0.0),
|
| 926 |
+
'latent': max(latent_load, 0.0)
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
except Exception as e:
|
| 930 |
+
if self.debug_mode:
|
| 931 |
+
logger.error(f"Error in calculate_infiltration_cooling_load: {str(e)}")
|
| 932 |
+
raise Exception(f"Error in calculate_infiltration_cooling_load: {str(e)}")
|
| 933 |
+
|
| 934 |
+
def calculate_ventilation_cooling_load(
|
| 935 |
+
self,
|
| 936 |
+
flow_rate: float,
|
| 937 |
+
outdoor_temp: float,
|
| 938 |
+
outdoor_rh: float,
|
| 939 |
+
indoor_temp: float,
|
| 940 |
+
indoor_rh: float,
|
| 941 |
+
p_atm: float = 101325
|
| 942 |
+
) -> Dict[str, float]:
|
| 943 |
+
"""
|
| 944 |
+
Calculate cooling load from ventilation.
|
| 945 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.5-18.6.
|
| 946 |
+
|
| 947 |
+
Args:
|
| 948 |
+
flow_rate: Ventilation flow rate (m³/s)
|
| 949 |
+
outdoor_temp: Outdoor temperature (°C)
|
| 950 |
+
outdoor_rh: Outdoor relative humidity (%)
|
| 951 |
+
indoor_temp: Indoor temperature (°C)
|
| 952 |
+
indoor_rh: Indoor relative humidity (%)
|
| 953 |
+
p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
|
| 954 |
+
|
| 955 |
+
Returns:
|
| 956 |
+
Dictionary with sensible and latent loads in Watts
|
| 957 |
+
"""
|
| 958 |
+
try:
|
| 959 |
+
if self.debug_mode:
|
| 960 |
+
logger.debug(f"calculate_ventilation_cooling_load: flow_rate={flow_rate}, outdoor_temp={outdoor_temp}, indoor_temp={indoor_temp}")
|
| 961 |
+
|
| 962 |
+
self.validate_conditions(outdoor_temp, indoor_temp, outdoor_rh, indoor_rh)
|
| 963 |
+
if flow_rate < 0:
|
| 964 |
+
raise ValueError("Flow rate cannot be negative")
|
| 965 |
+
|
| 966 |
+
# Calculate humidity ratio difference
|
| 967 |
+
outdoor_w = self.heat_transfer.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh, p_atm)
|
| 968 |
+
indoor_w = self.heat_transfer.psychrometrics.humidity_ratio(indoor_temp, indoor_rh, p_atm)
|
| 969 |
+
delta_w = max(0, outdoor_w - indoor_w)
|
| 970 |
+
|
| 971 |
+
# Calculate sensible and latent loads using heat_transfer methods
|
| 972 |
+
sensible_load = self.heat_transfer.infiltration_heat_transfer(
|
| 973 |
+
flow_rate, outdoor_temp - indoor_temp, indoor_temp, indoor_rh, p_atm
|
| 974 |
+
)
|
| 975 |
+
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
|
| 976 |
+
flow_rate, delta_w, indoor_temp, indoor_rh, p_atm
|
| 977 |
+
)
|
| 978 |
+
|
| 979 |
+
if self.debug_mode:
|
| 980 |
+
logger.debug(f"Ventilation load: sensible={sensible_load}, latent={latent_load}, outdoor_w={outdoor_w}, indoor_w={indoor_w}")
|
| 981 |
+
|
| 982 |
+
return {
|
| 983 |
+
'sensible': max(sensible_load, 0.0),
|
| 984 |
+
'latent': max(latent_load, 0.0)
|
| 985 |
+
}
|
| 986 |
+
|
| 987 |
+
except Exception as e:
|
| 988 |
+
if self.debug_mode:
|
| 989 |
+
logger.error(f"Error in calculate_ventilation_cooling_load: {str(e)}")
|
| 990 |
+
raise Exception(f"Error in calculate_ventilation_cooling_load: {str(e)}")
|
| 991 |
+
|
| 992 |
+
# Example usage
|
| 993 |
+
if __name__ == "__main__":
|
| 994 |
+
calculator = CoolingLoadCalculator(debug_mode=True)
|
| 995 |
+
|
| 996 |
+
# Example inputs
|
| 997 |
+
components = {
|
| 998 |
+
'walls': [Wall(id="w1", name="North Wall", area=20.0, u_value=0.5, orientation=Orientation.NORTH, wall_group='A')],
|
| 999 |
+
'roofs': [Roof(id="r1", name="Main Roof", area=100.0, u_value=0.3, orientation=Orientation.HORIZONTAL, roof_group='A')],
|
| 1000 |
+
'windows': [Window(id="win1", name="South Window", area=10.0, u_value=2.8, orientation=Orientation.SOUTH, shgc=0.7, shading_coefficient=0.8)],
|
| 1001 |
+
'doors': [Door(id="d1", name="Main Door", area=2.0, u_value=2.0, orientation=Orientation.NORTH)]
|
| 1002 |
+
}
|
| 1003 |
+
outdoor_conditions = {
|
| 1004 |
+
'temperature': 35.0,
|
| 1005 |
+
'relative_humidity': 60.0,
|
| 1006 |
+
'latitude': '32N',
|
| 1007 |
+
'month': 'JUL'
|
| 1008 |
+
}
|
| 1009 |
+
indoor_conditions = {
|
| 1010 |
+
'temperature': 24.0,
|
| 1011 |
+
'relative_humidity': 50.0
|
| 1012 |
+
}
|
| 1013 |
+
internal_loads = {
|
| 1014 |
+
'people': {'number': 10, 'activity_level': 'Seated/Resting'},
|
| 1015 |
+
'lights': {'power': 1000.0, 'use_factor': 0.8, 'special_allowance': 1.0},
|
| 1016 |
+
'equipment': {'power': 500.0, 'use_factor': 0.7, 'radiation_factor': 0.5},
|
| 1017 |
+
'infiltration': {'flow_rate': 0.05},
|
| 1018 |
+
'ventilation': {'flow_rate': 0.1}
|
| 1019 |
+
}
|
| 1020 |
+
building_volume = 300.0
|
| 1021 |
+
|
| 1022 |
+
# Calculate hourly loads
|
| 1023 |
+
hourly_loads = calculator.calculate_hourly_cooling_loads(
|
| 1024 |
+
components, outdoor_conditions, indoor_conditions, internal_loads, building_volume
|
| 1025 |
+
)
|
| 1026 |
+
design_loads = calculator.calculate_design_cooling_load(hourly_loads)
|
| 1027 |
+
summary = calculator.calculate_cooling_load_summary(design_loads)
|
| 1028 |
+
|
| 1029 |
+
logger.info(f"Design Cooling Load Summary: {summary}")
|
utils/heat_transfer.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Heat transfer calculation module for HVAC Load Calculator.
|
| 3 |
+
This module implements heat transfer calculations for conduction, infiltration, and solar effects.
|
| 4 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapters 16 and 18.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 8 |
+
import math
|
| 9 |
+
import numpy as np
|
| 10 |
+
import logging
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
# Configure logging
|
| 14 |
+
logging.basicConfig(level=logging.INFO)
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# Import utility modules
|
| 18 |
+
from utils.psychrometrics import Psychrometrics
|
| 19 |
+
|
| 20 |
+
# Import data modules
|
| 21 |
+
from data.building_components import Orientation
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SolarCalculations:
|
| 25 |
+
"""Class for solar geometry and radiation calculations."""
|
| 26 |
+
|
| 27 |
+
def validate_angle(self, angle: float, name: str, min_val: float, max_val: float) -> None:
|
| 28 |
+
"""
|
| 29 |
+
Validate angle inputs for solar calculations.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
angle: Angle in degrees
|
| 33 |
+
name: Name of the angle
|
| 34 |
+
min_val: Minimum allowed value
|
| 35 |
+
max_val: Maximum allowed value
|
| 36 |
+
|
| 37 |
+
Raises:
|
| 38 |
+
ValueError: If angle is out of range
|
| 39 |
+
"""
|
| 40 |
+
if not min_val <= angle <= max_val:
|
| 41 |
+
raise ValueError(f"{name} {angle}° must be between {min_val}° and {max_val}°")
|
| 42 |
+
|
| 43 |
+
def solar_declination(self, day_of_year: int) -> float:
|
| 44 |
+
"""
|
| 45 |
+
Calculate solar declination angle.
|
| 46 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Equation 14.6.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
day_of_year: Day of the year (1-365)
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
Declination angle in degrees
|
| 53 |
+
"""
|
| 54 |
+
if not 1 <= day_of_year <= 365:
|
| 55 |
+
raise ValueError("Day of year must be between 1 and 365")
|
| 56 |
+
|
| 57 |
+
declination = 23.45 * math.sin(math.radians(360 * (284 + day_of_year) / 365))
|
| 58 |
+
self.validate_angle(declination, "Declination angle", -23.45, 23.45)
|
| 59 |
+
return declination
|
| 60 |
+
|
| 61 |
+
def solar_hour_angle(self, hour: float) -> float:
|
| 62 |
+
"""
|
| 63 |
+
Calculate solar hour angle.
|
| 64 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Equation 14.7.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
hour: Hour of the day (0-23)
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Hour angle in degrees
|
| 71 |
+
"""
|
| 72 |
+
if not 0 <= hour <= 24:
|
| 73 |
+
raise ValueError("Hour must be between 0 and 24")
|
| 74 |
+
|
| 75 |
+
hour_angle = (hour - 12) * 15
|
| 76 |
+
self.validate_angle(hour_angle, "Hour angle", -180, 180)
|
| 77 |
+
return hour_angle
|
| 78 |
+
|
| 79 |
+
def solar_altitude(self, latitude: float, declination: float, hour_angle: float) -> float:
|
| 80 |
+
"""
|
| 81 |
+
Calculate solar altitude angle.
|
| 82 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Equation 14.8.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
latitude: Latitude in degrees
|
| 86 |
+
declination: Declination angle in degrees
|
| 87 |
+
hour_angle: Hour angle in degrees
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
Altitude angle in degrees
|
| 91 |
+
"""
|
| 92 |
+
self.validate_angle(latitude, "Latitude", -90, 90)
|
| 93 |
+
self.validate_angle(declination, "Declination", -23.45, 23.45)
|
| 94 |
+
self.validate_angle(hour_angle, "Hour angle", -180, 180)
|
| 95 |
+
|
| 96 |
+
sin_beta = (math.sin(math.radians(latitude)) * math.sin(math.radians(declination)) +
|
| 97 |
+
math.cos(math.radians(latitude)) * math.cos(math.radians(declination)) *
|
| 98 |
+
math.cos(math.radians(hour_angle)))
|
| 99 |
+
beta = math.degrees(math.asin(sin_beta))
|
| 100 |
+
self.validate_angle(beta, "Altitude angle", 0, 90)
|
| 101 |
+
return beta
|
| 102 |
+
|
| 103 |
+
def solar_azimuth(self, latitude: float, declination: float, hour_angle: float, altitude: float) -> float:
|
| 104 |
+
"""
|
| 105 |
+
Calculate solar azimuth angle.
|
| 106 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Equation 14.9.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
latitude: Latitude in degrees
|
| 110 |
+
declination: Declination angle in degrees
|
| 111 |
+
hour_angle: Hour angle in degrees
|
| 112 |
+
altitude: Altitude angle in degrees
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
Azimuth angle in degrees
|
| 116 |
+
"""
|
| 117 |
+
self.validate_angle(latitude, "Latitude", -90, 90)
|
| 118 |
+
self.validate_angle(declination, "Declination", -23.45, 23.45)
|
| 119 |
+
self.validate_angle(hour_angle, "Hour angle", -180, 180)
|
| 120 |
+
self.validate_angle(altitude, "Altitude", 0, 90)
|
| 121 |
+
|
| 122 |
+
sin_phi = (math.cos(math.radians(declination)) * math.sin(math.radians(hour_angle)) /
|
| 123 |
+
math.cos(math.radians(altitude)))
|
| 124 |
+
phi = math.degrees(math.asin(sin_phi))
|
| 125 |
+
|
| 126 |
+
if hour_angle > 0:
|
| 127 |
+
phi = 180 - phi
|
| 128 |
+
elif hour_angle < 0:
|
| 129 |
+
phi = -180 - phi
|
| 130 |
+
|
| 131 |
+
self.validate_angle(phi, "Azimuth angle", -180, 180)
|
| 132 |
+
return phi
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class HeatTransferCalculations:
|
| 136 |
+
"""Class for heat transfer calculations."""
|
| 137 |
+
|
| 138 |
+
def __init__(self):
|
| 139 |
+
"""
|
| 140 |
+
Initialize heat transfer calculations with psychrometrics and solar calculations.
|
| 141 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16.
|
| 142 |
+
"""
|
| 143 |
+
self.psychrometrics = Psychrometrics()
|
| 144 |
+
self.solar = SolarCalculations()
|
| 145 |
+
self.debug_mode = False
|
| 146 |
+
|
| 147 |
+
def conduction_heat_transfer(self, u_value: float, area: float, delta_t: float) -> float:
|
| 148 |
+
"""
|
| 149 |
+
Calculate heat transfer via conduction.
|
| 150 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
u_value: U-value of the component in W/(m²·K)
|
| 154 |
+
area: Area of the component in m²
|
| 155 |
+
delta_t: Temperature difference in °C
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
Heat transfer rate in W
|
| 159 |
+
"""
|
| 160 |
+
if u_value < 0 or area < 0:
|
| 161 |
+
raise ValueError("U-value and area must be non-negative")
|
| 162 |
+
|
| 163 |
+
q = u_value * area * delta_t
|
| 164 |
+
return q
|
| 165 |
+
|
| 166 |
+
def infiltration_heat_transfer(self, flow_rate: float, delta_t: float,
|
| 167 |
+
t_db: float, rh: float, p_atm: float = 101325) -> float:
|
| 168 |
+
"""
|
| 169 |
+
Calculate sensible heat transfer due to infiltration or ventilation.
|
| 170 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.5.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
flow_rate: Air flow rate in m³/s
|
| 174 |
+
delta_t: Temperature difference in °C
|
| 175 |
+
t_db: Dry-bulb temperature for air properties in °C
|
| 176 |
+
rh: Relative humidity in % (0-100)
|
| 177 |
+
p_atm: Atmospheric pressure in Pa
|
| 178 |
+
|
| 179 |
+
Returns:
|
| 180 |
+
Sensible heat transfer rate in W
|
| 181 |
+
"""
|
| 182 |
+
if flow_rate < 0:
|
| 183 |
+
raise ValueError("Flow rate cannot be negative")
|
| 184 |
+
|
| 185 |
+
# Calculate air density and specific heat using psychrometrics
|
| 186 |
+
w = self.psychrometrics.humidity_ratio(t_db, rh, p_atm)
|
| 187 |
+
rho = self.psychrometrics.density(t_db, w, p_atm)
|
| 188 |
+
c_p = 1006 + 1860 * w # Specific heat of moist air in J/(kg·K)
|
| 189 |
+
|
| 190 |
+
q = flow_rate * rho * c_p * delta_t
|
| 191 |
+
return q
|
| 192 |
+
|
| 193 |
+
def infiltration_latent_heat_transfer(self, flow_rate: float, delta_w: float,
|
| 194 |
+
t_db: float, rh: float, p_atm: float = 101325) -> float:
|
| 195 |
+
"""
|
| 196 |
+
Calculate latent heat transfer due to infiltration or ventilation.
|
| 197 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.6.
|
| 198 |
+
|
| 199 |
+
Args:
|
| 200 |
+
flow_rate: Air flow rate in m³/s
|
| 201 |
+
delta_w: Humidity ratio difference in kg/kg
|
| 202 |
+
t_db: Dry-bulb temperature for air properties in °C
|
| 203 |
+
rh: Relative humidity in % (0-100)
|
| 204 |
+
p_atm: Atmospheric pressure in Pa
|
| 205 |
+
|
| 206 |
+
Returns:
|
| 207 |
+
Latent heat transfer rate in W
|
| 208 |
+
"""
|
| 209 |
+
if flow_rate < 0 or delta_w < 0:
|
| 210 |
+
raise ValueError("Flow rate and humidity ratio difference cannot be negative")
|
| 211 |
+
|
| 212 |
+
# Calculate air density and latent heat
|
| 213 |
+
w = self.psychrometrics.humidity_ratio(t_db, rh, p_atm)
|
| 214 |
+
rho = self.psychrometrics.density(t_db, w, p_atm)
|
| 215 |
+
h_fg = 2501000 + 1840 * t_db # Latent heat of vaporization in J/kg
|
| 216 |
+
|
| 217 |
+
q = flow_rate * rho * h_fg * delta_w
|
| 218 |
+
return q
|
| 219 |
+
|
| 220 |
+
def wind_pressure_difference(self, wind_speed: float) -> float:
|
| 221 |
+
"""
|
| 222 |
+
Calculate pressure difference due to wind.
|
| 223 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.3.
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
wind_speed: Wind speed in m/s
|
| 227 |
+
|
| 228 |
+
Returns:
|
| 229 |
+
Pressure difference in Pa
|
| 230 |
+
"""
|
| 231 |
+
if wind_speed < 0:
|
| 232 |
+
raise ValueError("Wind speed cannot be negative")
|
| 233 |
+
|
| 234 |
+
c_p = 0.6 # Wind pressure coefficient
|
| 235 |
+
rho_air = 1.2 # Air density at standard conditions in kg/m³
|
| 236 |
+
delta_p = 0.5 * c_p * rho_air * wind_speed**2
|
| 237 |
+
return delta_p
|
| 238 |
+
|
| 239 |
+
def stack_pressure_difference(self, height: float, t_inside: float, t_outside: float) -> float:
|
| 240 |
+
"""
|
| 241 |
+
Calculate pressure difference due to stack effect.
|
| 242 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.4.
|
| 243 |
+
|
| 244 |
+
Args:
|
| 245 |
+
height: Height of the building in m
|
| 246 |
+
t_inside: Inside temperature in K
|
| 247 |
+
t_outside: Outside temperature in K
|
| 248 |
+
|
| 249 |
+
Returns:
|
| 250 |
+
Pressure difference in Pa
|
| 251 |
+
"""
|
| 252 |
+
if height < 0 or t_inside <= 0 or t_outside <= 0:
|
| 253 |
+
raise ValueError("Height and temperatures must be positive")
|
| 254 |
+
|
| 255 |
+
g = 9.81 # Gravitational acceleration in m/s²
|
| 256 |
+
rho_air = 1.2 # Air density at standard conditions in kg/m³
|
| 257 |
+
delta_p = rho_air * g * height * (1 / t_outside - 1 / t_inside)
|
| 258 |
+
return delta_p
|
| 259 |
+
|
| 260 |
+
def combined_pressure_difference(self, wind_pd: float, stack_pd: float) -> float:
|
| 261 |
+
"""
|
| 262 |
+
Calculate combined pressure difference from wind and stack effects.
|
| 263 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Section 16.2.
|
| 264 |
+
|
| 265 |
+
Args:
|
| 266 |
+
wind_pd: Wind pressure difference in Pa
|
| 267 |
+
stack_pd: Stack pressure difference in Pa
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
Combined pressure difference in Pa
|
| 271 |
+
"""
|
| 272 |
+
delta_p = math.sqrt(wind_pd**2 + stack_pd**2)
|
| 273 |
+
return delta_p
|
| 274 |
+
|
| 275 |
+
def crack_method_infiltration(self, crack_length: float, crack_width: float, delta_p: float) -> float:
|
| 276 |
+
"""
|
| 277 |
+
Calculate infiltration flow rate using crack method.
|
| 278 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.5.
|
| 279 |
+
|
| 280 |
+
Args:
|
| 281 |
+
crack_length: Length of cracks in m
|
| 282 |
+
crack_width: Width of cracks in m
|
| 283 |
+
delta_p: Pressure difference across cracks in Pa
|
| 284 |
+
|
| 285 |
+
Returns:
|
| 286 |
+
Infiltration flow rate in m³/s
|
| 287 |
+
"""
|
| 288 |
+
if crack_length < 0 or crack_width < 0 or delta_p < 0:
|
| 289 |
+
raise ValueError("Crack dimensions and pressure difference cannot be negative")
|
| 290 |
+
|
| 291 |
+
c_d = 0.65 # Discharge coefficient
|
| 292 |
+
area = crack_length * crack_width
|
| 293 |
+
rho_air = 1.2 # Air density at standard conditions in kg/m³
|
| 294 |
+
q = c_d * area * math.sqrt(2 * delta_p / rho_air)
|
| 295 |
+
return q
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# Example usage
|
| 299 |
+
if __name__ == "__main__":
|
| 300 |
+
heat_transfer = HeatTransferCalculations()
|
| 301 |
+
heat_transfer.debug_mode = True
|
| 302 |
+
|
| 303 |
+
# Example conduction calculation
|
| 304 |
+
u_value = 0.5 # W/(m²·K)
|
| 305 |
+
area = 20.0 # m²
|
| 306 |
+
delta_t = 26.0 # °C
|
| 307 |
+
q_conduction = heat_transfer.conduction_heat_transfer(u_value, area, delta_t)
|
| 308 |
+
logger.info(f"Conduction heat transfer: {q_conduction:.2f} W")
|
| 309 |
+
|
| 310 |
+
# Example infiltration calculation
|
| 311 |
+
flow_rate = 0.05 # m³/s
|
| 312 |
+
delta_t = 26.0 # °C
|
| 313 |
+
t_db = 21.0 # °C
|
| 314 |
+
rh = 40.0 # %
|
| 315 |
+
p_atm = 101325 # Pa
|
| 316 |
+
q_infiltration = heat_transfer.infiltration_heat_transfer(flow_rate, delta_t, t_db, rh, p_atm)
|
| 317 |
+
logger.info(f"Infiltration sensible heat transfer: {q_infiltration:.2f} W")
|
| 318 |
+
|
| 319 |
+
# Example solar calculation
|
| 320 |
+
latitude = 40.0 # degrees
|
| 321 |
+
day_of_year = 172 # June 21
|
| 322 |
+
hour = 12.0 # Noon
|
| 323 |
+
declination = heat_transfer.solar.solar_declination(day_of_year)
|
| 324 |
+
hour_angle = heat_transfer.solar.solar_hour_angle(hour)
|
| 325 |
+
altitude = heat_transfer.solar.solar_altitude(latitude, declination, hour_angle)
|
| 326 |
+
azimuth = heat_transfer.solar.solar_azimuth(latitude, declination, hour_angle, altitude)
|
| 327 |
+
logger.info(f"Solar altitude: {altitude:.2f}°, Azimuth: {azimuth:.2f}°")
|
utils/heating_load.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Heating load calculation module for HVAC Load Calculator.
|
| 3 |
+
Implements ASHRAE steady-state methods with simplified thermal lag for compatibility.
|
| 4 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 8 |
+
import math
|
| 9 |
+
import numpy as np
|
| 10 |
+
import logging
|
| 11 |
+
from enum import Enum
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
# Configure logging
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Import utility modules
|
| 19 |
+
from utils.psychrometrics import Psychrometrics
|
| 20 |
+
from utils.heat_transfer import HeatTransferCalculations
|
| 21 |
+
|
| 22 |
+
# Import data modules
|
| 23 |
+
from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class HeatingLoadCalculator:
|
| 27 |
+
"""Class for heating load calculations based on ASHRAE steady-state methods."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, debug_mode: bool = False):
|
| 30 |
+
"""
|
| 31 |
+
Initialize heating load calculator with psychrometric and heat transfer calculations.
|
| 32 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
debug_mode: Enable debug logging if True
|
| 36 |
+
"""
|
| 37 |
+
self.psychrometrics = Psychrometrics()
|
| 38 |
+
self.heat_transfer = HeatTransferCalculations()
|
| 39 |
+
self.safety_factor = 1.15 # 15% safety factor for design loads
|
| 40 |
+
self.debug_mode = debug_mode
|
| 41 |
+
if debug_mode:
|
| 42 |
+
logger.setLevel(logging.DEBUG)
|
| 43 |
+
|
| 44 |
+
def validate_inputs(self, components: Dict[str, List[Any]], outdoor_temp: float, indoor_temp: float) -> None:
|
| 45 |
+
"""
|
| 46 |
+
Validate input parameters for heating load calculations.
|
| 47 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
components: Dictionary of building components
|
| 51 |
+
outdoor_temp: Outdoor design temperature in °C
|
| 52 |
+
indoor_temp: Indoor design temperature in °C
|
| 53 |
+
|
| 54 |
+
Raises:
|
| 55 |
+
ValueError: If inputs are invalid
|
| 56 |
+
"""
|
| 57 |
+
if not components:
|
| 58 |
+
raise ValueError("Building components dictionary cannot be empty")
|
| 59 |
+
for component_type, comp_list in components.items():
|
| 60 |
+
if not isinstance(comp_list, list):
|
| 61 |
+
raise ValueError(f"Components for {component_type} must be a list")
|
| 62 |
+
for comp in comp_list:
|
| 63 |
+
if not hasattr(comp, 'area') or comp.area <= 0:
|
| 64 |
+
raise ValueError(f"Invalid area for {component_type}: {comp.name}")
|
| 65 |
+
if not hasattr(comp, 'u_value') or comp.u_value <= 0:
|
| 66 |
+
raise ValueError(f"Invalid U-value for {component_type}: {comp.name}")
|
| 67 |
+
if not -50 <= outdoor_temp <= 60 or not -50 <= indoor_temp <= 60:
|
| 68 |
+
raise ValueError("Temperatures must be between -50°C and 60°C")
|
| 69 |
+
if indoor_temp - outdoor_temp < 1:
|
| 70 |
+
raise ValueError("Indoor temperature must be at least 1°C above outdoor temperature for heating")
|
| 71 |
+
|
| 72 |
+
def calculate_wall_heating_load(self, wall: Wall, outdoor_temp: float, indoor_temp: float) -> float:
|
| 73 |
+
"""
|
| 74 |
+
Calculate heating load for a wall, with simplified thermal lag.
|
| 75 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
wall: Wall component
|
| 79 |
+
outdoor_temp: Outdoor temperature in °C
|
| 80 |
+
indoor_temp: Indoor temperature in °C
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
Heating load in W
|
| 84 |
+
"""
|
| 85 |
+
delta_t = indoor_temp - outdoor_temp
|
| 86 |
+
if delta_t <= 1:
|
| 87 |
+
return 0.0 # Skip calculation for small temperature differences
|
| 88 |
+
|
| 89 |
+
# Use default lag factor (no thermal mass adjustment)
|
| 90 |
+
lag_factor = 1.0
|
| 91 |
+
adjusted_delta_t = delta_t * lag_factor
|
| 92 |
+
|
| 93 |
+
load = self.heat_transfer.conduction_heat_transfer(wall.u_value, wall.area, adjusted_delta_t)
|
| 94 |
+
return max(0, load)
|
| 95 |
+
|
| 96 |
+
def calculate_roof_heating_load(self, roof: Roof, outdoor_temp: float, indoor_temp: float) -> float:
|
| 97 |
+
"""
|
| 98 |
+
Calculate heating load for a roof, with simplified thermal lag.
|
| 99 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
roof: Roof component
|
| 103 |
+
outdoor_temp: Outdoor temperature in °C
|
| 104 |
+
indoor_temp: Indoor temperature in °C
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Heating load in W
|
| 108 |
+
"""
|
| 109 |
+
delta_t = indoor_temp - outdoor_temp
|
| 110 |
+
if delta_t <= 1:
|
| 111 |
+
return 0.0
|
| 112 |
+
|
| 113 |
+
lag_factor = 1.0
|
| 114 |
+
adjusted_delta_t = delta_t * lag_factor
|
| 115 |
+
|
| 116 |
+
load = self.heat_transfer.conduction_heat_transfer(roof.u_value, roof.area, adjusted_delta_t)
|
| 117 |
+
return max(0, load)
|
| 118 |
+
|
| 119 |
+
def calculate_floor_heating_load(self, floor: Floor, ground_temp: float, indoor_temp: float) -> float:
|
| 120 |
+
"""
|
| 121 |
+
Calculate heating load for a floor, using dynamic F-factor for ground contact.
|
| 122 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.3.
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
floor: Floor component
|
| 126 |
+
ground_temp: Ground temperature in °C
|
| 127 |
+
indoor_temp: Indoor temperature in °C
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Heating load in W
|
| 131 |
+
"""
|
| 132 |
+
delta_t = indoor_temp - ground_temp
|
| 133 |
+
if delta_t <= 1:
|
| 134 |
+
return 0.0
|
| 135 |
+
|
| 136 |
+
if floor.is_ground_contact:
|
| 137 |
+
# Dynamic F-factor based on insulation
|
| 138 |
+
f_factor = 0.3 if floor.insulated else 0.73 # W/m·K
|
| 139 |
+
load = f_factor * floor.perimeter_length * delta_t
|
| 140 |
+
else:
|
| 141 |
+
load = self.heat_transfer.conduction_heat_transfer(floor.u_value, floor.area, delta_t)
|
| 142 |
+
|
| 143 |
+
if self.debug_mode:
|
| 144 |
+
logger.debug(f"Floor {floor.name} load: {load:.2f} W, Delta T: {delta_t:.2f}°C")
|
| 145 |
+
|
| 146 |
+
return max(0, load)
|
| 147 |
+
|
| 148 |
+
def calculate_window_heating_load(self, window: Window, outdoor_temp: float, indoor_temp: float) -> float:
|
| 149 |
+
"""
|
| 150 |
+
Calculate heating load for a window.
|
| 151 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
window: Window component
|
| 155 |
+
outdoor_temp: Outdoor temperature in °C
|
| 156 |
+
indoor_temp: Indoor temperature in °C
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
Heating load in W
|
| 160 |
+
"""
|
| 161 |
+
delta_t = indoor_temp - outdoor_temp
|
| 162 |
+
if delta_t <= 1:
|
| 163 |
+
return 0.0
|
| 164 |
+
|
| 165 |
+
load = self.heat_transfer.conduction_heat_transfer(window.u_value, window.area, delta_t)
|
| 166 |
+
return max(0, load)
|
| 167 |
+
|
| 168 |
+
def calculate_door_heating_load(self, door: Door, outdoor_temp: float, indoor_temp: float) -> float:
|
| 169 |
+
"""
|
| 170 |
+
Calculate heating load for a door.
|
| 171 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
door: Door component
|
| 175 |
+
outdoor_temp: Outdoor temperature in °C
|
| 176 |
+
indoor_temp: Indoor temperature in °C
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Heating load in W
|
| 180 |
+
"""
|
| 181 |
+
delta_t = indoor_temp - outdoor_temp
|
| 182 |
+
if delta_t <= 1:
|
| 183 |
+
return 0.0
|
| 184 |
+
|
| 185 |
+
load = self.heat_transfer.conduction_heat_transfer(door.u_value, door.area, delta_t)
|
| 186 |
+
return max(0, load)
|
| 187 |
+
|
| 188 |
+
def calculate_infiltration_heating_load(self, indoor_conditions: Dict[str, float],
|
| 189 |
+
outdoor_conditions: Dict[str, float],
|
| 190 |
+
infiltration: Dict[str, float],
|
| 191 |
+
building_height: float,
|
| 192 |
+
p_atm: float = 101325) -> Tuple[float, float]:
|
| 193 |
+
"""
|
| 194 |
+
Calculate sensible and latent heating loads due to infiltration.
|
| 195 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.5-18.6.
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
indoor_conditions: Indoor conditions (temperature, relative_humidity)
|
| 199 |
+
outdoor_conditions: Outdoor conditions (design_temperature, design_relative_humidity, wind_speed)
|
| 200 |
+
infiltration: Infiltration parameters (flow_rate, crack_length, height)
|
| 201 |
+
building_height: Building height in m
|
| 202 |
+
p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
Tuple of sensible and latent loads in W
|
| 206 |
+
"""
|
| 207 |
+
delta_t = indoor_conditions['temperature'] - outdoor_conditions['design_temperature']
|
| 208 |
+
if delta_t <= 1:
|
| 209 |
+
return 0.0, 0.0
|
| 210 |
+
|
| 211 |
+
# Calculate pressure differences
|
| 212 |
+
wind_pd = self.heat_transfer.wind_pressure_difference(outdoor_conditions['wind_speed'])
|
| 213 |
+
stack_pd = self.heat_transfer.stack_pressure_difference(
|
| 214 |
+
building_height,
|
| 215 |
+
indoor_conditions['temperature'] + 273.15,
|
| 216 |
+
outdoor_conditions['design_temperature'] + 273.15
|
| 217 |
+
)
|
| 218 |
+
total_pd = self.heat_transfer.combined_pressure_difference(wind_pd, stack_pd)
|
| 219 |
+
|
| 220 |
+
# Calculate infiltration flow rate
|
| 221 |
+
crack_length = infiltration.get('crack_length', 20.0)
|
| 222 |
+
flow_rate = self.heat_transfer.crack_method_infiltration(crack_length, 0.0002, total_pd)
|
| 223 |
+
|
| 224 |
+
# Calculate humidity ratio difference
|
| 225 |
+
w_indoor = self.psychrometrics.humidity_ratio(
|
| 226 |
+
indoor_conditions['temperature'],
|
| 227 |
+
indoor_conditions['relative_humidity'],
|
| 228 |
+
p_atm
|
| 229 |
+
)
|
| 230 |
+
w_outdoor = self.psychrometrics.humidity_ratio(
|
| 231 |
+
outdoor_conditions['design_temperature'],
|
| 232 |
+
outdoor_conditions['design_relative_humidity'],
|
| 233 |
+
p_atm
|
| 234 |
+
)
|
| 235 |
+
delta_w = max(0, w_indoor - w_outdoor)
|
| 236 |
+
|
| 237 |
+
# Calculate sensible and latent loads using indoor conditions for air properties
|
| 238 |
+
sensible_load = self.heat_transfer.infiltration_heat_transfer(
|
| 239 |
+
flow_rate, delta_t,
|
| 240 |
+
indoor_conditions['temperature'],
|
| 241 |
+
indoor_conditions['relative_humidity'],
|
| 242 |
+
p_atm
|
| 243 |
+
)
|
| 244 |
+
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
|
| 245 |
+
flow_rate, delta_w,
|
| 246 |
+
indoor_conditions['temperature'],
|
| 247 |
+
indoor_conditions['relative_humidity'],
|
| 248 |
+
p_atm
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
if self.debug_mode:
|
| 252 |
+
logger.debug(f"Infiltration flow rate: {flow_rate:.6f} m³/s, Sensible load: {sensible_load:.2f} W, Latent load: {latent_load:.2f} W")
|
| 253 |
+
|
| 254 |
+
return max(0, sensible_load), max(0, latent_load)
|
| 255 |
+
|
| 256 |
+
def calculate_ventilation_heating_load(self, ventilation: Dict[str, float],
|
| 257 |
+
indoor_conditions: Dict[str, float],
|
| 258 |
+
outdoor_conditions: Dict[str, float],
|
| 259 |
+
p_atm: float = 101325) -> Tuple[float, float]:
|
| 260 |
+
"""
|
| 261 |
+
Calculate sensible and latent heating loads due to ventilation.
|
| 262 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.5-18.6.
|
| 263 |
+
|
| 264 |
+
Args:
|
| 265 |
+
ventilation: Ventilation parameters (flow_rate)
|
| 266 |
+
indoor_conditions: Indoor conditions (temperature, relative_humidity)
|
| 267 |
+
outdoor_conditions: Outdoor conditions (design_temperature, design_relative_humidity)
|
| 268 |
+
p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
|
| 269 |
+
|
| 270 |
+
Returns:
|
| 271 |
+
Tuple of sensible and latent loads in W
|
| 272 |
+
"""
|
| 273 |
+
delta_t = indoor_conditions['temperature'] - outdoor_conditions['design_temperature']
|
| 274 |
+
if delta_t <= 1:
|
| 275 |
+
return 0.0, 0.0
|
| 276 |
+
|
| 277 |
+
flow_rate = ventilation['flow_rate']
|
| 278 |
+
|
| 279 |
+
w_indoor = self.psychrometrics.humidity_ratio(
|
| 280 |
+
indoor_conditions['temperature'],
|
| 281 |
+
indoor_conditions['relative_humidity'],
|
| 282 |
+
p_atm
|
| 283 |
+
)
|
| 284 |
+
w_outdoor = self.psychrometrics.humidity_ratio(
|
| 285 |
+
outdoor_conditions['design_temperature'],
|
| 286 |
+
outdoor_conditions['design_relative_humidity'],
|
| 287 |
+
p_atm
|
| 288 |
+
)
|
| 289 |
+
delta_w = max(0, w_indoor - w_outdoor)
|
| 290 |
+
|
| 291 |
+
# Calculate sensible and latent loads using indoor conditions for air properties
|
| 292 |
+
sensible_load = self.heat_transfer.infiltration_heat_transfer(
|
| 293 |
+
flow_rate, delta_t,
|
| 294 |
+
indoor_conditions['temperature'],
|
| 295 |
+
indoor_conditions['relative_humidity'],
|
| 296 |
+
p_atm
|
| 297 |
+
)
|
| 298 |
+
latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
|
| 299 |
+
flow_rate, delta_w,
|
| 300 |
+
indoor_conditions['temperature'],
|
| 301 |
+
indoor_conditions['relative_humidity'],
|
| 302 |
+
p_atm
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
if self.debug_mode:
|
| 306 |
+
logger.debug(f"Ventilation flow rate: {flow_rate:.6f} m³/s, Sensible load: {sensible_load:.2f} W, Latent load: {latent_load:.2f} W")
|
| 307 |
+
|
| 308 |
+
return max(0, sensible_load), max(0, latent_load)
|
| 309 |
+
|
| 310 |
+
def calculate_internal_gains(self, internal_loads: Dict[str, Any]) -> float:
|
| 311 |
+
"""
|
| 312 |
+
Calculate internal heat gains from people, lighting, and equipment.
|
| 313 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.4.
|
| 314 |
+
|
| 315 |
+
Args:
|
| 316 |
+
internal_loads: Internal loads (people, lights, equipment)
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
Total internal gains in W
|
| 320 |
+
"""
|
| 321 |
+
total_gains = 0.0
|
| 322 |
+
|
| 323 |
+
# People gains
|
| 324 |
+
people = internal_loads.get('people', {})
|
| 325 |
+
if people.get('number', 0) > 0:
|
| 326 |
+
sensible_gain = people.get('sensible_gain', 70.0)
|
| 327 |
+
total_gains += people['number'] * sensible_gain
|
| 328 |
+
|
| 329 |
+
# Lighting gains
|
| 330 |
+
lights = internal_loads.get('lights', {})
|
| 331 |
+
if lights.get('power', 0) > 0:
|
| 332 |
+
total_gains += lights['power'] * lights.get('use_factor', 0.8)
|
| 333 |
+
|
| 334 |
+
# Equipment gains
|
| 335 |
+
equipment = internal_loads.get('equipment', {})
|
| 336 |
+
if equipment.get('power', 0) > 0:
|
| 337 |
+
total_gains += equipment['power'] * equipment.get('use_factor', 0.7)
|
| 338 |
+
|
| 339 |
+
return max(0, total_gains)
|
| 340 |
+
|
| 341 |
+
def calculate_design_heating_load(self, building_components: Dict[str, List[Any]],
|
| 342 |
+
outdoor_conditions: Dict[str, float],
|
| 343 |
+
indoor_conditions: Dict[str, float],
|
| 344 |
+
internal_loads: Dict[str, Any],
|
| 345 |
+
p_atm: float = 101325) -> Dict[str, float]:
|
| 346 |
+
"""
|
| 347 |
+
Calculate design heating loads for all components.
|
| 348 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
building_components: Dictionary of building components
|
| 352 |
+
outdoor_conditions: Outdoor conditions (design_temperature, design_relative_humidity, ground_temperature, wind_speed)
|
| 353 |
+
indoor_conditions: Indoor conditions (temperature, relative_humidity)
|
| 354 |
+
internal_loads: Internal loads (people, lights, equipment, infiltration, ventilation)
|
| 355 |
+
p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
|
| 356 |
+
|
| 357 |
+
Returns:
|
| 358 |
+
Dictionary of design loads in W
|
| 359 |
+
"""
|
| 360 |
+
try:
|
| 361 |
+
self.validate_inputs(building_components, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
|
| 362 |
+
except ValueError as e:
|
| 363 |
+
raise ValueError(f"Input validation failed: {str(e)}")
|
| 364 |
+
|
| 365 |
+
loads = {
|
| 366 |
+
'walls': 0.0,
|
| 367 |
+
'roofs': 0.0,
|
| 368 |
+
'floors': 0.0,
|
| 369 |
+
'windows': 0.0,
|
| 370 |
+
'doors': 0.0,
|
| 371 |
+
'infiltration_sensible': 0.0,
|
| 372 |
+
'infiltration_latent': 0.0,
|
| 373 |
+
'ventilation_sensible': 0.0,
|
| 374 |
+
'ventilation_latent': 0.0,
|
| 375 |
+
'internal_gains': 0.0
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
# Calculate envelope loads
|
| 379 |
+
for wall in building_components.get('walls', []):
|
| 380 |
+
loads['walls'] += self.calculate_wall_heating_load(wall, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
|
| 381 |
+
|
| 382 |
+
for roof in building_components.get('roofs', []):
|
| 383 |
+
loads['roofs'] += self.calculate_roof_heating_load(roof, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
|
| 384 |
+
|
| 385 |
+
for floor in building_components.get('floors', []):
|
| 386 |
+
loads['floors'] += self.calculate_floor_heating_load(floor, outdoor_conditions['ground_temperature'], indoor_conditions['temperature'])
|
| 387 |
+
|
| 388 |
+
for window in building_components.get('windows', []):
|
| 389 |
+
loads['windows'] += self.calculate_window_heating_load(window, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
|
| 390 |
+
|
| 391 |
+
for door in building_components.get('doors', []):
|
| 392 |
+
loads['doors'] += self.calculate_door_heating_load(door, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
|
| 393 |
+
|
| 394 |
+
# Calculate infiltration and ventilation loads
|
| 395 |
+
building_height = internal_loads.get('infiltration', {}).get('height', 3.0)
|
| 396 |
+
infiltration_sensible, infiltration_latent = self.calculate_infiltration_heating_load(
|
| 397 |
+
indoor_conditions, outdoor_conditions, internal_loads.get('infiltration', {}), building_height, p_atm
|
| 398 |
+
)
|
| 399 |
+
loads['infiltration_sensible'] = infiltration_sensible
|
| 400 |
+
loads['infiltration_latent'] = infiltration_latent
|
| 401 |
+
|
| 402 |
+
ventilation_sensible, ventilation_latent = self.calculate_ventilation_heating_load(
|
| 403 |
+
internal_loads.get('ventilation', {}), indoor_conditions, outdoor_conditions, p_atm
|
| 404 |
+
)
|
| 405 |
+
loads['ventilation_sensible'] = ventilation_sensible
|
| 406 |
+
loads['ventilation_latent'] = ventilation_latent
|
| 407 |
+
|
| 408 |
+
# Calculate internal gains (negative for heating)
|
| 409 |
+
loads['internal_gains'] = -self.calculate_internal_gains(internal_loads)
|
| 410 |
+
|
| 411 |
+
return loads
|
| 412 |
+
|
| 413 |
+
def calculate_heating_load_summary(self, design_loads: Dict[str, float]) -> Dict[str, float]:
|
| 414 |
+
"""
|
| 415 |
+
Summarize heating loads with safety factor.
|
| 416 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
|
| 417 |
+
|
| 418 |
+
Args:
|
| 419 |
+
design_loads: Dictionary of design loads in W
|
| 420 |
+
|
| 421 |
+
Returns:
|
| 422 |
+
Summary dictionary with total, subtotal, and safety factor
|
| 423 |
+
"""
|
| 424 |
+
subtotal = sum(
|
| 425 |
+
load for key, load in design_loads.items()
|
| 426 |
+
if key not in ['internal_gains'] and load > 0
|
| 427 |
+
)
|
| 428 |
+
internal_gains = design_loads.get('internal_gains', 0)
|
| 429 |
+
|
| 430 |
+
total = max(0, subtotal + internal_gains) * self.safety_factor
|
| 431 |
+
|
| 432 |
+
return {
|
| 433 |
+
'subtotal': subtotal,
|
| 434 |
+
'internal_gains': internal_gains,
|
| 435 |
+
'total': total,
|
| 436 |
+
'safety_factor': self.safety_factor
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
def calculate_heating_degree_days(self, base_temp: float, monthly_temps: Dict[str, float]) -> float:
|
| 440 |
+
"""
|
| 441 |
+
Calculate heating degree days for a year.
|
| 442 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.3.
|
| 443 |
+
|
| 444 |
+
Args:
|
| 445 |
+
base_temp: Base temperature for HDD calculation in °C
|
| 446 |
+
monthly_temps: Dictionary of monthly average temperatures
|
| 447 |
+
|
| 448 |
+
Returns:
|
| 449 |
+
Total heating degree days
|
| 450 |
+
"""
|
| 451 |
+
hdd = 0.0
|
| 452 |
+
days_per_month = {
|
| 453 |
+
'Jan': 31, 'Feb': 28, 'Mar': 31, 'Apr': 30, 'May': 31, 'Jun': 30,
|
| 454 |
+
'Jul': 31, 'Aug': 31, 'Sep': 30, 'Oct': 31, 'Nov': 30, 'Dec': 31
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
for month, temp in monthly_temps.items():
|
| 458 |
+
if temp < base_temp:
|
| 459 |
+
hdd += (base_temp - temp) * days_per_month[month]
|
| 460 |
+
|
| 461 |
+
return hdd
|
| 462 |
+
|
| 463 |
+
def calculate_annual_heating_energy(self, design_loads: Dict[str, float],
|
| 464 |
+
monthly_temps: Dict[str, float],
|
| 465 |
+
indoor_temp: float,
|
| 466 |
+
operating_hours: str) -> float:
|
| 467 |
+
"""
|
| 468 |
+
Calculate annual heating energy consumption.
|
| 469 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.3.
|
| 470 |
+
|
| 471 |
+
Args:
|
| 472 |
+
design_loads: Dictionary of design loads in W
|
| 473 |
+
monthly_temps: Dictionary of monthly average temperatures
|
| 474 |
+
indoor_temp: Indoor design temperature in °C
|
| 475 |
+
operating_hours: Operating hours (e.g., '8:00-18:00')
|
| 476 |
+
|
| 477 |
+
Returns:
|
| 478 |
+
Annual heating energy in kWh
|
| 479 |
+
"""
|
| 480 |
+
base_temp = indoor_temp
|
| 481 |
+
hdd = self.calculate_heating_degree_days(base_temp, monthly_temps)
|
| 482 |
+
|
| 483 |
+
# Parse operating hours
|
| 484 |
+
start_hour, end_hour = map(lambda x: int(x.split(':')[0]), operating_hours.split('-'))
|
| 485 |
+
daily_hours = end_hour - start_hour
|
| 486 |
+
|
| 487 |
+
# Calculate design condition degree days
|
| 488 |
+
design_temp = min(monthly_temps.values())
|
| 489 |
+
design_delta_t = indoor_temp - design_temp
|
| 490 |
+
if design_delta_t <= 1:
|
| 491 |
+
return 0.0
|
| 492 |
+
|
| 493 |
+
total_load = self.calculate_heating_load_summary(design_loads)['total']
|
| 494 |
+
|
| 495 |
+
# Scale load by HDD and operating hours
|
| 496 |
+
annual_energy = (total_load / design_delta_t) * hdd * (daily_hours / 24) / 1000 # kWh
|
| 497 |
+
|
| 498 |
+
return max(0, annual_energy)
|
| 499 |
+
|
| 500 |
+
def calculate_monthly_heating_loads(self, building_components: Dict[str, List[Any]],
|
| 501 |
+
outdoor_conditions: Dict[str, float],
|
| 502 |
+
indoor_conditions: Dict[str, float],
|
| 503 |
+
internal_loads: Dict[str, Any],
|
| 504 |
+
monthly_temps: Dict[str, float],
|
| 505 |
+
p_atm: float = 101325) -> Dict[str, float]:
|
| 506 |
+
"""
|
| 507 |
+
Calculate monthly heating loads.
|
| 508 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
|
| 509 |
+
|
| 510 |
+
Args:
|
| 511 |
+
building_components: Dictionary of building components
|
| 512 |
+
outdoor_conditions: Outdoor conditions
|
| 513 |
+
indoor_conditions: Indoor conditions
|
| 514 |
+
internal_loads: Internal loads
|
| 515 |
+
monthly_temps: Dictionary of monthly average temperatures
|
| 516 |
+
p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
|
| 517 |
+
|
| 518 |
+
Returns:
|
| 519 |
+
Dictionary of monthly heating loads in kW
|
| 520 |
+
"""
|
| 521 |
+
monthly_loads = {}
|
| 522 |
+
days_per_month = {
|
| 523 |
+
'Jan': 31, 'Feb': 28, 'Mar': 31, 'Apr': 30, 'May': 31, 'Jun': 30,
|
| 524 |
+
'Jul': 31, 'Aug': 31, 'Sep': 30, 'Oct': 31, 'Nov': 30, 'Dec': 31
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
for month, temp in monthly_temps.items():
|
| 528 |
+
modified_outdoor = outdoor_conditions.copy()
|
| 529 |
+
modified_outdoor['design_temperature'] = temp
|
| 530 |
+
modified_outdoor['ground_temperature'] = temp
|
| 531 |
+
|
| 532 |
+
try:
|
| 533 |
+
design_loads = self.calculate_design_heating_load(
|
| 534 |
+
building_components, modified_outdoor, indoor_conditions, internal_loads, p_atm
|
| 535 |
+
)
|
| 536 |
+
summary = self.calculate_heating_load_summary(design_loads)
|
| 537 |
+
monthly_loads[month] = summary['total'] / 1000 # kW
|
| 538 |
+
except ValueError:
|
| 539 |
+
monthly_loads[month] = 0.0 # Skip invalid months
|
| 540 |
+
|
| 541 |
+
return monthly_loads
|
| 542 |
+
|
| 543 |
+
# Example usage
|
| 544 |
+
if __name__ == "__main__":
|
| 545 |
+
calculator = HeatingLoadCalculator(debug_mode=True)
|
| 546 |
+
|
| 547 |
+
# Example building components
|
| 548 |
+
components = {
|
| 549 |
+
'walls': [Wall(id="w1", name="North Wall", area=20.0, u_value=0.5, orientation=Orientation.NORTH)],
|
| 550 |
+
'roofs': [Roof(id="r1", name="Main Roof", area=100.0, u_value=0.3, orientation=Orientation.HORIZONTAL)],
|
| 551 |
+
'floors': [Floor(id="f1", name="Ground Floor", area=100.0, u_value=0.4, perimeter_length=40.0,
|
| 552 |
+
is_ground_contact=True, insulated=True, ground_temperature_c=10.0)],
|
| 553 |
+
'windows': [Window(id="win1", name="South Window", area=10.0, u_value=2.8, orientation=Orientation.SOUTH,
|
| 554 |
+
shgc=0.7, shading_coefficient=0.8)],
|
| 555 |
+
'doors': [Door(id="d1", name="Main Door", area=2.0, u_value=2.0, orientation=Orientation.NORTH)]
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
outdoor_conditions = {
|
| 559 |
+
'design_temperature': -5.0,
|
| 560 |
+
'design_relative_humidity': 80.0,
|
| 561 |
+
'ground_temperature': 10.0,
|
| 562 |
+
'wind_speed': 4.0
|
| 563 |
+
}
|
| 564 |
+
indoor_conditions = {
|
| 565 |
+
'temperature': 21.0,
|
| 566 |
+
'relative_humidity': 40.0
|
| 567 |
+
}
|
| 568 |
+
internal_loads = {
|
| 569 |
+
'people': {'number': 10, 'sensible_gain': 70.0, 'operating_hours': '8:00-18:00'},
|
| 570 |
+
'lights': {'power': 1000.0, 'use_factor': 0.8, 'hours_operation': '8h'},
|
| 571 |
+
'equipment': {'power': 500.0, 'use_factor': 0.7, 'hours_operation': '8h'},
|
| 572 |
+
'infiltration': {'flow_rate': 0.05, 'height': 3.0, 'crack_length': 20.0},
|
| 573 |
+
'ventilation': {'flow_rate': 0.1},
|
| 574 |
+
'operating_hours': '8:00-18:00'
|
| 575 |
+
}
|
| 576 |
+
monthly_temps = {
|
| 577 |
+
'Jan': -5.0, 'Feb': -3.0, 'Mar': 2.0, 'Apr': 8.0, 'May': 14.0, 'Jun': 19.0,
|
| 578 |
+
'Jul': 22.0, 'Aug': 21.0, 'Sep': 16.0, 'Oct': 10.0, 'Nov': 4.0, 'Dec': -2.0
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
# Calculate design loads
|
| 582 |
+
design_loads = calculator.calculate_design_heating_load(components, outdoor_conditions, indoor_conditions, internal_loads)
|
| 583 |
+
summary = calculator.calculate_heating_load_summary(design_loads)
|
| 584 |
+
|
| 585 |
+
# Log results
|
| 586 |
+
logger.info(f"Total Heating Load: {summary['total']:.2f} W")
|
| 587 |
+
logger.info(f"Wall Load: {design_loads['walls']:.2f} W")
|
| 588 |
+
logger.info(f"Roof Load: {design_loads['roofs']:.2f} W")
|
| 589 |
+
logger.info(f"Floor Load: {design_loads['floors']:.2f} W")
|
| 590 |
+
logger.info(f"Window Load: {design_loads['windows']:.2f} W")
|
| 591 |
+
logger.info(f"Door Load: {design_loads['doors']:.2f} W")
|
| 592 |
+
logger.info(f"Infiltration Sensible Load: {design_loads['infiltration_sensible']:.2f} W")
|
| 593 |
+
logger.info(f"Infiltration Latent Load: {design_loads['infiltration_latent']:.2f} W")
|
| 594 |
+
logger.info(f"Ventilation Sensible Load: {design_loads['ventilation_sensible']:.2f} W")
|
| 595 |
+
logger.info(f"Ventilation Latent Load: {design_loads['ventilation_latent']:.2f} W")
|
| 596 |
+
logger.info(f"Internal Gains: {design_loads['internal_gains']:.2f} W")
|
| 597 |
+
|
| 598 |
+
# Calculate annual energy
|
| 599 |
+
annual_energy = calculator.calculate_annual_heating_energy(
|
| 600 |
+
design_loads, monthly_temps, indoor_conditions['temperature'], internal_loads['operating_hours']
|
| 601 |
+
)
|
| 602 |
+
logger.info(f"Annual Heating Energy: {annual_energy:.2f} kWh")
|
| 603 |
+
|
| 604 |
+
# Calculate monthly loads
|
| 605 |
+
monthly_loads = calculator.calculate_monthly_heating_loads(
|
| 606 |
+
components, outdoor_conditions, indoor_conditions, internal_loads, monthly_temps
|
| 607 |
+
)
|
| 608 |
+
logger.info("Monthly Heating Loads (kW):")
|
| 609 |
+
for month, load in monthly_loads.items():
|
| 610 |
+
logger.info(f"{month}: {load:.2f} kW")
|
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,581 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Psychrometric module for HVAC Load Calculator.
|
| 3 |
+
This module implements psychrometric calculations for air properties.
|
| 4 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Dict, List, Any, Optional, Tuple
|
| 8 |
+
import math
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
# Constants
|
| 12 |
+
ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure in Pa
|
| 13 |
+
WATER_MOLECULAR_WEIGHT = 18.01534 # kg/kmol
|
| 14 |
+
DRY_AIR_MOLECULAR_WEIGHT = 28.9645 # kg/kmol
|
| 15 |
+
UNIVERSAL_GAS_CONSTANT = 8314.462618 # J/(kmol·K)
|
| 16 |
+
GAS_CONSTANT_DRY_AIR = UNIVERSAL_GAS_CONSTANT / DRY_AIR_MOLECULAR_WEIGHT # J/(kg·K)
|
| 17 |
+
GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Psychrometrics:
|
| 21 |
+
"""Class for psychrometric calculations."""
|
| 22 |
+
|
| 23 |
+
@staticmethod
|
| 24 |
+
def validate_inputs(t_db: float, rh: Optional[float] = None, p_atm: Optional[float] = None) -> None:
|
| 25 |
+
"""
|
| 26 |
+
Validate input parameters for psychrometric calculations.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
t_db: Dry-bulb temperature in °C
|
| 30 |
+
rh: Relative humidity in % (0-100), optional
|
| 31 |
+
p_atm: Atmospheric pressure in Pa, optional
|
| 32 |
+
|
| 33 |
+
Raises:
|
| 34 |
+
ValueError: If inputs are invalid
|
| 35 |
+
"""
|
| 36 |
+
if not -50 <= t_db <= 60:
|
| 37 |
+
raise ValueError(f"Temperature {t_db}°C must be between -50°C and 60°C")
|
| 38 |
+
if rh is not None and not 0 <= rh <= 100:
|
| 39 |
+
raise ValueError(f"Relative humidity {rh}% must be between 0 and 100%")
|
| 40 |
+
if p_atm is not None and p_atm <= 0:
|
| 41 |
+
raise ValueError(f"Atmospheric pressure {p_atm} Pa must be positive")
|
| 42 |
+
|
| 43 |
+
@staticmethod
|
| 44 |
+
def saturation_pressure(t_db: float) -> float:
|
| 45 |
+
"""
|
| 46 |
+
Calculate saturation pressure of water vapor.
|
| 47 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5 and 6.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
t_db: Dry-bulb temperature in °C
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
Saturation pressure in Pa
|
| 54 |
+
"""
|
| 55 |
+
Psychrometrics.validate_inputs(t_db)
|
| 56 |
+
|
| 57 |
+
# Convert temperature to Kelvin
|
| 58 |
+
t_k = t_db + 273.15
|
| 59 |
+
|
| 60 |
+
# ASHRAE Fundamentals 2017 Chapter 1, Equation 5 & 6
|
| 61 |
+
if t_db >= 0:
|
| 62 |
+
# Equation 5 for temperatures above freezing
|
| 63 |
+
c1 = -5.8002206e3
|
| 64 |
+
c2 = 1.3914993
|
| 65 |
+
c3 = -4.8640239e-2
|
| 66 |
+
c4 = 4.1764768e-5
|
| 67 |
+
c5 = -1.4452093e-8
|
| 68 |
+
c6 = 6.5459673
|
| 69 |
+
else:
|
| 70 |
+
# Equation 6 for temperatures below freezing
|
| 71 |
+
c1 = -5.6745359e3
|
| 72 |
+
c2 = 6.3925247
|
| 73 |
+
c3 = -9.6778430e-3
|
| 74 |
+
c4 = 6.2215701e-7
|
| 75 |
+
c5 = 2.0747825e-9
|
| 76 |
+
c6 = -9.4840240e-13
|
| 77 |
+
c7 = 4.1635019
|
| 78 |
+
|
| 79 |
+
# Calculate natural log of saturation pressure in Pa
|
| 80 |
+
if t_db >= 0:
|
| 81 |
+
ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * math.log(t_k)
|
| 82 |
+
else:
|
| 83 |
+
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)
|
| 84 |
+
|
| 85 |
+
# Convert from natural log to actual pressure in Pa
|
| 86 |
+
p_ws = math.exp(ln_p_ws)
|
| 87 |
+
|
| 88 |
+
return p_ws
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
def humidity_ratio(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 92 |
+
"""
|
| 93 |
+
Calculate humidity ratio (mass of water vapor per unit mass of dry air).
|
| 94 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
t_db: Dry-bulb temperature in °C
|
| 98 |
+
rh: Relative humidity (0-100)
|
| 99 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
Humidity ratio in kg water vapor / kg dry air
|
| 103 |
+
"""
|
| 104 |
+
Psychrometrics.validate_inputs(t_db, rh, p_atm)
|
| 105 |
+
|
| 106 |
+
# Convert relative humidity to decimal
|
| 107 |
+
rh_decimal = rh / 100.0
|
| 108 |
+
|
| 109 |
+
# Calculate saturation pressure
|
| 110 |
+
p_ws = Psychrometrics.saturation_pressure(t_db)
|
| 111 |
+
|
| 112 |
+
# Calculate partial pressure of water vapor
|
| 113 |
+
p_w = rh_decimal * p_ws
|
| 114 |
+
|
| 115 |
+
if p_w >= p_atm:
|
| 116 |
+
raise ValueError("Partial pressure of water vapor exceeds atmospheric pressure")
|
| 117 |
+
|
| 118 |
+
# Calculate humidity ratio
|
| 119 |
+
w = 0.621945 * p_w / (p_atm - p_w)
|
| 120 |
+
|
| 121 |
+
return w
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def relative_humidity(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 125 |
+
"""
|
| 126 |
+
Calculate relative humidity from humidity ratio.
|
| 127 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20 (rearranged).
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
t_db: Dry-bulb temperature in °C
|
| 131 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 132 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
Relative humidity (0-100)
|
| 136 |
+
"""
|
| 137 |
+
Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
|
| 138 |
+
if w < 0:
|
| 139 |
+
raise ValueError("Humidity ratio cannot be negative")
|
| 140 |
+
|
| 141 |
+
# Calculate saturation pressure
|
| 142 |
+
p_ws = Psychrometrics.saturation_pressure(t_db)
|
| 143 |
+
|
| 144 |
+
# Calculate partial pressure of water vapor
|
| 145 |
+
p_w = p_atm * w / (0.621945 + w)
|
| 146 |
+
|
| 147 |
+
# Calculate relative humidity
|
| 148 |
+
rh = 100.0 * p_w / p_ws
|
| 149 |
+
|
| 150 |
+
return rh
|
| 151 |
+
|
| 152 |
+
@staticmethod
|
| 153 |
+
def wet_bulb_temperature(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 154 |
+
"""
|
| 155 |
+
Calculate wet-bulb temperature using iterative method.
|
| 156 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 35.
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
t_db: Dry-bulb temperature in °C
|
| 160 |
+
rh: Relative humidity (0-100)
|
| 161 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
Wet-bulb temperature in °C
|
| 165 |
+
"""
|
| 166 |
+
Psychrometrics.validate_inputs(t_db, rh, p_atm)
|
| 167 |
+
|
| 168 |
+
# Calculate humidity ratio at given conditions
|
| 169 |
+
w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
|
| 170 |
+
|
| 171 |
+
# Initial guess for wet-bulb temperature
|
| 172 |
+
t_wb = t_db
|
| 173 |
+
|
| 174 |
+
# Iterative solution
|
| 175 |
+
max_iterations = 100
|
| 176 |
+
tolerance = 0.001 # °C
|
| 177 |
+
|
| 178 |
+
for i in range(max_iterations):
|
| 179 |
+
# Validate wet-bulb temperature
|
| 180 |
+
Psychrometrics.validate_inputs(t_wb)
|
| 181 |
+
|
| 182 |
+
# Calculate saturation pressure at wet-bulb temperature
|
| 183 |
+
p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
|
| 184 |
+
|
| 185 |
+
# Calculate saturation humidity ratio at wet-bulb temperature
|
| 186 |
+
w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
|
| 187 |
+
|
| 188 |
+
# Calculate humidity ratio from wet-bulb temperature
|
| 189 |
+
h_fg = 2501000 + 1840 * t_wb # Latent heat of vaporization at t_wb in J/kg
|
| 190 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 191 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 192 |
+
|
| 193 |
+
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)
|
| 194 |
+
|
| 195 |
+
# Check convergence
|
| 196 |
+
if abs(w - w_calc) < tolerance:
|
| 197 |
+
break
|
| 198 |
+
|
| 199 |
+
# Adjust wet-bulb temperature
|
| 200 |
+
if w_calc > w:
|
| 201 |
+
t_wb -= 0.1
|
| 202 |
+
else:
|
| 203 |
+
t_wb += 0.1
|
| 204 |
+
|
| 205 |
+
return t_wb
|
| 206 |
+
|
| 207 |
+
@staticmethod
|
| 208 |
+
def dew_point_temperature(t_db: float, rh: float) -> float:
|
| 209 |
+
"""
|
| 210 |
+
Calculate dew point temperature.
|
| 211 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 39 and 40.
|
| 212 |
+
|
| 213 |
+
Args:
|
| 214 |
+
t_db: Dry-bulb temperature in °C
|
| 215 |
+
rh: Relative humidity (0-100)
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
Dew point temperature in °C
|
| 219 |
+
"""
|
| 220 |
+
Psychrometrics.validate_inputs(t_db, rh)
|
| 221 |
+
|
| 222 |
+
# Convert relative humidity to decimal
|
| 223 |
+
rh_decimal = rh / 100.0
|
| 224 |
+
|
| 225 |
+
# Calculate saturation pressure
|
| 226 |
+
p_ws = Psychrometrics.saturation_pressure(t_db)
|
| 227 |
+
|
| 228 |
+
# Calculate partial pressure of water vapor
|
| 229 |
+
p_w = rh_decimal * p_ws
|
| 230 |
+
|
| 231 |
+
# Calculate dew point temperature
|
| 232 |
+
alpha = math.log(p_w / 1000.0) # Convert to kPa for the formula
|
| 233 |
+
|
| 234 |
+
if t_db >= 0:
|
| 235 |
+
# For temperatures above freezing
|
| 236 |
+
c14 = 6.54
|
| 237 |
+
c15 = 14.526
|
| 238 |
+
c16 = 0.7389
|
| 239 |
+
c17 = 0.09486
|
| 240 |
+
c18 = 0.4569
|
| 241 |
+
|
| 242 |
+
t_dp = c14 + c15 * alpha + c16 * alpha**2 + c17 * alpha**3 + c18 * p_w**(0.1984)
|
| 243 |
+
else:
|
| 244 |
+
# For temperatures below freezing
|
| 245 |
+
c14 = 6.09
|
| 246 |
+
c15 = 12.608
|
| 247 |
+
c16 = 0.4959
|
| 248 |
+
|
| 249 |
+
t_dp = c14 + c15 * alpha + c16 * alpha**2
|
| 250 |
+
|
| 251 |
+
return t_dp
|
| 252 |
+
|
| 253 |
+
@staticmethod
|
| 254 |
+
def enthalpy(t_db: float, w: float) -> float:
|
| 255 |
+
"""
|
| 256 |
+
Calculate specific enthalpy of moist air.
|
| 257 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
t_db: Dry-bulb temperature in °C
|
| 261 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 262 |
+
|
| 263 |
+
Returns:
|
| 264 |
+
Specific enthalpy in J/kg dry air
|
| 265 |
+
"""
|
| 266 |
+
Psychrometrics.validate_inputs(t_db)
|
| 267 |
+
if w < 0:
|
| 268 |
+
raise ValueError("Humidity ratio cannot be negative")
|
| 269 |
+
|
| 270 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 271 |
+
h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
|
| 272 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 273 |
+
|
| 274 |
+
h = c_pa * t_db + w * (h_fg + c_pw * t_db)
|
| 275 |
+
|
| 276 |
+
return h
|
| 277 |
+
|
| 278 |
+
@staticmethod
|
| 279 |
+
def specific_volume(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 280 |
+
"""
|
| 281 |
+
Calculate specific volume of moist air.
|
| 282 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 28.
|
| 283 |
+
|
| 284 |
+
Args:
|
| 285 |
+
t_db: Dry-bulb temperature in °C
|
| 286 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 287 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 288 |
+
|
| 289 |
+
Returns:
|
| 290 |
+
Specific volume in m³/kg dry air
|
| 291 |
+
"""
|
| 292 |
+
Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
|
| 293 |
+
if w < 0:
|
| 294 |
+
raise ValueError("Humidity ratio cannot be negative")
|
| 295 |
+
|
| 296 |
+
# Convert temperature to Kelvin
|
| 297 |
+
t_k = t_db + 273.15
|
| 298 |
+
|
| 299 |
+
r_da = GAS_CONSTANT_DRY_AIR # Gas constant for dry air in J/(kg·K)
|
| 300 |
+
|
| 301 |
+
v = r_da * t_k * (1 + 1.607858 * w) / p_atm
|
| 302 |
+
|
| 303 |
+
return v
|
| 304 |
+
|
| 305 |
+
@staticmethod
|
| 306 |
+
def density(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
|
| 307 |
+
"""
|
| 308 |
+
Calculate density of moist air.
|
| 309 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, derived from Equation 28.
|
| 310 |
+
|
| 311 |
+
Args:
|
| 312 |
+
t_db: Dry-bulb temperature in °C
|
| 313 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 314 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 315 |
+
|
| 316 |
+
Returns:
|
| 317 |
+
Density in kg/m³
|
| 318 |
+
"""
|
| 319 |
+
Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
|
| 320 |
+
if w < 0:
|
| 321 |
+
raise ValueError("Humidity ratio cannot be negative")
|
| 322 |
+
|
| 323 |
+
# Calculate specific volume
|
| 324 |
+
v = Psychrometrics.specific_volume(t_db, w, p_atm)
|
| 325 |
+
|
| 326 |
+
# Density is the reciprocal of specific volume
|
| 327 |
+
rho = (1 + w) / v
|
| 328 |
+
|
| 329 |
+
return rho
|
| 330 |
+
|
| 331 |
+
@staticmethod
|
| 332 |
+
def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
|
| 333 |
+
"""
|
| 334 |
+
Calculate all psychrometric properties of moist air.
|
| 335 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
|
| 336 |
+
|
| 337 |
+
Args:
|
| 338 |
+
t_db: Dry-bulb temperature in °C
|
| 339 |
+
rh: Relative humidity (0-100)
|
| 340 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
Dictionary with all psychrometric properties
|
| 344 |
+
"""
|
| 345 |
+
Psychrometrics.validate_inputs(t_db, rh, p_atm)
|
| 346 |
+
|
| 347 |
+
# Calculate humidity ratio
|
| 348 |
+
w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
|
| 349 |
+
|
| 350 |
+
# Calculate wet-bulb temperature
|
| 351 |
+
t_wb = Psychrometrics.wet_bulb_temperature(t_db, rh, p_atm)
|
| 352 |
+
|
| 353 |
+
# Calculate dew point temperature
|
| 354 |
+
t_dp = Psychrometrics.dew_point_temperature(t_db, rh)
|
| 355 |
+
|
| 356 |
+
# Calculate enthalpy
|
| 357 |
+
h = Psychrometrics.enthalpy(t_db, w)
|
| 358 |
+
|
| 359 |
+
# Calculate specific volume
|
| 360 |
+
v = Psychrometrics.specific_volume(t_db, w, p_atm)
|
| 361 |
+
|
| 362 |
+
# Calculate density
|
| 363 |
+
rho = Psychrometrics.density(t_db, w, p_atm)
|
| 364 |
+
|
| 365 |
+
# Calculate saturation pressure
|
| 366 |
+
p_ws = Psychrometrics.saturation_pressure(t_db)
|
| 367 |
+
|
| 368 |
+
# Calculate partial pressure of water vapor
|
| 369 |
+
p_w = rh / 100.0 * p_ws
|
| 370 |
+
|
| 371 |
+
# Return all properties
|
| 372 |
+
return {
|
| 373 |
+
"dry_bulb_temperature": t_db,
|
| 374 |
+
"wet_bulb_temperature": t_wb,
|
| 375 |
+
"dew_point_temperature": t_dp,
|
| 376 |
+
"relative_humidity": rh,
|
| 377 |
+
"humidity_ratio": w,
|
| 378 |
+
"enthalpy": h,
|
| 379 |
+
"specific_volume": v,
|
| 380 |
+
"density": rho,
|
| 381 |
+
"saturation_pressure": p_ws,
|
| 382 |
+
"partial_pressure": p_w,
|
| 383 |
+
"atmospheric_pressure": p_atm
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
@staticmethod
|
| 387 |
+
def find_humidity_ratio_for_enthalpy(t_db: float, h: float) -> float:
|
| 388 |
+
"""
|
| 389 |
+
Find humidity ratio for a given dry-bulb temperature and enthalpy.
|
| 390 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
|
| 391 |
+
|
| 392 |
+
Args:
|
| 393 |
+
t_db: Dry-bulb temperature in °C
|
| 394 |
+
h: Specific enthalpy in J/kg dry air
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
Humidity ratio in kg water vapor / kg dry air
|
| 398 |
+
"""
|
| 399 |
+
Psychrometrics.validate_inputs(t_db)
|
| 400 |
+
if h < 0:
|
| 401 |
+
raise ValueError("Enthalpy cannot be negative")
|
| 402 |
+
|
| 403 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 404 |
+
h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
|
| 405 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 406 |
+
|
| 407 |
+
w = (h - c_pa * t_db) / (h_fg + c_pw * t_db)
|
| 408 |
+
|
| 409 |
+
return max(0, w)
|
| 410 |
+
|
| 411 |
+
@staticmethod
|
| 412 |
+
def find_temperature_for_enthalpy(w: float, h: float) -> float:
|
| 413 |
+
"""
|
| 414 |
+
Find dry-bulb temperature for a given humidity ratio and enthalpy.
|
| 415 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
|
| 416 |
+
|
| 417 |
+
Args:
|
| 418 |
+
w: Humidity ratio in kg water vapor / kg dry air
|
| 419 |
+
h: Specific enthalpy in J/kg dry air
|
| 420 |
+
|
| 421 |
+
Returns:
|
| 422 |
+
Dry-bulb temperature in °C
|
| 423 |
+
"""
|
| 424 |
+
if w < 0:
|
| 425 |
+
raise ValueError("Humidity ratio cannot be negative")
|
| 426 |
+
if h < 0:
|
| 427 |
+
raise ValueError("Enthalpy cannot be negative")
|
| 428 |
+
|
| 429 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 430 |
+
h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
|
| 431 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 432 |
+
|
| 433 |
+
t_db = (h - w * h_fg) / (c_pa + w * c_pw)
|
| 434 |
+
|
| 435 |
+
Psychrometrics.validate_inputs(t_db)
|
| 436 |
+
return t_db
|
| 437 |
+
|
| 438 |
+
@staticmethod
|
| 439 |
+
def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
|
| 440 |
+
"""
|
| 441 |
+
Calculate sensible heat ratio.
|
| 442 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.5.
|
| 443 |
+
|
| 444 |
+
Args:
|
| 445 |
+
q_sensible: Sensible heat load in W
|
| 446 |
+
q_total: Total heat load in W
|
| 447 |
+
|
| 448 |
+
Returns:
|
| 449 |
+
Sensible heat ratio (0-1)
|
| 450 |
+
"""
|
| 451 |
+
if q_total == 0:
|
| 452 |
+
return 1.0
|
| 453 |
+
if q_sensible < 0 or q_total < 0:
|
| 454 |
+
raise ValueError("Heat loads cannot be negative")
|
| 455 |
+
|
| 456 |
+
return q_sensible / q_total
|
| 457 |
+
|
| 458 |
+
@staticmethod
|
| 459 |
+
def air_flow_rate_for_load(q_sensible: float, t_supply: float, t_return: float,
|
| 460 |
+
rh_return: float = 50.0, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
|
| 461 |
+
"""
|
| 462 |
+
Calculate required air flow rate for a given sensible load.
|
| 463 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.6.
|
| 464 |
+
|
| 465 |
+
Args:
|
| 466 |
+
q_sensible: Sensible heat load in W
|
| 467 |
+
t_supply: Supply air temperature in °C
|
| 468 |
+
t_return: Return air temperature in °C
|
| 469 |
+
rh_return: Return air relative humidity in % (default: 50%)
|
| 470 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 471 |
+
|
| 472 |
+
Returns:
|
| 473 |
+
Dictionary with air flow rate in different units
|
| 474 |
+
"""
|
| 475 |
+
Psychrometrics.validate_inputs(t_return, rh_return, p_atm)
|
| 476 |
+
Psychrometrics.validate_inputs(t_supply)
|
| 477 |
+
|
| 478 |
+
# Calculate return air properties
|
| 479 |
+
w_return = Psychrometrics.humidity_ratio(t_return, rh_return, p_atm)
|
| 480 |
+
rho_return = Psychrometrics.density(t_return, w_return, p_atm)
|
| 481 |
+
|
| 482 |
+
# Calculate specific heat of moist air
|
| 483 |
+
c_pa = 1006 # Specific heat of dry air in J/(kg·K)
|
| 484 |
+
c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
|
| 485 |
+
c_p_moist = c_pa + w_return * c_pw
|
| 486 |
+
|
| 487 |
+
# Calculate mass flow rate
|
| 488 |
+
delta_t = t_return - t_supply
|
| 489 |
+
if delta_t == 0:
|
| 490 |
+
raise ValueError("Supply and return temperatures cannot be equal")
|
| 491 |
+
|
| 492 |
+
m_dot = q_sensible / (c_p_moist * delta_t)
|
| 493 |
+
|
| 494 |
+
# Calculate volumetric flow rate
|
| 495 |
+
v_dot = m_dot / rho_return
|
| 496 |
+
|
| 497 |
+
# Convert to different units
|
| 498 |
+
v_dot_m3_s = v_dot
|
| 499 |
+
v_dot_m3_h = v_dot * 3600
|
| 500 |
+
v_dot_cfm = v_dot * 2118.88
|
| 501 |
+
v_dot_l_s = v_dot * 1000
|
| 502 |
+
|
| 503 |
+
return {
|
| 504 |
+
"mass_flow_rate_kg_s": m_dot,
|
| 505 |
+
"volumetric_flow_rate_m3_s": v_dot_m3_s,
|
| 506 |
+
"volumetric_flow_rate_m3_h": v_dot_m3_h,
|
| 507 |
+
"volumetric_flow_rate_cfm": v_dot_cfm,
|
| 508 |
+
"volumetric_flow_rate_l_s": v_dot_l_s
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
@staticmethod
|
| 512 |
+
def mixing_air_properties(m1: float, t_db1: float, rh1: float,
|
| 513 |
+
m2: float, t_db2: float, rh2: float,
|
| 514 |
+
p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
|
| 515 |
+
"""
|
| 516 |
+
Calculate properties of mixed airstreams.
|
| 517 |
+
Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.7.
|
| 518 |
+
|
| 519 |
+
Args:
|
| 520 |
+
m1: Mass flow rate of airstream 1 in kg/s
|
| 521 |
+
t_db1: Dry-bulb temperature of airstream 1 in °C
|
| 522 |
+
rh1: Relative humidity of airstream 1 in %
|
| 523 |
+
m2: Mass flow rate of airstream 2 in kg/s
|
| 524 |
+
t_db2: Dry-bulb temperature of airstream 2 in °C
|
| 525 |
+
rh2: Relative humidity of airstream 2 in %
|
| 526 |
+
p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
|
| 527 |
+
|
| 528 |
+
Returns:
|
| 529 |
+
Dictionary with mixed air properties
|
| 530 |
+
"""
|
| 531 |
+
Psychrometrics.validate_inputs(t_db1, rh1, p_atm)
|
| 532 |
+
Psychrometrics.validate_inputs(t_db2, rh2, p_atm)
|
| 533 |
+
if m1 < 0 or m2 < 0:
|
| 534 |
+
raise ValueError("Mass flow rates cannot be negative")
|
| 535 |
+
|
| 536 |
+
# Calculate humidity ratios
|
| 537 |
+
w1 = Psychrometrics.humidity_ratio(t_db1, rh1, p_atm)
|
| 538 |
+
w2 = Psychrometrics.humidity_ratio(t_db2, rh2, p_atm)
|
| 539 |
+
|
| 540 |
+
# Calculate enthalpies
|
| 541 |
+
h1 = Psychrometrics.enthalpy(t_db1, w1)
|
| 542 |
+
h2 = Psychrometrics.enthalpy(t_db2, w2)
|
| 543 |
+
|
| 544 |
+
# Calculate mixed air properties
|
| 545 |
+
m_total = m1 + m2
|
| 546 |
+
|
| 547 |
+
if m_total == 0:
|
| 548 |
+
raise ValueError("Total mass flow rate cannot be zero")
|
| 549 |
+
|
| 550 |
+
w_mix = (m1 * w1 + m2 * w2) / m_total
|
| 551 |
+
h_mix = (m1 * h1 + m2 * h2) / m_total
|
| 552 |
+
|
| 553 |
+
# Find dry-bulb temperature for the mixed air
|
| 554 |
+
t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
|
| 555 |
+
|
| 556 |
+
# Calculate relative humidity for the mixed air
|
| 557 |
+
rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
|
| 558 |
+
|
| 559 |
+
# Return mixed air properties
|
| 560 |
+
return Psychrometrics.moist_air_properties(t_db_mix, rh_mix, p_atm)
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
# Create a singleton instance
|
| 564 |
+
psychrometrics = Psychrometrics()
|
| 565 |
+
|
| 566 |
+
# Example usage
|
| 567 |
+
if __name__ == "__main__":
|
| 568 |
+
# Calculate properties of air at 25°C and 50% RH
|
| 569 |
+
properties = psychrometrics.moist_air_properties(25, 50)
|
| 570 |
+
|
| 571 |
+
print("Air Properties at 25°C and 50% RH:")
|
| 572 |
+
print(f"Dry-bulb temperature: {properties['dry_bulb_temperature']:.2f} °C")
|
| 573 |
+
print(f"Wet-bulb temperature: {properties['wet_bulb_temperature']:.2f} °C")
|
| 574 |
+
print(f"Dew point temperature: {properties['dew_point_temperature']:.2f} °C")
|
| 575 |
+
print(f"Relative humidity: {properties['relative_humidity']:.2f} %")
|
| 576 |
+
print(f"Humidity ratio: {properties['humidity_ratio']:.6f} kg/kg")
|
| 577 |
+
print(f"Enthalpy: {properties['enthalpy']/1000:.2f} kJ/kg")
|
| 578 |
+
print(f"Specific volume: {properties['specific_volume']:.4f} m³/kg")
|
| 579 |
+
print(f"Density: {properties['density']:.4f} kg/m³")
|
| 580 |
+
print(f"Saturation pressure: {properties['saturation_pressure']/1000:.2f} kPa")
|
| 581 |
+
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"))
|