Spaces:
Sleeping
Sleeping
Update app/hvac_loads.py
Browse files- app/hvac_loads.py +925 -28
app/hvac_loads.py
CHANGED
@@ -8,43 +8,940 @@ modules to determine the building's thermal loads.
|
|
8 |
Developed by: Dr Majed Abuseif, Deakin University
|
9 |
© 2025
|
10 |
"""
|
|
|
11 |
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
st.text_input("City", key="city")
|
22 |
-
st.number_input("Latitude (°)", min_value=-90.0, max_value=90.0, value=0.0, step=0.1, key="latitude")
|
23 |
-
st.number_input("Longitude (°)", min_value=-180.0, max_value=180.0, value=0.0, step=0.1, key="longitude")
|
24 |
-
with col2:
|
25 |
-
st.number_input("Time Zone", min_value=-12.0, max_value=14.0, value=0.0, step=0.5, key="time_zone")
|
26 |
-
st.number_input("Ground Reflectivity", min_value=0.0, max_value=1.0, value=0.2, step=0.01, key="ground_reflectivity")
|
27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
# Simulation Period
|
29 |
-
with st.expander("Simulation Period"
|
30 |
-
|
31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
col1, col2 = st.columns(2)
|
33 |
with col1:
|
34 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
with col2:
|
36 |
-
st.
|
37 |
-
|
38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
|
40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
col1, col2, col3 = st.columns(3)
|
42 |
with col1:
|
43 |
-
st.
|
|
|
44 |
with col2:
|
45 |
-
st.
|
46 |
-
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
|
49 |
-
|
50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
Developed by: Dr Majed Abuseif, Deakin University
|
9 |
© 2025
|
10 |
"""
|
11 |
+
|
12 |
import streamlit as st
|
13 |
+
import pandas as pd
|
14 |
+
import numpy as np
|
15 |
+
import json
|
16 |
+
import logging
|
17 |
+
import uuid
|
18 |
+
import plotly.graph_objects as go
|
19 |
+
import plotly.express as px
|
20 |
+
from typing import Dict, List, Any, Optional, Tuple, Union
|
21 |
+
from datetime import datetime
|
22 |
+
import math
|
23 |
+
from collections import defaultdict
|
24 |
+
|
25 |
+
# Configure logging
|
26 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
27 |
+
logger = logging.getLogger(__name__)
|
28 |
|
29 |
+
# Constants
|
30 |
+
STEFAN_BOLTZMANN = 5.67e-8 # W/m²·K⁴
|
31 |
+
ABSORPTIVITY_DEFAULT = 0.7 # Default surface absorptivity
|
32 |
+
EMISSIVITY_DEFAULT = 0.9 # Default surface emissivity
|
33 |
+
SKY_TEMP_FACTOR = 0.0552 # Factor for sky temperature calculation
|
34 |
+
AIR_DENSITY = 1.2 # kg/m³
|
35 |
+
AIR_SPECIFIC_HEAT = 1005 # J/kg·K
|
36 |
+
LATENT_HEAT_VAPORIZATION = 2260000 # J/kg
|
|
|
|
|
|
|
|
|
|
|
|
|
37 |
|
38 |
+
def display_hvac_loads_page():
|
39 |
+
"""
|
40 |
+
Display the HVAC loads calculation page.
|
41 |
+
This is the main function called by main.py when the HVAC Loads page is selected.
|
42 |
+
"""
|
43 |
+
st.title("HVAC Load Calculations")
|
44 |
+
|
45 |
+
# Display help information in an expandable section
|
46 |
+
with st.expander("Help & Information"):
|
47 |
+
display_hvac_loads_help()
|
48 |
+
|
49 |
+
# Check if necessary data is available
|
50 |
+
if not check_prerequisites():
|
51 |
+
st.warning("Please complete all previous steps (Building Info, Climate, Materials, Construction, Components, Internal Loads) before calculating HVAC loads.")
|
52 |
+
return
|
53 |
+
|
54 |
+
# --- User Input for Simulation Parameters --- #
|
55 |
+
st.subheader("Simulation Parameters")
|
56 |
+
|
57 |
# Simulation Period
|
58 |
+
with st.expander("Simulation Period"):
|
59 |
+
sim_period_type = st.selectbox(
|
60 |
+
"Simulation Period Type",
|
61 |
+
["Full Year", "From-to", "HDD", "CDD"],
|
62 |
+
key="sim_period_type"
|
63 |
+
)
|
64 |
+
sim_period = {"type": sim_period_type}
|
65 |
+
|
66 |
+
if sim_period_type == "From-to":
|
67 |
+
col1, col2 = st.columns(2)
|
68 |
+
with col1:
|
69 |
+
start_date = st.date_input("Start Date", value=datetime(2025, 1, 1))
|
70 |
+
with col2:
|
71 |
+
end_date = st.date_input("End Date", value=datetime(2025, 12, 31))
|
72 |
+
sim_period["start_date"] = start_date
|
73 |
+
sim_period["end_date"] = end_date
|
74 |
+
elif sim_period_type in ["HDD", "CDD"]:
|
75 |
+
base_temp = st.number_input(
|
76 |
+
f"Base Temperature (°C) for {sim_period_type}",
|
77 |
+
value=18.3 if sim_period_type == "HDD" else 23.9,
|
78 |
+
min_value=0.0,
|
79 |
+
max_value=40.0,
|
80 |
+
step=0.1
|
81 |
+
)
|
82 |
+
sim_period["base_temp"] = base_temp
|
83 |
+
|
84 |
+
# Indoor Conditions
|
85 |
+
with st.expander("Indoor Conditions"):
|
86 |
+
indoor_cond_type = st.selectbox(
|
87 |
+
"Indoor Conditions Type",
|
88 |
+
["Fixed", "Time-varying", "Adaptive"],
|
89 |
+
key="indoor_conditions_type"
|
90 |
+
)
|
91 |
+
indoor_conditions = {"type": indoor_cond_type}
|
92 |
+
|
93 |
+
if indoor_cond_type == "Fixed":
|
94 |
col1, col2 = st.columns(2)
|
95 |
with col1:
|
96 |
+
cooling_temp = st.number_input(
|
97 |
+
"Cooling Setpoint Temperature (°C)",
|
98 |
+
value=24.0,
|
99 |
+
min_value=15.0,
|
100 |
+
max_value=30.0,
|
101 |
+
step=0.5
|
102 |
+
)
|
103 |
+
cooling_rh = st.number_input(
|
104 |
+
"Cooling Setpoint Relative Humidity (%)",
|
105 |
+
value=50.0,
|
106 |
+
min_value=30.0,
|
107 |
+
max_value=70.0,
|
108 |
+
step=5.0
|
109 |
+
)
|
110 |
with col2:
|
111 |
+
heating_temp = st.number_input(
|
112 |
+
"Heating Setpoint Temperature (°C)",
|
113 |
+
value=22.0,
|
114 |
+
min_value=15.0,
|
115 |
+
max_value=30.0,
|
116 |
+
step=0.5
|
117 |
+
)
|
118 |
+
heating_rh = st.number_input(
|
119 |
+
"Heating Setpoint Relative Humidity (%)",
|
120 |
+
value=50.0,
|
121 |
+
min_value=30.0,
|
122 |
+
max_value=70.0,
|
123 |
+
step=5.0
|
124 |
+
)
|
125 |
+
indoor_conditions["cooling_setpoint"] = {"temperature": cooling_temp, "rh": cooling_rh}
|
126 |
+
indoor_conditions["heating_setpoint"] = {"temperature": heating_temp, "rh": heating_rh}
|
127 |
+
elif indoor_cond_type == "Time-varying":
|
128 |
+
st.write("Define Hourly Schedule")
|
129 |
+
schedule = []
|
130 |
+
for hour in range(24):
|
131 |
+
with st.container():
|
132 |
+
col1, col2, col3 = st.columns([1, 2, 2])
|
133 |
+
with col1:
|
134 |
+
st.write(f"Hour {hour}:00")
|
135 |
+
with col2:
|
136 |
+
temp = st.number_input(
|
137 |
+
"Temperature (°C)",
|
138 |
+
value=24.0,
|
139 |
+
min_value=15.0,
|
140 |
+
max_value=30.0,
|
141 |
+
step=0.5,
|
142 |
+
key=f"schedule_temp_{hour}"
|
143 |
+
)
|
144 |
+
with col3:
|
145 |
+
rh = st.number_input(
|
146 |
+
"Relative Humidity (%)",
|
147 |
+
value=50.0,
|
148 |
+
min_value=30.0,
|
149 |
+
max_value=70.0,
|
150 |
+
step=5.0,
|
151 |
+
key=f"schedule_rh_{hour}"
|
152 |
+
)
|
153 |
+
schedule.append({"hour": hour, "temperature": temp, "rh": rh})
|
154 |
+
indoor_conditions["schedule"] = schedule
|
155 |
+
|
156 |
+
# Operating Hours
|
157 |
+
with st.expander("HVAC Operating Hours"):
|
158 |
+
num_periods = st.number_input(
|
159 |
+
"Number of Operating Periods",
|
160 |
+
value=1,
|
161 |
+
min_value=1,
|
162 |
+
max_value=5,
|
163 |
+
step=1
|
164 |
+
)
|
165 |
+
operating_periods = []
|
166 |
+
for i in range(int(num_periods)):
|
167 |
+
with st.container():
|
168 |
+
st.write(f"Operating Period {i+1}")
|
169 |
+
col1, col2 = st.columns(2)
|
170 |
+
with col1:
|
171 |
+
start_hour = st.number_input(
|
172 |
+
"Start Hour",
|
173 |
+
value=8,
|
174 |
+
min_value=0,
|
175 |
+
max_value=23,
|
176 |
+
step=1,
|
177 |
+
key=f"start_hour_{i}"
|
178 |
+
)
|
179 |
+
with col2:
|
180 |
+
end_hour = st.number_input(
|
181 |
+
"End Hour",
|
182 |
+
value=18,
|
183 |
+
min_value=0,
|
184 |
+
max_value=23,
|
185 |
+
step=1,
|
186 |
+
key=f"end_hour_{i}"
|
187 |
+
)
|
188 |
+
operating_periods.append({"start": start_hour, "end": end_hour})
|
189 |
+
hvac_settings = {"operating_hours": operating_periods}
|
190 |
+
|
191 |
+
# Calculation trigger
|
192 |
+
if st.button("Calculate HVAC Loads", key="calculate_hvac_loads"):
|
193 |
+
try:
|
194 |
+
results = calculate_loads(
|
195 |
+
sim_period=sim_period,
|
196 |
+
indoor_conditions=indoor_conditions,
|
197 |
+
hvac_settings=hvac_settings
|
198 |
+
)
|
199 |
+
st.session_state.project_data["hvac_loads"] = results
|
200 |
+
st.success("HVAC loads calculated successfully.")
|
201 |
+
logger.info("HVAC loads calculated.")
|
202 |
+
except Exception as e:
|
203 |
+
st.error(f"Error calculating HVAC loads: {e}")
|
204 |
+
logger.error(f"Error calculating HVAC loads: {e}", exc_info=True)
|
205 |
+
st.session_state.project_data["hvac_loads"] = None
|
206 |
+
|
207 |
+
# Display results if available
|
208 |
+
if "hvac_loads" in st.session_state.project_data and st.session_state.project_data["hvac_loads"]:
|
209 |
+
display_load_results(st.session_state.project_data["hvac_loads"])
|
210 |
+
else:
|
211 |
+
st.info("Click the button above to calculate HVAC loads.")
|
212 |
+
|
213 |
+
# Navigation buttons
|
214 |
+
col1, col2 = st.columns(2)
|
215 |
+
|
216 |
+
with col1:
|
217 |
+
if st.button("Back to Internal Loads", key="back_to_internal_loads"):
|
218 |
+
st.session_state.current_page = "Internal Loads"
|
219 |
+
st.rerun()
|
220 |
+
|
221 |
+
with col2:
|
222 |
+
if st.button("Continue to Building Energy", key="continue_to_building_energy"):
|
223 |
+
st.session_state.current_page = "Building Energy"
|
224 |
+
st.rerun()
|
225 |
+
|
226 |
+
def check_prerequisites() -> bool:
|
227 |
+
"""Check if all prerequisite data is available in session state."""
|
228 |
+
required_keys = [
|
229 |
+
"building_info",
|
230 |
+
"climate_data",
|
231 |
+
"materials",
|
232 |
+
"constructions",
|
233 |
+
"components",
|
234 |
+
"internal_loads"
|
235 |
+
]
|
236 |
+
|
237 |
+
for key in required_keys:
|
238 |
+
if key not in st.session_state.project_data or not st.session_state.project_data[key]:
|
239 |
+
logger.warning(f"Prerequisite check failed: Missing data for '{key}'.")
|
240 |
+
return False
|
241 |
+
|
242 |
+
# Specific checks within components
|
243 |
+
components = st.session_state.project_data["components"]
|
244 |
+
if not components.get("walls") and not components.get("roofs") and not components.get("floors"):
|
245 |
+
logger.warning("Prerequisite check failed: No opaque components defined.")
|
246 |
+
return False
|
247 |
+
|
248 |
+
# Specific checks within climate data
|
249 |
+
climate = st.session_state.project_data["climate_data"]
|
250 |
+
if not climate.get("hourly_data") or not climate.get("design_conditions"):
|
251 |
+
logger.warning("Prerequisite check failed: Climate data not processed.")
|
252 |
+
return False
|
253 |
+
|
254 |
+
return True
|
255 |
+
|
256 |
+
def filter_hourly_data(hourly_data: List[Dict], sim_period: Dict, climate_data: Dict) -> List[Dict]:
|
257 |
+
"""Filter hourly data based on simulation period, ignoring year."""
|
258 |
+
if sim_period["type"] == "Full Year":
|
259 |
+
return hourly_data
|
260 |
+
filtered_data = []
|
261 |
+
if sim_period["type"] == "From-to":
|
262 |
+
start_month = sim_period["start_date"].month
|
263 |
+
start_day = sim_period["start_date"].day
|
264 |
+
end_month = sim_period["end_date"].month
|
265 |
+
end_day = sim_period["end_date"].day
|
266 |
+
for data in hourly_data:
|
267 |
+
month, day = data["month"], data["day"]
|
268 |
+
if (month > start_month or (month == start_month and day >= start_day)) and \
|
269 |
+
(month < end_month or (month == end_month and day <= end_day)):
|
270 |
+
filtered_data.append(data)
|
271 |
+
elif sim_period["type"] in ["HDD", "CDD"]:
|
272 |
+
base_temp = sim_period.get("base_temp", 18.3 if sim_period["type"] == "HDD" else 23.9)
|
273 |
+
for data in hourly_data:
|
274 |
+
temp = data["dry_bulb"]
|
275 |
+
if (sim_period["type"] == "HDD" and temp < base_temp) or (sim_period["type"] == "CDD" and temp > base_temp):
|
276 |
+
filtered_data.append(data)
|
277 |
+
return filtered_data
|
278 |
+
|
279 |
+
def get_indoor_conditions(indoor_conditions: Dict, hour: int, outdoor_temp: float) -> Dict:
|
280 |
+
"""Determine indoor conditions based on user settings."""
|
281 |
+
if indoor_conditions["type"] == "Fixed":
|
282 |
+
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
|
283 |
+
if mode == "cooling":
|
284 |
+
return {
|
285 |
+
"temperature": indoor_conditions.get("cooling_setpoint", {}).get("temperature", 24.0),
|
286 |
+
"rh": indoor_conditions.get("cooling_setpoint", {}).get("rh", 50.0)
|
287 |
+
}
|
288 |
+
elif mode == "heating":
|
289 |
+
return {
|
290 |
+
"temperature": indoor_conditions.get("heating_setpoint", {}).get("temperature", 22.0),
|
291 |
+
"rh": indoor_conditions.get("heating_setpoint", {}).get("rh", 50.0)
|
292 |
+
}
|
293 |
+
else:
|
294 |
+
return {"temperature": 24.0, "rh": 50.0}
|
295 |
+
elif indoor_conditions["type"] == "Time-varying":
|
296 |
+
schedule = indoor_conditions.get("schedule", [])
|
297 |
+
if schedule:
|
298 |
+
hour_idx = hour % 24
|
299 |
+
for entry in schedule:
|
300 |
+
if entry["hour"] == hour_idx:
|
301 |
+
return {"temperature": entry["temperature"], "rh": entry["rh"]}
|
302 |
+
return {"temperature": 24.0, "rh": 50.0}
|
303 |
+
else: # Adaptive
|
304 |
+
if 10 <= outdoor_temp <= 33.5:
|
305 |
+
return {"temperature": 0.31 * outdoor_temp + 17.8, "rh": 50.0}
|
306 |
+
return {"temperature": 24.0, "rh": 50.0}
|
307 |
|
308 |
+
def calculate_loads(sim_period: Dict, indoor_conditions: Dict, hvac_settings: Dict) -> Dict[str, Any]:
|
309 |
+
"""
|
310 |
+
Perform the main HVAC load calculations with user-defined filters and temperature threshold.
|
311 |
+
|
312 |
+
Args:
|
313 |
+
sim_period: Simulation period settings.
|
314 |
+
indoor_conditions: Indoor conditions settings.
|
315 |
+
hvac_settings: HVAC operating hours settings.
|
316 |
+
|
317 |
+
Returns:
|
318 |
+
Dictionary containing calculation results.
|
319 |
+
"""
|
320 |
+
logger.info("Starting HVAC load calculations...")
|
321 |
+
|
322 |
+
# Get data from session state
|
323 |
+
building_info = st.session_state.project_data["building_info"]
|
324 |
+
climate_data = st.session_state.project_data["climate_data"]
|
325 |
+
materials = get_available_materials()
|
326 |
+
constructions = get_available_constructions()
|
327 |
+
fenestrations = get_available_fenestrations()
|
328 |
+
components = st.session_state.project_data["components"]
|
329 |
+
internal_loads = st.session_state.project_data["internal_loads"]
|
330 |
+
|
331 |
+
# Get design conditions
|
332 |
+
design_conditions = climate_data["design_conditions"]
|
333 |
+
hourly_data_list = climate_data["hourly_data"]
|
334 |
+
|
335 |
+
# Filter hourly data based on simulation period
|
336 |
+
filtered_hourly_data = filter_hourly_data(hourly_data_list, sim_period, climate_data)
|
337 |
+
hourly_data_df = pd.DataFrame(filtered_hourly_data)
|
338 |
+
if hourly_data_df.empty:
|
339 |
+
logger.warning("No hourly data available after filtering.")
|
340 |
+
return {}
|
341 |
+
|
342 |
+
# --- Solar Calculations --- #
|
343 |
+
logger.info("Calculating solar geometry...")
|
344 |
+
latitude = climate_data["location"]["latitude"]
|
345 |
+
longitude = climate_data["location"]["longitude"]
|
346 |
+
timezone = climate_data["location"]["timezone"]
|
347 |
+
building_orientation_angle = building_info["orientation_angle"]
|
348 |
+
|
349 |
+
# Calculate solar angles for filtered hours
|
350 |
+
solar_angles = calculate_solar_angles(latitude, longitude, timezone, hourly_data_df.index)
|
351 |
+
hourly_data_df = pd.concat([hourly_data_df, solar_angles], axis=1)
|
352 |
+
|
353 |
+
# Initialize load arrays
|
354 |
+
num_hours = len(filtered_hourly_data)
|
355 |
+
cooling_loads = {
|
356 |
+
"opaque_conduction": np.zeros(num_hours),
|
357 |
+
"fenestration_conduction": np.zeros(num_hours),
|
358 |
+
"fenestration_solar": np.zeros(num_hours),
|
359 |
+
"infiltration": np.zeros(num_hours),
|
360 |
+
"ventilation": np.zeros(num_hours),
|
361 |
+
"internal_sensible": np.zeros(num_hours),
|
362 |
+
"internal_latent": np.zeros(num_hours)
|
363 |
+
}
|
364 |
+
heating_loads = {
|
365 |
+
"opaque_conduction": np.zeros(num_hours),
|
366 |
+
"fenestration_conduction": np.zeros(num_hours),
|
367 |
+
"infiltration": np.zeros(num_hours),
|
368 |
+
"ventilation": np.zeros(num_hours)
|
369 |
+
}
|
370 |
+
|
371 |
+
# --- Load Calculations --- #
|
372 |
+
operating_periods = hvac_settings.get("operating_hours", [{"start": 8, "end": 18}])
|
373 |
+
area = building_info["floor_area"]
|
374 |
+
|
375 |
+
for idx, hour_data in enumerate(filtered_hourly_data):
|
376 |
+
hour = hour_data["hour"]
|
377 |
+
outdoor_temp = hour_data["dry_bulb"]
|
378 |
+
indoor_cond = get_indoor_conditions(indoor_conditions, hour, outdoor_temp)
|
379 |
+
indoor_temp = indoor_cond["temperature"]
|
380 |
+
|
381 |
+
# Check if hour is within operating periods
|
382 |
+
is_operating = False
|
383 |
+
for period in operating_periods:
|
384 |
+
start_hour = period.get("start", 8)
|
385 |
+
end_hour = period.get("end", 18)
|
386 |
+
if start_hour <= hour % 24 <= end_hour:
|
387 |
+
is_operating = True
|
388 |
+
break
|
389 |
+
|
390 |
+
# Determine mode based on temperature threshold (18°C)
|
391 |
+
mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
|
392 |
+
|
393 |
+
if not is_operating:
|
394 |
+
continue
|
395 |
+
|
396 |
+
# 1. Opaque Surfaces (Walls, Roofs, Floors)
|
397 |
+
for comp_type in ["walls", "roofs", "floors"]:
|
398 |
+
for comp in components.get(comp_type, []):
|
399 |
+
logger.debug(f"Calculating loads for {comp_type}: {comp['name']}")
|
400 |
+
construction = constructions[comp['construction']]
|
401 |
+
u_value = construction['u_value']
|
402 |
+
area = comp['area']
|
403 |
+
tilt = comp['tilt']
|
404 |
+
orientation = comp['orientation']
|
405 |
+
|
406 |
+
# Calculate surface azimuth
|
407 |
+
surface_azimuth = calculate_surface_azimuth(orientation, building_orientation_angle)
|
408 |
+
|
409 |
+
# Calculate sol-air temperature
|
410 |
+
sol_air_temp = calculate_sol_air_temperature(
|
411 |
+
pd.Series([outdoor_temp], index=[idx]),
|
412 |
+
pd.Series([hour_data.get("direct_normal_radiation", 0)], index=[idx]),
|
413 |
+
pd.Series([hour_data.get("diffuse_horizontal_radiation", 0)], index=[idx]),
|
414 |
+
pd.Series([hourly_data_df.loc[idx, "solar_altitude"]], index=[idx]),
|
415 |
+
pd.Series([hourly_data_df.loc[idx, "solar_azimuth"]], index=[idx]),
|
416 |
+
tilt,
|
417 |
+
surface_azimuth,
|
418 |
+
absorptivity=ABSORPTIVITY_DEFAULT,
|
419 |
+
emissivity=EMISSIVITY_DEFAULT,
|
420 |
+
h_o=20.0
|
421 |
+
)[0]
|
422 |
+
|
423 |
+
# Calculate conduction heat gain/loss
|
424 |
+
if mode == "cooling":
|
425 |
+
delta_t = sol_air_temp - indoor_temp
|
426 |
+
if delta_t > 0:
|
427 |
+
cooling_loads["opaque_conduction"][idx] += u_value * area * delta_t
|
428 |
+
elif mode == "heating":
|
429 |
+
delta_t = indoor_temp - outdoor_temp
|
430 |
+
if delta_t > 0:
|
431 |
+
heating_loads["opaque_conduction"][idx] += u_value * area * delta_t
|
432 |
+
|
433 |
+
# 2. Fenestration (Windows, Doors, Skylights)
|
434 |
+
for comp_type in ["windows", "doors", "skylights"]:
|
435 |
+
for comp in components.get(comp_type, []):
|
436 |
+
logger.debug(f"Calculating loads for {comp_type}: {comp['name']}")
|
437 |
+
fenestration = fenestrations[comp['fenestration']]
|
438 |
+
u_value = fenestration['u_value']
|
439 |
+
shgc = fenestration['shgc']
|
440 |
+
area = comp['area']
|
441 |
+
tilt = comp['tilt']
|
442 |
+
orientation = comp['orientation']
|
443 |
+
|
444 |
+
# Calculate surface azimuth
|
445 |
+
surface_azimuth = calculate_surface_azimuth(orientation, building_orientation_angle)
|
446 |
+
|
447 |
+
# Calculate incident solar radiation
|
448 |
+
incident_solar = calculate_incident_solar(
|
449 |
+
pd.Series([hour_data.get("direct_normal_radiation", 0)], index=[idx]),
|
450 |
+
pd.Series([hour_data.get("diffuse_horizontal_radiation", 0)], index=[idx]),
|
451 |
+
pd.Series([hourly_data_df.loc[idx, "solar_altitude"]], index=[idx]),
|
452 |
+
pd.Series([hourly_data_df.loc[idx, "solar_azimuth"]], index=[idx]),
|
453 |
+
tilt,
|
454 |
+
surface_azimuth
|
455 |
+
)[0]
|
456 |
+
|
457 |
+
# Calculate conduction heat gain/loss
|
458 |
+
if mode == "cooling":
|
459 |
+
delta_t = outdoor_temp - indoor_temp
|
460 |
+
if delta_t > 0:
|
461 |
+
cooling_loads["fenestration_conduction"][idx] += u_value * area * delta_t
|
462 |
+
elif mode == "heating":
|
463 |
+
delta_t = indoor_temp - outdoor_temp
|
464 |
+
if delta_t > 0:
|
465 |
+
heating_loads["fenestration_conduction"][idx] += u_value * area * delta_t
|
466 |
+
|
467 |
+
# Calculate solar heat gain
|
468 |
+
if mode == "cooling":
|
469 |
+
solar_gain = shgc * area * incident_solar
|
470 |
+
cooling_loads["fenestration_solar"][idx] += solar_gain
|
471 |
+
|
472 |
+
# 3. Infiltration
|
473 |
+
if mode in ["cooling", "heating"]:
|
474 |
+
logger.info("Calculating infiltration loads...")
|
475 |
+
volume = building_info["floor_area"] * building_info["building_height"]
|
476 |
+
ach = 0.5
|
477 |
+
infiltration_rate_m3s = volume * ach / 3600.0
|
478 |
+
|
479 |
+
delta_t = outdoor_temp - indoor_temp if mode == "cooling" else indoor_temp - outdoor_temp
|
480 |
+
if delta_t > 0:
|
481 |
+
infiltration_sensible = AIR_DENSITY * AIR_SPECIFIC_HEAT * infiltration_rate_m3s * delta_t
|
482 |
+
if mode == "cooling":
|
483 |
+
cooling_loads["infiltration"][idx] += infiltration_sensible
|
484 |
+
else:
|
485 |
+
heating_loads["infiltration"][idx] += infiltration_sensible
|
486 |
+
|
487 |
+
# 4. Ventilation
|
488 |
+
if mode in ["cooling", "heating"]:
|
489 |
+
logger.info("Calculating ventilation loads...")
|
490 |
+
ventilation_rate_lps = building_info["ventilation_rate"]
|
491 |
+
ventilation_rate_m3s = ventilation_rate_lps * building_info["floor_area"] / 1000.0
|
492 |
+
|
493 |
+
delta_t = outdoor_temp - indoor_temp if mode == "cooling" else indoor_temp - outdoor_temp
|
494 |
+
if delta_t > 0:
|
495 |
+
ventilation_sensible = AIR_DENSITY * AIR_SPECIFIC_HEAT * ventilation_rate_m3s * delta_t
|
496 |
+
if mode == "cooling":
|
497 |
+
cooling_loads["ventilation"][idx] += ventilation_sensible
|
498 |
+
else:
|
499 |
+
heating_loads["ventilation"][idx] += ventilation_sensible
|
500 |
+
|
501 |
+
# 5. Internal Loads
|
502 |
+
if mode == "cooling":
|
503 |
+
logger.info("Calculating internal loads...")
|
504 |
+
internal_sensible, internal_latent = calculate_hourly_internal_loads(internal_loads)
|
505 |
+
cooling_loads["internal_sensible"][idx] = internal_sensible[idx % 8760]
|
506 |
+
cooling_loads["internal_latent"][idx] = internal_latent[idx % 8760]
|
507 |
+
|
508 |
+
# --- Total Loads --- #
|
509 |
+
logger.info("Summing up loads...")
|
510 |
+
total_cooling_sensible = (
|
511 |
+
cooling_loads["opaque_conduction"] +
|
512 |
+
cooling_loads["fenestration_conduction"] +
|
513 |
+
cooling_loads["fenestration_solar"] +
|
514 |
+
cooling_loads["infiltration"] +
|
515 |
+
cooling_loads["ventilation"] +
|
516 |
+
cooling_loads["internal_sensible"]
|
517 |
+
)
|
518 |
+
total_cooling_latent = cooling_loads["internal_latent"]
|
519 |
+
total_cooling = total_cooling_sensible + total_cooling_latent
|
520 |
+
|
521 |
+
total_heating_loss = (
|
522 |
+
heating_loads["opaque_conduction"] +
|
523 |
+
heating_loads["fenestration_conduction"] +
|
524 |
+
heating_loads["infiltration"] +
|
525 |
+
heating_loads["ventilation"]
|
526 |
+
)
|
527 |
+
total_heating_needed = np.maximum(0, total_heating_loss - cooling_loads["internal_sensible"])
|
528 |
+
|
529 |
+
# --- Daily Control --- #
|
530 |
+
loads_by_day = defaultdict(list)
|
531 |
+
for idx, hour_data in enumerate(filtered_hourly_data):
|
532 |
+
day_key = (hour_data["month"], hour_data["day"])
|
533 |
+
loads_by_day[day_key].append({
|
534 |
+
"hour": hour_data["hour"],
|
535 |
+
"month": hour_data["month"],
|
536 |
+
"day": hour_data["day"],
|
537 |
+
"total_cooling": total_cooling[idx],
|
538 |
+
"total_heating": total_heating_needed[idx],
|
539 |
+
"cooling_components": {k: v[idx] for k, v in cooling_loads.items()},
|
540 |
+
"heating_components": {k: v[idx] for k, v in heating_loads.items()}
|
541 |
+
})
|
542 |
+
|
543 |
+
final_loads = []
|
544 |
+
for day_key, day_loads in loads_by_day.items():
|
545 |
+
cooling_hours = sum(1 for load in day_loads if load["total_cooling"] > 0)
|
546 |
+
heating_hours = sum(1 for load in day_loads if load["total_heating"] > 0)
|
547 |
+
for load in day_loads:
|
548 |
+
if cooling_hours > heating_hours:
|
549 |
+
load["total_heating"] = 0
|
550 |
+
elif heating_hours > cooling_hours:
|
551 |
+
load["total_cooling"] = 0
|
552 |
+
else:
|
553 |
+
load["total_cooling"] = 0
|
554 |
+
load["total_heating"] = 0
|
555 |
+
final_loads.append(load)
|
556 |
+
|
557 |
+
# --- Peak Loads --- #
|
558 |
+
logger.info("Calculating peak loads...")
|
559 |
+
hourly_cooling_sensible = np.array([load["cooling_components"]["opaque_conduction"] +
|
560 |
+
load["cooling_components"]["fenestration_conduction"] +
|
561 |
+
load["cooling_components"]["fenestration_solar"] +
|
562 |
+
load["cooling_components"]["infiltration"] +
|
563 |
+
load["cooling_components"]["ventilation"] +
|
564 |
+
load["cooling_components"]["internal_sensible"]
|
565 |
+
for load in final_loads])
|
566 |
+
hourly_cooling_latent = np.array([load["cooling_components"]["internal_latent"] for load in final_loads])
|
567 |
+
hourly_cooling_total = hourly_cooling_sensible + hourly_cooling_latent
|
568 |
+
hourly_heating = np.array([load["total_heating"] for load in final_loads])
|
569 |
+
|
570 |
+
peak_cooling_sensible = np.max(hourly_cooling_sensible) if len(hourly_cooling_sensible) > 0 else 0
|
571 |
+
peak_cooling_latent = np.max(hourly_cooling_latent) if len(hourly_cooling_latent) > 0 else 0
|
572 |
+
peak_cooling_total = np.max(hourly_cooling_total) if len(hourly_cooling_total) > 0 else 0
|
573 |
+
peak_heating = np.max(hourly_heating) if len(hourly_heating) > 0 else 0
|
574 |
+
|
575 |
+
peak_cooling_sensible_hour = np.argmax(hourly_cooling_sensible) if len(hourly_cooling_sensible) > 0 else 0
|
576 |
+
peak_cooling_latent_hour = np.argmax(hourly_cooling_latent) if len(hourly_cooling_latent) > 0 else 0
|
577 |
+
peak_cooling_total_hour = np.argmax(hourly_cooling_total) if len(hourly_cooling_total) > 0 else 0
|
578 |
+
peak_heating_hour = np.argmax(hourly_heating) if len(hourly_heating) > 0 else 0
|
579 |
+
|
580 |
+
results = {
|
581 |
+
"hourly_cooling_sensible": hourly_cooling_sensible.tolist(),
|
582 |
+
"hourly_cooling_latent": hourly_cooling_latent.tolist(),
|
583 |
+
"hourly_cooling_total": hourly_cooling_total.tolist(),
|
584 |
+
"hourly_heating": hourly_heating.tolist(),
|
585 |
+
"cooling_load_components": {k: [load["cooling_components"][k] for load in final_loads] for k in cooling_loads},
|
586 |
+
"heating_load_components": {k: [load["heating_components"][k] for load in final_loads] for k in heating_loads},
|
587 |
+
"peak_cooling_sensible": peak_cooling_sensible,
|
588 |
+
"peak_cooling_latent": peak_cooling_latent,
|
589 |
+
"peak_cooling_total": peak_cooling_total,
|
590 |
+
"peak_heating": peak_heating,
|
591 |
+
"peak_cooling_sensible_hour": int(peak_cooling_sensible_hour),
|
592 |
+
"peak_cooling_latent_hour": int(peak_cooling_latent_hour),
|
593 |
+
"peak_cooling_total_hour": int(peak_cooling_total_hour),
|
594 |
+
"peak_heating_hour": int(peak_heating_hour),
|
595 |
+
"filtered_hours": [{"month": load["month"], "day": load["day"], "hour": load["hour"]} for load in final_loads]
|
596 |
+
}
|
597 |
+
|
598 |
+
logger.info("HVAC load calculations completed.")
|
599 |
+
return results
|
600 |
+
|
601 |
+
def calculate_solar_angles(latitude: float, longitude: float, timezone: float, index: pd.Index) -> pd.DataFrame:
|
602 |
+
"""
|
603 |
+
Calculate solar altitude and azimuth angles for given location and time.
|
604 |
+
Uses formulas from Duffie & Beckman, Solar Engineering of Thermal Processes.
|
605 |
+
|
606 |
+
Args:
|
607 |
+
latitude: Latitude in degrees
|
608 |
+
longitude: Longitude in degrees (East positive)
|
609 |
+
timezone: Timezone offset from UTC in hours
|
610 |
+
index: Pandas Index for the hours
|
611 |
+
|
612 |
+
Returns:
|
613 |
+
DataFrame with solar altitude and azimuth angles in degrees.
|
614 |
+
"""
|
615 |
+
# Convert angles to radians
|
616 |
+
lat_rad = np.radians(latitude)
|
617 |
+
|
618 |
+
# Day of year
|
619 |
+
day_of_year = pd.Series(index).apply(lambda x: pd.Timestamp(x).dayofyear)
|
620 |
+
|
621 |
+
# Equation of time (simplified)
|
622 |
+
b = 2 * np.pi * (day_of_year - 81) / 364
|
623 |
+
eot = 9.87 * np.sin(2 * b) - 7.53 * np.cos(b) - 1.5 * np.sin(b) # minutes
|
624 |
+
|
625 |
+
# Local Standard Time Meridian (LSTM)
|
626 |
+
lstm = 15 * timezone
|
627 |
+
|
628 |
+
# Time Correction Factor (TC)
|
629 |
+
tc = 4 * (longitude - lstm) + eot # minutes
|
630 |
+
|
631 |
+
# Local Solar Time (LST)
|
632 |
+
local_time_hour = pd.Series(index).apply(lambda x: pd.Timestamp(x).hour + pd.Timestamp(x).minute / 60.0)
|
633 |
+
lst = local_time_hour + tc / 60.0
|
634 |
+
|
635 |
+
# Hour Angle (HRA)
|
636 |
+
hra = 15 * (lst - 12) # degrees
|
637 |
+
hra_rad = np.radians(hra)
|
638 |
+
|
639 |
+
# Declination Angle
|
640 |
+
declination_rad = np.radians(23.45 * np.sin(2 * np.pi * (284 + day_of_year) / 365))
|
641 |
+
|
642 |
+
# Solar Altitude (alpha)
|
643 |
+
sin_alpha = np.sin(lat_rad) * np.sin(declination_rad) + \
|
644 |
+
np.cos(lat_rad) * np.cos(declination_rad) * np.cos(hra_rad)
|
645 |
+
solar_altitude_rad = np.arcsin(np.clip(sin_alpha, -1, 1))
|
646 |
+
solar_altitude_deg = np.degrees(solar_altitude_rad)
|
647 |
+
|
648 |
+
# Solar Azimuth (gamma_s) - measured from South, positive West
|
649 |
+
cos_gamma_s = (np.sin(solar_altitude_rad) * np.sin(lat_rad) - np.sin(declination_rad)) / \
|
650 |
+
(np.cos(solar_altitude_rad) * np.cos(lat_rad))
|
651 |
+
solar_azimuth_rad = np.arccos(np.clip(cos_gamma_s, -1, 1))
|
652 |
+
solar_azimuth_deg = np.degrees(solar_azimuth_rad)
|
653 |
+
# Adjust azimuth based on hour angle
|
654 |
+
solar_azimuth_deg = np.where(hra > 0, solar_azimuth_deg, 360 - solar_azimuth_deg)
|
655 |
+
# Convert to azimuth from North, positive East
|
656 |
+
solar_azimuth_deg = (solar_azimuth_deg + 180) % 360
|
657 |
+
|
658 |
+
return pd.DataFrame({
|
659 |
+
"solar_altitude": solar_altitude_deg,
|
660 |
+
"solar_azimuth": solar_azimuth_deg
|
661 |
+
}, index=index)
|
662 |
+
|
663 |
+
def calculate_surface_azimuth(orientation: str, building_orientation_angle: float) -> float:
|
664 |
+
"""
|
665 |
+
Calculate the actual surface azimuth based on orientation label and building rotation.
|
666 |
+
Azimuth: 0=N, 90=E, 180=S, 270=W
|
667 |
+
|
668 |
+
Args:
|
669 |
+
orientation: Orientation label (e.g., "A (North)", "B (South)")
|
670 |
+
building_orientation_angle: Building rotation angle from North (degrees, positive East)
|
671 |
+
|
672 |
+
Returns:
|
673 |
+
Surface azimuth in degrees.
|
674 |
+
"""
|
675 |
+
base_azimuth = {
|
676 |
+
"A (North)": 0.0,
|
677 |
+
"B (South)": 180.0,
|
678 |
+
"C (East)": 90.0,
|
679 |
+
"D (West)": 270.0,
|
680 |
+
"Horizontal": 0.0
|
681 |
+
}.get(orientation, 0.0)
|
682 |
+
|
683 |
+
# Adjust for building rotation
|
684 |
+
surface_azimuth = (base_azimuth + building_orientation_angle) % 360
|
685 |
+
return surface_azimuth
|
686 |
+
|
687 |
+
def calculate_incident_solar(dnr: pd.Series, dhr: pd.Series, solar_altitude: pd.Series, solar_azimuth: pd.Series, tilt: float, surface_azimuth: float) -> pd.Series:
|
688 |
+
"""
|
689 |
+
Calculate total incident solar radiation on a tilted surface.
|
690 |
+
|
691 |
+
Args:
|
692 |
+
dnr: Direct Normal Radiation (W/m²)
|
693 |
+
dhr: Diffuse Horizontal Radiation (W/m²)
|
694 |
+
solar_altitude: Solar altitude angle (degrees)
|
695 |
+
solar_azimuth: Solar azimuth angle (degrees, 0=N, positive E)
|
696 |
+
tilt: Surface tilt angle from horizontal (degrees)
|
697 |
+
surface_azimuth: Surface azimuth angle (degrees, 0=N, positive E)
|
698 |
+
|
699 |
+
Returns:
|
700 |
+
Total incident solar radiation on the surface (W/m²).
|
701 |
+
"""
|
702 |
+
# Convert angles to radians
|
703 |
+
alt_rad = np.radians(solar_altitude)
|
704 |
+
az_rad = np.radians(solar_azimuth)
|
705 |
+
tilt_rad = np.radians(tilt)
|
706 |
+
surf_az_rad = np.radians(surface_azimuth)
|
707 |
+
|
708 |
+
# Angle of Incidence (theta)
|
709 |
+
cos_theta = np.cos(alt_rad) * np.sin(tilt_rad) * np.cos(az_rad - surf_az_rad) + \
|
710 |
+
np.sin(alt_rad) * np.cos(tilt_rad)
|
711 |
+
cos_theta = np.maximum(0, cos_theta)
|
712 |
+
|
713 |
+
# Direct radiation component
|
714 |
+
direct_tilted = dnr * cos_theta
|
715 |
+
|
716 |
+
# Diffuse radiation component (simplified isotropic sky model)
|
717 |
+
sky_diffuse_tilted = dhr * (1 + np.cos(tilt_rad)) / 2
|
718 |
+
|
719 |
+
# Ground reflected component
|
720 |
+
albedo = 0.2
|
721 |
+
ground_reflected_tilted = (dnr * np.sin(alt_rad) + dhr) * albedo * (1 - np.cos(tilt_rad)) / 2
|
722 |
+
|
723 |
+
# Total incident solar radiation
|
724 |
+
total_incident = direct_tilted + sky_diffuse_tilted + ground_reflected_tilted
|
725 |
+
return total_incident
|
726 |
+
|
727 |
+
def calculate_sol_air_temperature(t_oa: pd.Series, dnr: pd.Series, dhr: pd.Series, solar_altitude: pd.Series, solar_azimuth: pd.Series, tilt: float, surface_azimuth: float, absorptivity: float, emissivity: float, h_o: float) -> pd.Series:
|
728 |
+
"""
|
729 |
+
Calculate Sol-Air Temperature.
|
730 |
+
|
731 |
+
Args:
|
732 |
+
t_oa: Outdoor air temperature (°C)
|
733 |
+
dnr, dhr, solar_altitude, solar_azimuth: Solar data
|
734 |
+
tilt, surface_azimuth: Surface orientation
|
735 |
+
absorptivity: Surface solar absorptivity
|
736 |
+
emissivity: Surface thermal emissivity
|
737 |
+
h_o: Outside surface heat transfer coefficient (W/m²·K)
|
738 |
+
|
739 |
+
Returns:
|
740 |
+
Sol-air temperature (°C).
|
741 |
+
"""
|
742 |
+
# Calculate incident solar radiation
|
743 |
+
i_total = calculate_incident_solar(dnr, dhr, solar_altitude, solar_azimuth, tilt, surface_azimuth)
|
744 |
+
|
745 |
+
# Calculate sky temperature
|
746 |
+
t_sky = t_oa * (SKY_TEMP_FACTOR * t_oa**0.25)**0.25
|
747 |
+
|
748 |
+
# Longwave radiation exchange term
|
749 |
+
delta_r = STEFAN_BOLTZMANN * emissivity * ((t_oa + 273.15)**4 - (t_sky + 273.15)**4) / h_o
|
750 |
+
|
751 |
+
# Sol-air temperature
|
752 |
+
t_sol_air = t_oa + (absorptivity * i_total / h_o) - delta_r
|
753 |
+
return t_sol_air
|
754 |
+
|
755 |
+
def calculate_hourly_internal_loads(internal_loads_data: Dict[str, List[Dict[str, Any]]]) -> Tuple[np.ndarray, np.ndarray]:
|
756 |
+
"""
|
757 |
+
Calculate total hourly sensible and latent internal loads.
|
758 |
+
|
759 |
+
Args:
|
760 |
+
internal_loads_data: Dictionary containing lists of loads for each type.
|
761 |
+
|
762 |
+
Returns:
|
763 |
+
Tuple of (hourly_sensible_loads, hourly_latent_loads) as numpy arrays.
|
764 |
+
"""
|
765 |
+
hourly_sensible = np.zeros(8760)
|
766 |
+
hourly_latent = np.zeros(8760)
|
767 |
+
|
768 |
+
# Process each load type
|
769 |
+
for load_type, loads in internal_loads_data.items():
|
770 |
+
for load in loads:
|
771 |
+
total_load = load["total"]
|
772 |
+
schedule_type = load["schedule_type"]
|
773 |
+
|
774 |
+
# Get schedule multipliers
|
775 |
+
schedule_multipliers = np.zeros(8760)
|
776 |
+
if schedule_type == "Custom" and "custom_schedule" in load:
|
777 |
+
weekday_schedule = load["custom_schedule"]["Weekday"]
|
778 |
+
weekend_schedule = load["custom_schedule"]["Weekend"]
|
779 |
+
elif schedule_type in DEFAULT_SCHEDULES:
|
780 |
+
weekday_schedule = DEFAULT_SCHEDULES[schedule_type]["Weekday"]
|
781 |
+
weekend_schedule = DEFAULT_SCHEDULES[schedule_type]["Weekend"]
|
782 |
+
else:
|
783 |
+
weekday_schedule = [1.0] * 24
|
784 |
+
weekend_schedule = [1.0] * 24
|
785 |
+
|
786 |
+
for hour in range(8760):
|
787 |
+
day_of_week = (hour // 24) % 7
|
788 |
+
hour_of_day = hour % 24
|
789 |
+
if day_of_week < 5:
|
790 |
+
schedule_multipliers[hour] = weekday_schedule[hour_of_day]
|
791 |
+
else:
|
792 |
+
schedule_multipliers[hour] = weekend_schedule[hour_of_day]
|
793 |
+
|
794 |
+
if load_type == "occupancy":
|
795 |
+
sensible_fraction = load["sensible_fraction"]
|
796 |
+
latent_fraction = load["latent_fraction"]
|
797 |
+
hourly_sensible += total_load * sensible_fraction * schedule_multipliers
|
798 |
+
hourly_latent += total_load * latent_fraction * schedule_multipliers
|
799 |
+
else:
|
800 |
+
hourly_sensible += total_load * schedule_multipliers
|
801 |
+
|
802 |
+
return hourly_sensible, hourly_latent
|
803 |
+
|
804 |
+
def display_load_results(results: Dict[str, Any]):
|
805 |
+
"""
|
806 |
+
Display the calculated HVAC load results.
|
807 |
+
|
808 |
+
Args:
|
809 |
+
results: Dictionary containing calculation results.
|
810 |
+
"""
|
811 |
+
st.subheader("Peak Load Summary")
|
812 |
+
|
813 |
col1, col2, col3 = st.columns(3)
|
814 |
with col1:
|
815 |
+
st.metric("Peak Cooling (Total)", f"{results['peak_cooling_total'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_cooling_total_hour']}")
|
816 |
+
st.metric("Peak Cooling (Sensible)", f"{results['peak_cooling_sensible'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_cooling_sensible_hour']}")
|
817 |
with col2:
|
818 |
+
st.metric("Peak Heating", f"{results['peak_heating'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_heating_hour']}")
|
819 |
+
st.metric("Peak Cooling (Latent)", f"{results['peak_cooling_latent'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_cooling_latent_hour']}")
|
820 |
+
|
821 |
+
st.subheader("Hourly Load Profiles")
|
822 |
+
|
823 |
+
# Create DataFrame for plotting
|
824 |
+
hourly_df = pd.DataFrame({
|
825 |
+
"Hour": range(len(results["hourly_cooling_total"])),
|
826 |
+
"Cooling (Sensible)": results["hourly_cooling_sensible"],
|
827 |
+
"Cooling (Latent)": results["hourly_cooling_latent"],
|
828 |
+
"Cooling (Total)": results["hourly_cooling_total"],
|
829 |
+
"Heating": results["hourly_heating"]
|
830 |
+
})
|
831 |
+
|
832 |
+
# Plot cooling loads
|
833 |
+
fig_cooling = go.Figure()
|
834 |
+
fig_cooling.add_trace(go.Scatter(x=hourly_df["Hour"], y=hourly_df["Cooling (Sensible)"], name="Sensible Cooling", stackgroup='one'))
|
835 |
+
fig_cooling.add_trace(go.Scatter(x=hourly_df["Hour"], y=hourly_df["Cooling (Latent)"], name="Latent Cooling", stackgroup='one'))
|
836 |
+
fig_cooling.update_layout(title="Hourly Cooling Load Profile", xaxis_title="Hour Index", yaxis_title="Load (W)", height=400)
|
837 |
+
st.plotly_chart(fig_cooling, use_container_width=True)
|
838 |
+
|
839 |
+
# Plot heating loads
|
840 |
+
fig_heating = go.Figure()
|
841 |
+
fig_heating.add_trace(go.Scatter(x=hourly_df["Hour"], y=hourly_df["Heating"], name="Heating Load", line=dict(color='red')))
|
842 |
+
fig_heating.update_layout(title="Hourly Heating Load Profile", xaxis_title="Hour Index", yaxis_title="Load (W)", height=400)
|
843 |
+
st.plotly_chart(fig_heating, use_container_width=True)
|
844 |
+
|
845 |
+
st.subheader("Load Components at Peak Cooling Hour")
|
846 |
+
peak_hour = results["peak_cooling_total_hour"]
|
847 |
+
|
848 |
+
cooling_components = results["cooling_load_components"]
|
849 |
+
peak_cooling_data = {
|
850 |
+
"Component": list(cooling_components.keys()),
|
851 |
+
"Load (W)": [cooling_components[k][peak_hour] for k in cooling_components]
|
852 |
+
}
|
853 |
+
peak_cooling_df = pd.DataFrame(peak_cooling_data)
|
854 |
+
peak_cooling_df = peak_cooling_df[peak_cooling_df["Load (W)"] > 0]
|
855 |
+
|
856 |
+
fig_peak_cooling = px.pie(
|
857 |
+
peak_cooling_df,
|
858 |
+
values="Load (W)",
|
859 |
+
names="Component",
|
860 |
+
title=f"Cooling Load Breakdown at Peak Hour ({peak_hour})"
|
861 |
+
)
|
862 |
+
st.plotly_chart(fig_peak_cooling, use_container_width=True)
|
863 |
+
|
864 |
+
st.subheader("Load Components at Peak Heating Hour")
|
865 |
+
peak_hour_heating = results["peak_heating_hour"]
|
866 |
+
|
867 |
+
heating_components = results["heating_load_components"]
|
868 |
+
internal_gains_at_peak = results["cooling_load_components"]["internal_sensible"][peak_hour_heating]
|
869 |
+
|
870 |
+
peak_heating_data = {
|
871 |
+
"Component": list(heating_components.keys()) + ["Internal Gains Offset"],
|
872 |
+
"Load (W)": [heating_components[k][peak_hour_heating] for k in heating_components] + [-internal_gains_at_peak]
|
873 |
+
}
|
874 |
+
peak_heating_df = pd.DataFrame(peak_heating_data)
|
875 |
+
peak_heating_df = peak_heating_df[peak_heating_df["Load (W)"] != 0]
|
876 |
+
|
877 |
+
fig_peak_heating = px.pie(
|
878 |
+
peak_heating_df,
|
879 |
+
values="Load (W)",
|
880 |
+
names="Component",
|
881 |
+
title=f"Heating Load Breakdown at Peak Hour ({peak_hour_heating})"
|
882 |
+
)
|
883 |
+
st.plotly_chart(fig_peak_heating, use_container_width=True)
|
884 |
+
|
885 |
+
def get_available_materials() -> Dict[str, Any]:
|
886 |
+
mats = {}
|
887 |
+
if "materials" in st.session_state.project_data:
|
888 |
+
mats.update(st.session_state.project_data["materials"].get("library", {}))
|
889 |
+
mats.update(st.session_state.project_data["materials"].get("project", {}))
|
890 |
+
return mats
|
891 |
+
|
892 |
+
def get_available_constructions() -> Dict[str, Any]:
|
893 |
+
consts = {}
|
894 |
+
if "constructions" in st.session_state.project_data:
|
895 |
+
consts.update(st.session_state.project_data["constructions"].get("library", {}))
|
896 |
+
consts.update(st.session_state.project_data["constructions"].get("project", {}))
|
897 |
+
return consts
|
898 |
+
|
899 |
+
def get_available_fenestrations() -> Dict[str, Any]:
|
900 |
+
fens = {}
|
901 |
+
if "fenestrations" in st.session_state.project_data:
|
902 |
+
fens.update(st.session_state.project_data["fenestrations"].get("library", {}))
|
903 |
+
fens.update(st.session_state.project_data["fenestrations"].get("project", {}))
|
904 |
+
return fens
|
905 |
|
906 |
+
def display_hvac_loads_help():
|
907 |
+
"""
|
908 |
+
Display help information for the HVAC loads page.
|
909 |
+
"""
|
910 |
+
st.markdown("""
|
911 |
+
### HVAC Loads Help
|
912 |
+
|
913 |
+
This section calculates the building's heating and cooling loads based on the information provided in the previous steps.
|
914 |
+
|
915 |
+
**Calculation Process:**
|
916 |
+
|
917 |
+
1. **Heat Transfer**: Calculates heat gains and losses through the building envelope (walls, roofs, floors, windows, doors, skylights) considering conduction and solar radiation.
|
918 |
+
2. **Infiltration & Ventilation**: Calculates loads due to air exchange with the outside.
|
919 |
+
3. **Internal Loads**: Incorporates heat gains from occupants, lighting, and equipment.
|
920 |
+
4. **Summation**: Combines all heat gains and losses to determine the net hourly cooling and heating loads.
|
921 |
+
|
922 |
+
**Simulation Parameters:**
|
923 |
+
|
924 |
+
* **Simulation Period**: Choose 'Full Year', a specific date range ('From-to'), or degree-day methods ('HDD' or 'CDD').
|
925 |
+
* **Indoor Conditions**: Set fixed setpoints, time-varying schedules, or adaptive comfort temperatures.
|
926 |
+
* **Operating Hours**: Define when the HVAC system operates.
|
927 |
+
|
928 |
+
**Results:**
|
929 |
+
|
930 |
+
* **Peak Loads**: Shows the maximum calculated cooling and heating loads.
|
931 |
+
* **Hourly Profiles**: Displays graphs of loads for the selected period.
|
932 |
+
* **Load Components**: Shows the breakdown of loads at peak hours.
|
933 |
+
|
934 |
+
**Workflow:**
|
935 |
+
|
936 |
+
1. Complete all previous sections.
|
937 |
+
2. Configure simulation parameters.
|
938 |
+
3. Click "Calculate HVAC Loads".
|
939 |
+
4. Review results.
|
940 |
+
5. Proceed to Building Energy section.
|
941 |
+
|
942 |
+
**Important:**
|
943 |
+
|
944 |
+
* Calculations follow ASHRAE methodology.
|
945 |
+
* Input data quality affects accuracy.
|
946 |
+
* Review results carefully.
|
947 |
+
""")
|