mabuseif commited on
Commit
4af4e9a
·
verified ·
1 Parent(s): c8ffdec

Upload 23 files

Browse files
app/building_info_form.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Building information input form for HVAC Load Calculator.
3
+ This module provides the UI components for entering building information.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ import json
11
+ import os
12
+
13
+ # Import data models
14
+ from data.building_components import Orientation, ComponentType
15
+
16
+
17
+ class BuildingInfoForm:
18
+ """Class for building information input form."""
19
+
20
+ @staticmethod
21
+ def display_building_info_form(session_state: Dict[str, Any]) -> None:
22
+ """
23
+ Display building information input form in Streamlit.
24
+
25
+ Args:
26
+ session_state: Streamlit session state for storing form data
27
+ """
28
+ st.header("Building Information")
29
+
30
+ # Initialize building info in session state if not exists
31
+ if "building_info" not in session_state:
32
+ session_state["building_info"] = {
33
+ "project_name": "",
34
+ "building_name": "",
35
+ "location": "",
36
+ "climate_zone": "",
37
+ "building_type": "",
38
+ "floor_area": 0.0,
39
+ "num_floors": 1,
40
+ "floor_height": 3.0,
41
+ "orientation": "NORTH",
42
+ "occupancy": 0,
43
+ "operating_hours": "8:00-18:00",
44
+ "design_conditions": {
45
+ "summer_outdoor_db": 35.0,
46
+ "summer_outdoor_wb": 25.0,
47
+ "summer_indoor_db": 24.0,
48
+ "summer_indoor_rh": 50.0,
49
+ "winter_outdoor_db": -5.0,
50
+ "winter_outdoor_rh": 80.0,
51
+ "winter_indoor_db": 21.0,
52
+ "winter_indoor_rh": 40.0
53
+ }
54
+ }
55
+
56
+ # Create form
57
+ with st.form("building_info_form"):
58
+ st.subheader("Project Information")
59
+
60
+ col1, col2 = st.columns(2)
61
+
62
+ with col1:
63
+ session_state["building_info"]["project_name"] = st.text_input(
64
+ "Project Name",
65
+ value=session_state["building_info"]["project_name"],
66
+ help="Enter the name of the project"
67
+ )
68
+
69
+ session_state["building_info"]["building_name"] = st.text_input(
70
+ "Building Name",
71
+ value=session_state["building_info"]["building_name"],
72
+ help="Enter the name of the building"
73
+ )
74
+
75
+ with col2:
76
+ session_state["building_info"]["location"] = st.text_input(
77
+ "Location",
78
+ value=session_state["building_info"]["location"],
79
+ help="Enter the location of the building (city, country)"
80
+ )
81
+
82
+ session_state["building_info"]["climate_zone"] = st.selectbox(
83
+ "Climate Zone",
84
+ ["1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"],
85
+ index=4 if session_state["building_info"]["climate_zone"] == "" else
86
+ ["1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"].index(session_state["building_info"]["climate_zone"]),
87
+ help="Select the ASHRAE climate zone for the building location"
88
+ )
89
+
90
+ st.subheader("Building Characteristics")
91
+
92
+ col1, col2, col3 = st.columns(3)
93
+
94
+ with col1:
95
+ session_state["building_info"]["building_type"] = st.selectbox(
96
+ "Building Type",
97
+ ["Residential", "Office", "Retail", "Educational", "Healthcare", "Industrial", "Other"],
98
+ index=1 if session_state["building_info"]["building_type"] == "" else
99
+ ["Residential", "Office", "Retail", "Educational", "Healthcare", "Industrial", "Other"].index(session_state["building_info"]["building_type"]),
100
+ help="Select the type of building"
101
+ )
102
+
103
+ with col2:
104
+ session_state["building_info"]["floor_area"] = st.number_input(
105
+ "Floor Area (m²)",
106
+ min_value=0.0,
107
+ value=float(session_state["building_info"]["floor_area"]),
108
+ step=10.0,
109
+ help="Enter the total floor area of the building in square meters"
110
+ )
111
+
112
+ with col3:
113
+ session_state["building_info"]["num_floors"] = st.number_input(
114
+ "Number of Floors",
115
+ min_value=1,
116
+ value=int(session_state["building_info"]["num_floors"]),
117
+ step=1,
118
+ help="Enter the number of floors in the building"
119
+ )
120
+
121
+ col1, col2, col3 = st.columns(3)
122
+
123
+ with col1:
124
+ session_state["building_info"]["floor_height"] = st.number_input(
125
+ "Floor Height (m)",
126
+ min_value=2.0,
127
+ max_value=10.0,
128
+ value=float(session_state["building_info"]["floor_height"]),
129
+ step=0.1,
130
+ help="Enter the floor-to-floor height in meters"
131
+ )
132
+
133
+ with col2:
134
+ session_state["building_info"]["orientation"] = st.selectbox(
135
+ "Building Orientation",
136
+ ["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
137
+ index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["building_info"]["orientation"]),
138
+ help="Select the orientation of the building's main facade"
139
+ )
140
+
141
+ with col3:
142
+ session_state["building_info"]["occupancy"] = st.number_input(
143
+ "Occupancy (people)",
144
+ min_value=0,
145
+ value=int(session_state["building_info"]["occupancy"]),
146
+ step=1,
147
+ help="Enter the total number of occupants"
148
+ )
149
+
150
+ session_state["building_info"]["operating_hours"] = st.text_input(
151
+ "Operating Hours",
152
+ value=session_state["building_info"]["operating_hours"],
153
+ help="Enter the operating hours of the building (e.g., 8:00-18:00)"
154
+ )
155
+
156
+ st.subheader("Design Conditions")
157
+
158
+ st.write("Summer Design Conditions")
159
+ col1, col2, col3, col4 = st.columns(4)
160
+
161
+ with col1:
162
+ session_state["building_info"]["design_conditions"]["summer_outdoor_db"] = st.number_input(
163
+ "Outdoor Dry-Bulb (°C)",
164
+ min_value=-10.0,
165
+ max_value=50.0,
166
+ value=float(session_state["building_info"]["design_conditions"]["summer_outdoor_db"]),
167
+ step=0.5,
168
+ key="summer_outdoor_db",
169
+ help="Enter the summer outdoor design dry-bulb temperature"
170
+ )
171
+
172
+ with col2:
173
+ session_state["building_info"]["design_conditions"]["summer_outdoor_wb"] = st.number_input(
174
+ "Outdoor Wet-Bulb (°C)",
175
+ min_value=-10.0,
176
+ max_value=40.0,
177
+ value=float(session_state["building_info"]["design_conditions"]["summer_outdoor_wb"]),
178
+ step=0.5,
179
+ key="summer_outdoor_wb",
180
+ help="Enter the summer outdoor design wet-bulb temperature"
181
+ )
182
+
183
+ with col3:
184
+ session_state["building_info"]["design_conditions"]["summer_indoor_db"] = st.number_input(
185
+ "Indoor Dry-Bulb (°C)",
186
+ min_value=18.0,
187
+ max_value=30.0,
188
+ value=float(session_state["building_info"]["design_conditions"]["summer_indoor_db"]),
189
+ step=0.5,
190
+ key="summer_indoor_db",
191
+ help="Enter the summer indoor design dry-bulb temperature"
192
+ )
193
+
194
+ with col4:
195
+ session_state["building_info"]["design_conditions"]["summer_indoor_rh"] = st.number_input(
196
+ "Indoor RH (%)",
197
+ min_value=30.0,
198
+ max_value=70.0,
199
+ value=float(session_state["building_info"]["design_conditions"]["summer_indoor_rh"]),
200
+ step=5.0,
201
+ key="summer_indoor_rh",
202
+ help="Enter the summer indoor design relative humidity"
203
+ )
204
+
205
+ st.write("Winter Design Conditions")
206
+ col1, col2, col3, col4 = st.columns(4)
207
+
208
+ with col1:
209
+ session_state["building_info"]["design_conditions"]["winter_outdoor_db"] = st.number_input(
210
+ "Outdoor Dry-Bulb (°C)",
211
+ min_value=-40.0,
212
+ max_value=20.0,
213
+ value=float(session_state["building_info"]["design_conditions"]["winter_outdoor_db"]),
214
+ step=0.5,
215
+ key="winter_outdoor_db",
216
+ help="Enter the winter outdoor design dry-bulb temperature"
217
+ )
218
+
219
+ with col2:
220
+ session_state["building_info"]["design_conditions"]["winter_outdoor_rh"] = st.number_input(
221
+ "Outdoor RH (%)",
222
+ min_value=0.0,
223
+ max_value=100.0,
224
+ value=float(session_state["building_info"]["design_conditions"]["winter_outdoor_rh"]),
225
+ step=5.0,
226
+ key="winter_outdoor_rh",
227
+ help="Enter the winter outdoor design relative humidity"
228
+ )
229
+
230
+ with col3:
231
+ session_state["building_info"]["design_conditions"]["winter_indoor_db"] = st.number_input(
232
+ "Indoor Dry-Bulb (°C)",
233
+ min_value=18.0,
234
+ max_value=25.0,
235
+ value=float(session_state["building_info"]["design_conditions"]["winter_indoor_db"]),
236
+ step=0.5,
237
+ key="winter_indoor_db",
238
+ help="Enter the winter indoor design dry-bulb temperature"
239
+ )
240
+
241
+ with col4:
242
+ session_state["building_info"]["design_conditions"]["winter_indoor_rh"] = st.number_input(
243
+ "Indoor RH (%)",
244
+ min_value=20.0,
245
+ max_value=60.0,
246
+ value=float(session_state["building_info"]["design_conditions"]["winter_indoor_rh"]),
247
+ step=5.0,
248
+ key="winter_indoor_rh",
249
+ help="Enter the winter indoor design relative humidity"
250
+ )
251
+
252
+ # Submit button
253
+ submitted = st.form_submit_button("Save Building Information")
254
+
255
+ if submitted:
256
+ st.success("Building information saved successfully!")
257
+
258
+ # Save to file
259
+ BuildingInfoForm.save_building_info(session_state["building_info"])
260
+
261
+ @staticmethod
262
+ def save_building_info(building_info: Dict[str, Any], file_path: str = "building_info.json") -> None:
263
+ """
264
+ Save building information to a JSON file.
265
+
266
+ Args:
267
+ building_info: Dictionary with building information
268
+ file_path: Path to save the JSON file
269
+ """
270
+ try:
271
+ with open(file_path, "w") as f:
272
+ json.dump(building_info, f, indent=4)
273
+ except Exception as e:
274
+ st.error(f"Error saving building information: {e}")
275
+
276
+ @staticmethod
277
+ def load_building_info(file_path: str = "building_info.json") -> Dict[str, Any]:
278
+ """
279
+ Load building information from a JSON file.
280
+
281
+ Args:
282
+ file_path: Path to the JSON file
283
+
284
+ Returns:
285
+ Dictionary with building information
286
+ """
287
+ if os.path.exists(file_path):
288
+ try:
289
+ with open(file_path, "r") as f:
290
+ return json.load(f)
291
+ except Exception as e:
292
+ st.error(f"Error loading building information: {e}")
293
+
294
+ # Return default building info if file doesn't exist or error occurs
295
+ return {
296
+ "project_name": "",
297
+ "building_name": "",
298
+ "location": "",
299
+ "climate_zone": "",
300
+ "building_type": "",
301
+ "floor_area": 0.0,
302
+ "num_floors": 1,
303
+ "floor_height": 3.0,
304
+ "orientation": "NORTH",
305
+ "occupancy": 0,
306
+ "operating_hours": "8:00-18:00",
307
+ "design_conditions": {
308
+ "summer_outdoor_db": 35.0,
309
+ "summer_outdoor_wb": 25.0,
310
+ "summer_indoor_db": 24.0,
311
+ "summer_indoor_rh": 50.0,
312
+ "winter_outdoor_db": -5.0,
313
+ "winter_outdoor_rh": 80.0,
314
+ "winter_indoor_db": 21.0,
315
+ "winter_indoor_rh": 40.0
316
+ }
317
+ }
318
+
319
+
320
+ # Create a singleton instance
321
+ building_info_form = BuildingInfoForm()
322
+
323
+ # Example usage
324
+ if __name__ == "__main__":
325
+ import streamlit as st
326
+
327
+ # Initialize session state
328
+ if "building_info" not in st.session_state:
329
+ st.session_state["building_info"] = BuildingInfoForm.load_building_info()
330
+
331
+ # Display building information form
332
+ building_info_form.display_building_info_form(st.session_state)
app/component_selection.py ADDED
@@ -0,0 +1,1674 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Component selection interface for HVAC Load Calculator.
3
+ This module provides the UI components for selecting building components.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ import json
11
+ import os
12
+ import uuid
13
+
14
+ # Import data models
15
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
16
+ from utils.component_library import ComponentLibrary
17
+ from utils.u_value_calculator import UValueCalculator
18
+
19
+
20
+ class ComponentSelectionInterface:
21
+ """Class for component selection interface."""
22
+
23
+ def __init__(self):
24
+ """Initialize component selection interface."""
25
+ self.component_library = ComponentLibrary()
26
+ self.u_value_calculator = UValueCalculator()
27
+
28
+ def display_component_selection(self, session_state: Dict[str, Any]) -> None:
29
+ """
30
+ Display component selection interface in Streamlit.
31
+
32
+ Args:
33
+ session_state: Streamlit session state for storing form data
34
+ """
35
+ st.header("Building Components")
36
+
37
+ # Initialize components in session state if not exists
38
+ if "components" not in session_state:
39
+ session_state["components"] = {
40
+ "walls": [],
41
+ "roofs": [],
42
+ "floors": [],
43
+ "windows": [],
44
+ "doors": []
45
+ }
46
+
47
+ # Create tabs for different component types
48
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
49
+ "Walls",
50
+ "Roofs",
51
+ "Floors",
52
+ "Windows",
53
+ "Doors"
54
+ ])
55
+
56
+ with tab1:
57
+ self._display_wall_selection(session_state)
58
+
59
+ with tab2:
60
+ self._display_roof_selection(session_state)
61
+
62
+ with tab3:
63
+ self._display_floor_selection(session_state)
64
+
65
+ with tab4:
66
+ self._display_window_selection(session_state)
67
+
68
+ with tab5:
69
+ self._display_door_selection(session_state)
70
+
71
+ def _display_wall_selection(self, session_state: Dict[str, Any]) -> None:
72
+ """
73
+ Display wall selection interface.
74
+
75
+ Args:
76
+ session_state: Streamlit session state for storing form data
77
+ """
78
+ st.subheader("Wall Components")
79
+
80
+ # Display existing walls
81
+ if session_state["components"]["walls"]:
82
+ st.write("Existing Walls:")
83
+
84
+ # Create a DataFrame for display
85
+ wall_data = []
86
+ for i, wall in enumerate(session_state["components"]["walls"]):
87
+ wall_data.append({
88
+ "ID": wall.id,
89
+ "Name": wall.name,
90
+ "Orientation": wall.orientation.name,
91
+ "Area (m²)": wall.area,
92
+ "U-Value (W/m²·K)": wall.u_value,
93
+ "Wall Type": wall.wall_type,
94
+ "Wall Group": wall.wall_group
95
+ })
96
+
97
+ wall_df = pd.DataFrame(wall_data)
98
+ st.dataframe(wall_df, use_container_width=True)
99
+
100
+ # Add edit and delete buttons
101
+ col1, col2 = st.columns(2)
102
+
103
+ with col1:
104
+ wall_to_edit = st.selectbox(
105
+ "Select Wall to Edit",
106
+ options=[f"#{i+1}: {wall.name}" for i, wall in enumerate(session_state["components"]["walls"])],
107
+ key="wall_to_edit"
108
+ )
109
+
110
+ if st.button("Edit Selected Wall", key="edit_wall_button"):
111
+ # Get index of wall to edit
112
+ wall_index = int(wall_to_edit.split(":")[0][1:]) - 1
113
+
114
+ # Set edit flag and index
115
+ session_state["edit_wall"] = True
116
+ session_state["edit_wall_index"] = wall_index
117
+
118
+ # Set form values from selected wall
119
+ wall = session_state["components"]["walls"][wall_index]
120
+ session_state["wall_name"] = wall.name
121
+ session_state["wall_orientation"] = wall.orientation.name
122
+ session_state["wall_area"] = wall.area
123
+ session_state["wall_u_value"] = wall.u_value
124
+ session_state["wall_type"] = wall.wall_type
125
+ session_state["wall_group"] = wall.wall_group
126
+
127
+ st.experimental_rerun()
128
+
129
+ with col2:
130
+ wall_to_delete = st.selectbox(
131
+ "Select Wall to Delete",
132
+ options=[f"#{i+1}: {wall.name}" for i, wall in enumerate(session_state["components"]["walls"])],
133
+ key="wall_to_delete"
134
+ )
135
+
136
+ if st.button("Delete Selected Wall", key="delete_wall_button"):
137
+ # Get index of wall to delete
138
+ wall_index = int(wall_to_delete.split(":")[0][1:]) - 1
139
+
140
+ # Remove wall from session state
141
+ session_state["components"]["walls"].pop(wall_index)
142
+
143
+ st.success(f"Wall {wall_to_delete} deleted successfully!")
144
+ st.experimental_rerun()
145
+
146
+ # Add new wall form
147
+ st.write("Add New Wall:")
148
+
149
+ # Initialize edit flag if not exists
150
+ if "edit_wall" not in session_state:
151
+ session_state["edit_wall"] = False
152
+ session_state["edit_wall_index"] = -1
153
+
154
+ # Initialize form values if not exists
155
+ if "wall_name" not in session_state:
156
+ session_state["wall_name"] = ""
157
+ session_state["wall_orientation"] = "NORTH"
158
+ session_state["wall_area"] = 0.0
159
+ session_state["wall_u_value"] = 0.5
160
+ session_state["wall_type"] = "Brick"
161
+ session_state["wall_group"] = "B"
162
+
163
+ # Create form
164
+ with st.form("wall_form"):
165
+ col1, col2 = st.columns(2)
166
+
167
+ with col1:
168
+ wall_name = st.text_input(
169
+ "Wall Name",
170
+ value=session_state["wall_name"],
171
+ key="wall_name_input",
172
+ help="Enter a descriptive name for the wall"
173
+ )
174
+
175
+ wall_orientation = st.selectbox(
176
+ "Orientation",
177
+ options=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
178
+ index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["wall_orientation"]),
179
+ key="wall_orientation_input",
180
+ help="Select the orientation of the wall"
181
+ )
182
+
183
+ wall_area = st.number_input(
184
+ "Area (m²)",
185
+ min_value=0.0,
186
+ value=session_state["wall_area"],
187
+ step=0.1,
188
+ key="wall_area_input",
189
+ help="Enter the area of the wall in square meters"
190
+ )
191
+
192
+ with col2:
193
+ # Option to select from preset or custom
194
+ wall_selection_method = st.radio(
195
+ "Wall Selection Method",
196
+ options=["Select from Presets", "Custom U-Value"],
197
+ key="wall_selection_method"
198
+ )
199
+
200
+ if wall_selection_method == "Select from Presets":
201
+ # Get preset wall types from component library
202
+ preset_walls = self.component_library.get_preset_walls()
203
+ wall_types = [wall["name"] for wall in preset_walls]
204
+
205
+ wall_type = st.selectbox(
206
+ "Wall Type",
207
+ options=wall_types,
208
+ index=wall_types.index(session_state["wall_type"]) if session_state["wall_type"] in wall_types else 0,
209
+ key="wall_type_input",
210
+ help="Select the type of wall construction"
211
+ )
212
+
213
+ # Get U-value from selected preset
214
+ selected_wall = next((wall for wall in preset_walls if wall["name"] == wall_type), None)
215
+ if selected_wall:
216
+ wall_u_value = selected_wall["u_value"]
217
+ wall_group = selected_wall["wall_group"]
218
+ else:
219
+ wall_u_value = session_state["wall_u_value"]
220
+ wall_group = session_state["wall_group"]
221
+
222
+ # Display U-value (read-only)
223
+ st.number_input(
224
+ "U-Value (W/m²·K)",
225
+ min_value=0.0,
226
+ value=wall_u_value,
227
+ step=0.01,
228
+ key="wall_u_value_display",
229
+ disabled=True,
230
+ help="U-value of the selected wall type"
231
+ )
232
+
233
+ # Display wall group (read-only)
234
+ st.text_input(
235
+ "Wall Group",
236
+ value=wall_group,
237
+ key="wall_group_display",
238
+ disabled=True,
239
+ help="ASHRAE wall group for cooling load calculations"
240
+ )
241
+ else:
242
+ # Custom U-value input
243
+ wall_u_value = st.number_input(
244
+ "U-Value (W/m²·K)",
245
+ min_value=0.0,
246
+ value=session_state["wall_u_value"],
247
+ step=0.01,
248
+ key="wall_u_value_input",
249
+ help="Enter the U-value of the wall"
250
+ )
251
+
252
+ # Wall group selection
253
+ wall_group = st.selectbox(
254
+ "Wall Group",
255
+ options=["A", "B", "C", "D", "E", "F", "G", "H"],
256
+ index=["A", "B", "C", "D", "E", "F", "G", "H"].index(session_state["wall_group"]),
257
+ key="wall_group_input",
258
+ help="Select the ASHRAE wall group for cooling load calculations"
259
+ )
260
+
261
+ # Set wall type to "Custom"
262
+ wall_type = "Custom"
263
+
264
+ # Submit button
265
+ if session_state["edit_wall"]:
266
+ submit_label = "Update Wall"
267
+ else:
268
+ submit_label = "Add Wall"
269
+
270
+ submitted = st.form_submit_button(submit_label)
271
+
272
+ if submitted:
273
+ # Validate inputs
274
+ if not wall_name:
275
+ st.error("Wall name is required!")
276
+ elif wall_area <= 0:
277
+ st.error("Wall area must be greater than zero!")
278
+ elif wall_u_value <= 0:
279
+ st.error("Wall U-value must be greater than zero!")
280
+ else:
281
+ # Create wall object
282
+ wall = Wall(
283
+ id=str(uuid.uuid4()) if not session_state["edit_wall"] else session_state["components"]["walls"][session_state["edit_wall_index"]].id,
284
+ name=wall_name,
285
+ component_type=ComponentType.WALL,
286
+ u_value=wall_u_value,
287
+ area=wall_area,
288
+ orientation=Orientation[wall_orientation],
289
+ wall_type=wall_type,
290
+ wall_group=wall_group
291
+ )
292
+
293
+ if session_state["edit_wall"]:
294
+ # Update existing wall
295
+ session_state["components"]["walls"][session_state["edit_wall_index"]] = wall
296
+ st.success(f"Wall '{wall_name}' updated successfully!")
297
+
298
+ # Reset edit flag
299
+ session_state["edit_wall"] = False
300
+ session_state["edit_wall_index"] = -1
301
+ else:
302
+ # Add new wall
303
+ session_state["components"]["walls"].append(wall)
304
+ st.success(f"Wall '{wall_name}' added successfully!")
305
+
306
+ # Reset form values
307
+ session_state["wall_name"] = ""
308
+ session_state["wall_orientation"] = "NORTH"
309
+ session_state["wall_area"] = 0.0
310
+ session_state["wall_u_value"] = 0.5
311
+ session_state["wall_type"] = "Brick"
312
+ session_state["wall_group"] = "B"
313
+
314
+ # Save components to file
315
+ self.save_components(session_state["components"])
316
+
317
+ st.experimental_rerun()
318
+
319
+ # Add U-value calculator button
320
+ if st.button("Open U-Value Calculator", key="open_u_value_calc_wall"):
321
+ session_state["show_u_value_calculator"] = True
322
+ session_state["u_value_calc_component_type"] = "wall"
323
+ st.experimental_rerun()
324
+
325
+ def _display_roof_selection(self, session_state: Dict[str, Any]) -> None:
326
+ """
327
+ Display roof selection interface.
328
+
329
+ Args:
330
+ session_state: Streamlit session state for storing form data
331
+ """
332
+ st.subheader("Roof Components")
333
+
334
+ # Display existing roofs
335
+ if session_state["components"]["roofs"]:
336
+ st.write("Existing Roofs:")
337
+
338
+ # Create a DataFrame for display
339
+ roof_data = []
340
+ for i, roof in enumerate(session_state["components"]["roofs"]):
341
+ roof_data.append({
342
+ "ID": roof.id,
343
+ "Name": roof.name,
344
+ "Orientation": roof.orientation.name,
345
+ "Area (m²)": roof.area,
346
+ "U-Value (W/m²·K)": roof.u_value,
347
+ "Roof Type": roof.roof_type,
348
+ "Roof Group": roof.roof_group
349
+ })
350
+
351
+ roof_df = pd.DataFrame(roof_data)
352
+ st.dataframe(roof_df, use_container_width=True)
353
+
354
+ # Add edit and delete buttons
355
+ col1, col2 = st.columns(2)
356
+
357
+ with col1:
358
+ roof_to_edit = st.selectbox(
359
+ "Select Roof to Edit",
360
+ options=[f"#{i+1}: {roof.name}" for i, roof in enumerate(session_state["components"]["roofs"])],
361
+ key="roof_to_edit"
362
+ )
363
+
364
+ if st.button("Edit Selected Roof", key="edit_roof_button"):
365
+ # Get index of roof to edit
366
+ roof_index = int(roof_to_edit.split(":")[0][1:]) - 1
367
+
368
+ # Set edit flag and index
369
+ session_state["edit_roof"] = True
370
+ session_state["edit_roof_index"] = roof_index
371
+
372
+ # Set form values from selected roof
373
+ roof = session_state["components"]["roofs"][roof_index]
374
+ session_state["roof_name"] = roof.name
375
+ session_state["roof_orientation"] = roof.orientation.name
376
+ session_state["roof_area"] = roof.area
377
+ session_state["roof_u_value"] = roof.u_value
378
+ session_state["roof_type"] = roof.roof_type
379
+ session_state["roof_group"] = roof.roof_group
380
+
381
+ st.experimental_rerun()
382
+
383
+ with col2:
384
+ roof_to_delete = st.selectbox(
385
+ "Select Roof to Delete",
386
+ options=[f"#{i+1}: {roof.name}" for i, roof in enumerate(session_state["components"]["roofs"])],
387
+ key="roof_to_delete"
388
+ )
389
+
390
+ if st.button("Delete Selected Roof", key="delete_roof_button"):
391
+ # Get index of roof to delete
392
+ roof_index = int(roof_to_delete.split(":")[0][1:]) - 1
393
+
394
+ # Remove roof from session state
395
+ session_state["components"]["roofs"].pop(roof_index)
396
+
397
+ st.success(f"Roof {roof_to_delete} deleted successfully!")
398
+ st.experimental_rerun()
399
+
400
+ # Add new roof form
401
+ st.write("Add New Roof:")
402
+
403
+ # Initialize edit flag if not exists
404
+ if "edit_roof" not in session_state:
405
+ session_state["edit_roof"] = False
406
+ session_state["edit_roof_index"] = -1
407
+
408
+ # Initialize form values if not exists
409
+ if "roof_name" not in session_state:
410
+ session_state["roof_name"] = ""
411
+ session_state["roof_orientation"] = "HORIZONTAL"
412
+ session_state["roof_area"] = 0.0
413
+ session_state["roof_u_value"] = 0.3
414
+ session_state["roof_type"] = "Concrete"
415
+ session_state["roof_group"] = "C"
416
+
417
+ # Create form
418
+ with st.form("roof_form"):
419
+ col1, col2 = st.columns(2)
420
+
421
+ with col1:
422
+ roof_name = st.text_input(
423
+ "Roof Name",
424
+ value=session_state["roof_name"],
425
+ key="roof_name_input",
426
+ help="Enter a descriptive name for the roof"
427
+ )
428
+
429
+ roof_orientation = st.selectbox(
430
+ "Orientation",
431
+ options=["HORIZONTAL", "NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
432
+ index=["HORIZONTAL", "NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["roof_orientation"]),
433
+ key="roof_orientation_input",
434
+ help="Select the orientation of the roof (HORIZONTAL for flat roofs)"
435
+ )
436
+
437
+ roof_area = st.number_input(
438
+ "Area (m²)",
439
+ min_value=0.0,
440
+ value=session_state["roof_area"],
441
+ step=0.1,
442
+ key="roof_area_input",
443
+ help="Enter the area of the roof in square meters"
444
+ )
445
+
446
+ with col2:
447
+ # Option to select from preset or custom
448
+ roof_selection_method = st.radio(
449
+ "Roof Selection Method",
450
+ options=["Select from Presets", "Custom U-Value"],
451
+ key="roof_selection_method"
452
+ )
453
+
454
+ if roof_selection_method == "Select from Presets":
455
+ # Get preset roof types from component library
456
+ preset_roofs = self.component_library.get_preset_roofs()
457
+ roof_types = [roof["name"] for roof in preset_roofs]
458
+
459
+ roof_type = st.selectbox(
460
+ "Roof Type",
461
+ options=roof_types,
462
+ index=roof_types.index(session_state["roof_type"]) if session_state["roof_type"] in roof_types else 0,
463
+ key="roof_type_input",
464
+ help="Select the type of roof construction"
465
+ )
466
+
467
+ # Get U-value from selected preset
468
+ selected_roof = next((roof for roof in preset_roofs if roof["name"] == roof_type), None)
469
+ if selected_roof:
470
+ roof_u_value = selected_roof["u_value"]
471
+ roof_group = selected_roof["roof_group"]
472
+ else:
473
+ roof_u_value = session_state["roof_u_value"]
474
+ roof_group = session_state["roof_group"]
475
+
476
+ # Display U-value (read-only)
477
+ st.number_input(
478
+ "U-Value (W/m²·K)",
479
+ min_value=0.0,
480
+ value=roof_u_value,
481
+ step=0.01,
482
+ key="roof_u_value_display",
483
+ disabled=True,
484
+ help="U-value of the selected roof type"
485
+ )
486
+
487
+ # Display roof group (read-only)
488
+ st.text_input(
489
+ "Roof Group",
490
+ value=roof_group,
491
+ key="roof_group_display",
492
+ disabled=True,
493
+ help="ASHRAE roof group for cooling load calculations"
494
+ )
495
+ else:
496
+ # Custom U-value input
497
+ roof_u_value = st.number_input(
498
+ "U-Value (W/m²·K)",
499
+ min_value=0.0,
500
+ value=session_state["roof_u_value"],
501
+ step=0.01,
502
+ key="roof_u_value_input",
503
+ help="Enter the U-value of the roof"
504
+ )
505
+
506
+ # Roof group selection
507
+ roof_group = st.selectbox(
508
+ "Roof Group",
509
+ options=["A", "B", "C", "D", "E", "F", "G"],
510
+ index=["A", "B", "C", "D", "E", "F", "G"].index(session_state["roof_group"]),
511
+ key="roof_group_input",
512
+ help="Select the ASHRAE roof group for cooling load calculations"
513
+ )
514
+
515
+ # Set roof type to "Custom"
516
+ roof_type = "Custom"
517
+
518
+ # Submit button
519
+ if session_state["edit_roof"]:
520
+ submit_label = "Update Roof"
521
+ else:
522
+ submit_label = "Add Roof"
523
+
524
+ submitted = st.form_submit_button(submit_label)
525
+
526
+ if submitted:
527
+ # Validate inputs
528
+ if not roof_name:
529
+ st.error("Roof name is required!")
530
+ elif roof_area <= 0:
531
+ st.error("Roof area must be greater than zero!")
532
+ elif roof_u_value <= 0:
533
+ st.error("Roof U-value must be greater than zero!")
534
+ else:
535
+ # Create roof object
536
+ roof = Roof(
537
+ id=str(uuid.uuid4()) if not session_state["edit_roof"] else session_state["components"]["roofs"][session_state["edit_roof_index"]].id,
538
+ name=roof_name,
539
+ component_type=ComponentType.ROOF,
540
+ u_value=roof_u_value,
541
+ area=roof_area,
542
+ orientation=Orientation[roof_orientation],
543
+ roof_type=roof_type,
544
+ roof_group=roof_group
545
+ )
546
+
547
+ if session_state["edit_roof"]:
548
+ # Update existing roof
549
+ session_state["components"]["roofs"][session_state["edit_roof_index"]] = roof
550
+ st.success(f"Roof '{roof_name}' updated successfully!")
551
+
552
+ # Reset edit flag
553
+ session_state["edit_roof"] = False
554
+ session_state["edit_roof_index"] = -1
555
+ else:
556
+ # Add new roof
557
+ session_state["components"]["roofs"].append(roof)
558
+ st.success(f"Roof '{roof_name}' added successfully!")
559
+
560
+ # Reset form values
561
+ session_state["roof_name"] = ""
562
+ session_state["roof_orientation"] = "HORIZONTAL"
563
+ session_state["roof_area"] = 0.0
564
+ session_state["roof_u_value"] = 0.3
565
+ session_state["roof_type"] = "Concrete"
566
+ session_state["roof_group"] = "C"
567
+
568
+ # Save components to file
569
+ self.save_components(session_state["components"])
570
+
571
+ st.experimental_rerun()
572
+
573
+ # Add U-value calculator button
574
+ if st.button("Open U-Value Calculator", key="open_u_value_calc_roof"):
575
+ session_state["show_u_value_calculator"] = True
576
+ session_state["u_value_calc_component_type"] = "roof"
577
+ st.experimental_rerun()
578
+
579
+ def _display_floor_selection(self, session_state: Dict[str, Any]) -> None:
580
+ """
581
+ Display floor selection interface.
582
+
583
+ Args:
584
+ session_state: Streamlit session state for storing form data
585
+ """
586
+ st.subheader("Floor Components")
587
+
588
+ # Display existing floors
589
+ if session_state["components"]["floors"]:
590
+ st.write("Existing Floors:")
591
+
592
+ # Create a DataFrame for display
593
+ floor_data = []
594
+ for i, floor in enumerate(session_state["components"]["floors"]):
595
+ floor_data.append({
596
+ "ID": floor.id,
597
+ "Name": floor.name,
598
+ "Area (m²)": floor.area,
599
+ "U-Value (W/m²·K)": floor.u_value,
600
+ "Floor Type": floor.floor_type
601
+ })
602
+
603
+ floor_df = pd.DataFrame(floor_data)
604
+ st.dataframe(floor_df, use_container_width=True)
605
+
606
+ # Add edit and delete buttons
607
+ col1, col2 = st.columns(2)
608
+
609
+ with col1:
610
+ floor_to_edit = st.selectbox(
611
+ "Select Floor to Edit",
612
+ options=[f"#{i+1}: {floor.name}" for i, floor in enumerate(session_state["components"]["floors"])],
613
+ key="floor_to_edit"
614
+ )
615
+
616
+ if st.button("Edit Selected Floor", key="edit_floor_button"):
617
+ # Get index of floor to edit
618
+ floor_index = int(floor_to_edit.split(":")[0][1:]) - 1
619
+
620
+ # Set edit flag and index
621
+ session_state["edit_floor"] = True
622
+ session_state["edit_floor_index"] = floor_index
623
+
624
+ # Set form values from selected floor
625
+ floor = session_state["components"]["floors"][floor_index]
626
+ session_state["floor_name"] = floor.name
627
+ session_state["floor_area"] = floor.area
628
+ session_state["floor_u_value"] = floor.u_value
629
+ session_state["floor_type"] = floor.floor_type
630
+
631
+ st.experimental_rerun()
632
+
633
+ with col2:
634
+ floor_to_delete = st.selectbox(
635
+ "Select Floor to Delete",
636
+ options=[f"#{i+1}: {floor.name}" for i, floor in enumerate(session_state["components"]["floors"])],
637
+ key="floor_to_delete"
638
+ )
639
+
640
+ if st.button("Delete Selected Floor", key="delete_floor_button"):
641
+ # Get index of floor to delete
642
+ floor_index = int(floor_to_delete.split(":")[0][1:]) - 1
643
+
644
+ # Remove floor from session state
645
+ session_state["components"]["floors"].pop(floor_index)
646
+
647
+ st.success(f"Floor {floor_to_delete} deleted successfully!")
648
+ st.experimental_rerun()
649
+
650
+ # Add new floor form
651
+ st.write("Add New Floor:")
652
+
653
+ # Initialize edit flag if not exists
654
+ if "edit_floor" not in session_state:
655
+ session_state["edit_floor"] = False
656
+ session_state["edit_floor_index"] = -1
657
+
658
+ # Initialize form values if not exists
659
+ if "floor_name" not in session_state:
660
+ session_state["floor_name"] = ""
661
+ session_state["floor_area"] = 0.0
662
+ session_state["floor_u_value"] = 0.4
663
+ session_state["floor_type"] = "Concrete"
664
+
665
+ # Create form
666
+ with st.form("floor_form"):
667
+ col1, col2 = st.columns(2)
668
+
669
+ with col1:
670
+ floor_name = st.text_input(
671
+ "Floor Name",
672
+ value=session_state["floor_name"],
673
+ key="floor_name_input",
674
+ help="Enter a descriptive name for the floor"
675
+ )
676
+
677
+ floor_area = st.number_input(
678
+ "Area (m²)",
679
+ min_value=0.0,
680
+ value=session_state["floor_area"],
681
+ step=0.1,
682
+ key="floor_area_input",
683
+ help="Enter the area of the floor in square meters"
684
+ )
685
+
686
+ with col2:
687
+ # Option to select from preset or custom
688
+ floor_selection_method = st.radio(
689
+ "Floor Selection Method",
690
+ options=["Select from Presets", "Custom U-Value"],
691
+ key="floor_selection_method"
692
+ )
693
+
694
+ if floor_selection_method == "Select from Presets":
695
+ # Get preset floor types from component library
696
+ preset_floors = self.component_library.get_preset_floors()
697
+ floor_types = [floor["name"] for floor in preset_floors]
698
+
699
+ floor_type = st.selectbox(
700
+ "Floor Type",
701
+ options=floor_types,
702
+ index=floor_types.index(session_state["floor_type"]) if session_state["floor_type"] in floor_types else 0,
703
+ key="floor_type_input",
704
+ help="Select the type of floor construction"
705
+ )
706
+
707
+ # Get U-value from selected preset
708
+ selected_floor = next((floor for floor in preset_floors if floor["name"] == floor_type), None)
709
+ if selected_floor:
710
+ floor_u_value = selected_floor["u_value"]
711
+ else:
712
+ floor_u_value = session_state["floor_u_value"]
713
+
714
+ # Display U-value (read-only)
715
+ st.number_input(
716
+ "U-Value (W/m²·K)",
717
+ min_value=0.0,
718
+ value=floor_u_value,
719
+ step=0.01,
720
+ key="floor_u_value_display",
721
+ disabled=True,
722
+ help="U-value of the selected floor type"
723
+ )
724
+ else:
725
+ # Custom U-value input
726
+ floor_u_value = st.number_input(
727
+ "U-Value (W/m²·K)",
728
+ min_value=0.0,
729
+ value=session_state["floor_u_value"],
730
+ step=0.01,
731
+ key="floor_u_value_input",
732
+ help="Enter the U-value of the floor"
733
+ )
734
+
735
+ # Set floor type to "Custom"
736
+ floor_type = "Custom"
737
+
738
+ # Submit button
739
+ if session_state["edit_floor"]:
740
+ submit_label = "Update Floor"
741
+ else:
742
+ submit_label = "Add Floor"
743
+
744
+ submitted = st.form_submit_button(submit_label)
745
+
746
+ if submitted:
747
+ # Validate inputs
748
+ if not floor_name:
749
+ st.error("Floor name is required!")
750
+ elif floor_area <= 0:
751
+ st.error("Floor area must be greater than zero!")
752
+ elif floor_u_value <= 0:
753
+ st.error("Floor U-value must be greater than zero!")
754
+ else:
755
+ # Create floor object
756
+ floor = Floor(
757
+ id=str(uuid.uuid4()) if not session_state["edit_floor"] else session_state["components"]["floors"][session_state["edit_floor_index"]].id,
758
+ name=floor_name,
759
+ component_type=ComponentType.FLOOR,
760
+ u_value=floor_u_value,
761
+ area=floor_area,
762
+ floor_type=floor_type
763
+ )
764
+
765
+ if session_state["edit_floor"]:
766
+ # Update existing floor
767
+ session_state["components"]["floors"][session_state["edit_floor_index"]] = floor
768
+ st.success(f"Floor '{floor_name}' updated successfully!")
769
+
770
+ # Reset edit flag
771
+ session_state["edit_floor"] = False
772
+ session_state["edit_floor_index"] = -1
773
+ else:
774
+ # Add new floor
775
+ session_state["components"]["floors"].append(floor)
776
+ st.success(f"Floor '{floor_name}' added successfully!")
777
+
778
+ # Reset form values
779
+ session_state["floor_name"] = ""
780
+ session_state["floor_area"] = 0.0
781
+ session_state["floor_u_value"] = 0.4
782
+ session_state["floor_type"] = "Concrete"
783
+
784
+ # Save components to file
785
+ self.save_components(session_state["components"])
786
+
787
+ st.experimental_rerun()
788
+
789
+ # Add U-value calculator button
790
+ if st.button("Open U-Value Calculator", key="open_u_value_calc_floor"):
791
+ session_state["show_u_value_calculator"] = True
792
+ session_state["u_value_calc_component_type"] = "floor"
793
+ st.experimental_rerun()
794
+
795
+ def _display_window_selection(self, session_state: Dict[str, Any]) -> None:
796
+ """
797
+ Display window selection interface.
798
+
799
+ Args:
800
+ session_state: Streamlit session state for storing form data
801
+ """
802
+ st.subheader("Window Components")
803
+
804
+ # Display existing windows
805
+ if session_state["components"]["windows"]:
806
+ st.write("Existing Windows:")
807
+
808
+ # Create a DataFrame for display
809
+ window_data = []
810
+ for i, window in enumerate(session_state["components"]["windows"]):
811
+ window_data.append({
812
+ "ID": window.id,
813
+ "Name": window.name,
814
+ "Orientation": window.orientation.name,
815
+ "Area (m²)": window.area,
816
+ "U-Value (W/m²·K)": window.u_value,
817
+ "SHGC": window.shgc,
818
+ "Window Type": window.window_type
819
+ })
820
+
821
+ window_df = pd.DataFrame(window_data)
822
+ st.dataframe(window_df, use_container_width=True)
823
+
824
+ # Add edit and delete buttons
825
+ col1, col2 = st.columns(2)
826
+
827
+ with col1:
828
+ window_to_edit = st.selectbox(
829
+ "Select Window to Edit",
830
+ options=[f"#{i+1}: {window.name}" for i, window in enumerate(session_state["components"]["windows"])],
831
+ key="window_to_edit"
832
+ )
833
+
834
+ if st.button("Edit Selected Window", key="edit_window_button"):
835
+ # Get index of window to edit
836
+ window_index = int(window_to_edit.split(":")[0][1:]) - 1
837
+
838
+ # Set edit flag and index
839
+ session_state["edit_window"] = True
840
+ session_state["edit_window_index"] = window_index
841
+
842
+ # Set form values from selected window
843
+ window = session_state["components"]["windows"][window_index]
844
+ session_state["window_name"] = window.name
845
+ session_state["window_orientation"] = window.orientation.name
846
+ session_state["window_area"] = window.area
847
+ session_state["window_u_value"] = window.u_value
848
+ session_state["window_shgc"] = window.shgc
849
+ session_state["window_vt"] = window.vt
850
+ session_state["window_type"] = window.window_type
851
+ session_state["window_glazing_layers"] = window.glazing_layers
852
+ session_state["window_gas_fill"] = window.gas_fill
853
+ session_state["window_low_e_coating"] = window.low_e_coating
854
+
855
+ st.experimental_rerun()
856
+
857
+ with col2:
858
+ window_to_delete = st.selectbox(
859
+ "Select Window to Delete",
860
+ options=[f"#{i+1}: {window.name}" for i, window in enumerate(session_state["components"]["windows"])],
861
+ key="window_to_delete"
862
+ )
863
+
864
+ if st.button("Delete Selected Window", key="delete_window_button"):
865
+ # Get index of window to delete
866
+ window_index = int(window_to_delete.split(":")[0][1:]) - 1
867
+
868
+ # Remove window from session state
869
+ session_state["components"]["windows"].pop(window_index)
870
+
871
+ st.success(f"Window {window_to_delete} deleted successfully!")
872
+ st.experimental_rerun()
873
+
874
+ # Add new window form
875
+ st.write("Add New Window:")
876
+
877
+ # Initialize edit flag if not exists
878
+ if "edit_window" not in session_state:
879
+ session_state["edit_window"] = False
880
+ session_state["edit_window_index"] = -1
881
+
882
+ # Initialize form values if not exists
883
+ if "window_name" not in session_state:
884
+ session_state["window_name"] = ""
885
+ session_state["window_orientation"] = "NORTH"
886
+ session_state["window_area"] = 0.0
887
+ session_state["window_u_value"] = 2.8
888
+ session_state["window_shgc"] = 0.7
889
+ session_state["window_vt"] = 0.8
890
+ session_state["window_type"] = "Double Glazed"
891
+ session_state["window_glazing_layers"] = 2
892
+ session_state["window_gas_fill"] = "Air"
893
+ session_state["window_low_e_coating"] = False
894
+
895
+ # Create form
896
+ with st.form("window_form"):
897
+ col1, col2 = st.columns(2)
898
+
899
+ with col1:
900
+ window_name = st.text_input(
901
+ "Window Name",
902
+ value=session_state["window_name"],
903
+ key="window_name_input",
904
+ help="Enter a descriptive name for the window"
905
+ )
906
+
907
+ window_orientation = st.selectbox(
908
+ "Orientation",
909
+ options=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
910
+ index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["window_orientation"]),
911
+ key="window_orientation_input",
912
+ help="Select the orientation of the window"
913
+ )
914
+
915
+ window_area = st.number_input(
916
+ "Area (m²)",
917
+ min_value=0.0,
918
+ value=session_state["window_area"],
919
+ step=0.1,
920
+ key="window_area_input",
921
+ help="Enter the area of the window in square meters"
922
+ )
923
+
924
+ with col2:
925
+ # Option to select from preset or custom
926
+ window_selection_method = st.radio(
927
+ "Window Selection Method",
928
+ options=["Select from Presets", "Custom Properties"],
929
+ key="window_selection_method"
930
+ )
931
+
932
+ if window_selection_method == "Select from Presets":
933
+ # Get preset window types from component library
934
+ preset_windows = self.component_library.get_preset_windows()
935
+ window_types = [window["name"] for window in preset_windows]
936
+
937
+ window_type = st.selectbox(
938
+ "Window Type",
939
+ options=window_types,
940
+ index=window_types.index(session_state["window_type"]) if session_state["window_type"] in window_types else 0,
941
+ key="window_type_input",
942
+ help="Select the type of window"
943
+ )
944
+
945
+ # Get properties from selected preset
946
+ selected_window = next((window for window in preset_windows if window["name"] == window_type), None)
947
+ if selected_window:
948
+ window_u_value = selected_window["u_value"]
949
+ window_shgc = selected_window["shgc"]
950
+ window_vt = selected_window["vt"]
951
+ window_glazing_layers = selected_window["glazing_layers"]
952
+ window_gas_fill = selected_window["gas_fill"]
953
+ window_low_e_coating = selected_window["low_e_coating"]
954
+ else:
955
+ window_u_value = session_state["window_u_value"]
956
+ window_shgc = session_state["window_shgc"]
957
+ window_vt = session_state["window_vt"]
958
+ window_glazing_layers = session_state["window_glazing_layers"]
959
+ window_gas_fill = session_state["window_gas_fill"]
960
+ window_low_e_coating = session_state["window_low_e_coating"]
961
+
962
+ # Display properties (read-only)
963
+ st.number_input(
964
+ "U-Value (W/m²·K)",
965
+ min_value=0.0,
966
+ value=window_u_value,
967
+ step=0.01,
968
+ key="window_u_value_display",
969
+ disabled=True,
970
+ help="U-value of the selected window type"
971
+ )
972
+
973
+ st.number_input(
974
+ "SHGC",
975
+ min_value=0.0,
976
+ max_value=1.0,
977
+ value=window_shgc,
978
+ step=0.01,
979
+ key="window_shgc_display",
980
+ disabled=True,
981
+ help="Solar Heat Gain Coefficient of the selected window type"
982
+ )
983
+ else:
984
+ # Custom properties input
985
+ window_u_value = st.number_input(
986
+ "U-Value (W/m²·K)",
987
+ min_value=0.0,
988
+ value=session_state["window_u_value"],
989
+ step=0.01,
990
+ key="window_u_value_input",
991
+ help="Enter the U-value of the window"
992
+ )
993
+
994
+ window_shgc = st.number_input(
995
+ "SHGC",
996
+ min_value=0.0,
997
+ max_value=1.0,
998
+ value=session_state["window_shgc"],
999
+ step=0.01,
1000
+ key="window_shgc_input",
1001
+ help="Enter the Solar Heat Gain Coefficient of the window"
1002
+ )
1003
+
1004
+ window_vt = st.number_input(
1005
+ "Visible Transmittance",
1006
+ min_value=0.0,
1007
+ max_value=1.0,
1008
+ value=session_state["window_vt"],
1009
+ step=0.01,
1010
+ key="window_vt_input",
1011
+ help="Enter the Visible Transmittance of the window"
1012
+ )
1013
+
1014
+ window_glazing_layers = st.selectbox(
1015
+ "Glazing Layers",
1016
+ options=[1, 2, 3],
1017
+ index=[1, 2, 3].index(session_state["window_glazing_layers"]),
1018
+ key="window_glazing_layers_input",
1019
+ help="Select the number of glazing layers"
1020
+ )
1021
+
1022
+ window_gas_fill = st.selectbox(
1023
+ "Gas Fill",
1024
+ options=["Air", "Argon", "Krypton"],
1025
+ index=["Air", "Argon", "Krypton"].index(session_state["window_gas_fill"]),
1026
+ key="window_gas_fill_input",
1027
+ help="Select the gas fill between glazing layers"
1028
+ )
1029
+
1030
+ window_low_e_coating = st.checkbox(
1031
+ "Low-E Coating",
1032
+ value=session_state["window_low_e_coating"],
1033
+ key="window_low_e_coating_input",
1034
+ help="Check if the window has low-emissivity coating"
1035
+ )
1036
+
1037
+ # Set window type to "Custom"
1038
+ window_type = "Custom"
1039
+
1040
+ # Submit button
1041
+ if session_state["edit_window"]:
1042
+ submit_label = "Update Window"
1043
+ else:
1044
+ submit_label = "Add Window"
1045
+
1046
+ submitted = st.form_submit_button(submit_label)
1047
+
1048
+ if submitted:
1049
+ # Validate inputs
1050
+ if not window_name:
1051
+ st.error("Window name is required!")
1052
+ elif window_area <= 0:
1053
+ st.error("Window area must be greater than zero!")
1054
+ elif window_u_value <= 0:
1055
+ st.error("Window U-value must be greater than zero!")
1056
+ elif window_shgc <= 0 or window_shgc > 1:
1057
+ st.error("Window SHGC must be between 0 and 1!")
1058
+ else:
1059
+ # Create window object
1060
+ window = Window(
1061
+ id=str(uuid.uuid4()) if not session_state["edit_window"] else session_state["components"]["windows"][session_state["edit_window_index"]].id,
1062
+ name=window_name,
1063
+ component_type=ComponentType.WINDOW,
1064
+ u_value=window_u_value,
1065
+ area=window_area,
1066
+ orientation=Orientation[window_orientation],
1067
+ shgc=window_shgc,
1068
+ vt=window_vt,
1069
+ window_type=window_type,
1070
+ glazing_layers=window_glazing_layers,
1071
+ gas_fill=window_gas_fill,
1072
+ low_e_coating=window_low_e_coating
1073
+ )
1074
+
1075
+ if session_state["edit_window"]:
1076
+ # Update existing window
1077
+ session_state["components"]["windows"][session_state["edit_window_index"]] = window
1078
+ st.success(f"Window '{window_name}' updated successfully!")
1079
+
1080
+ # Reset edit flag
1081
+ session_state["edit_window"] = False
1082
+ session_state["edit_window_index"] = -1
1083
+ else:
1084
+ # Add new window
1085
+ session_state["components"]["windows"].append(window)
1086
+ st.success(f"Window '{window_name}' added successfully!")
1087
+
1088
+ # Reset form values
1089
+ session_state["window_name"] = ""
1090
+ session_state["window_orientation"] = "NORTH"
1091
+ session_state["window_area"] = 0.0
1092
+ session_state["window_u_value"] = 2.8
1093
+ session_state["window_shgc"] = 0.7
1094
+ session_state["window_vt"] = 0.8
1095
+ session_state["window_type"] = "Double Glazed"
1096
+ session_state["window_glazing_layers"] = 2
1097
+ session_state["window_gas_fill"] = "Air"
1098
+ session_state["window_low_e_coating"] = False
1099
+
1100
+ # Save components to file
1101
+ self.save_components(session_state["components"])
1102
+
1103
+ st.experimental_rerun()
1104
+
1105
+ def _display_door_selection(self, session_state: Dict[str, Any]) -> None:
1106
+ """
1107
+ Display door selection interface.
1108
+
1109
+ Args:
1110
+ session_state: Streamlit session state for storing form data
1111
+ """
1112
+ st.subheader("Door Components")
1113
+
1114
+ # Display existing doors
1115
+ if session_state["components"]["doors"]:
1116
+ st.write("Existing Doors:")
1117
+
1118
+ # Create a DataFrame for display
1119
+ door_data = []
1120
+ for i, door in enumerate(session_state["components"]["doors"]):
1121
+ door_data.append({
1122
+ "ID": door.id,
1123
+ "Name": door.name,
1124
+ "Orientation": door.orientation.name,
1125
+ "Area (m²)": door.area,
1126
+ "U-Value (W/m²·K)": door.u_value,
1127
+ "Door Type": door.door_type
1128
+ })
1129
+
1130
+ door_df = pd.DataFrame(door_data)
1131
+ st.dataframe(door_df, use_container_width=True)
1132
+
1133
+ # Add edit and delete buttons
1134
+ col1, col2 = st.columns(2)
1135
+
1136
+ with col1:
1137
+ door_to_edit = st.selectbox(
1138
+ "Select Door to Edit",
1139
+ options=[f"#{i+1}: {door.name}" for i, door in enumerate(session_state["components"]["doors"])],
1140
+ key="door_to_edit"
1141
+ )
1142
+
1143
+ if st.button("Edit Selected Door", key="edit_door_button"):
1144
+ # Get index of door to edit
1145
+ door_index = int(door_to_edit.split(":")[0][1:]) - 1
1146
+
1147
+ # Set edit flag and index
1148
+ session_state["edit_door"] = True
1149
+ session_state["edit_door_index"] = door_index
1150
+
1151
+ # Set form values from selected door
1152
+ door = session_state["components"]["doors"][door_index]
1153
+ session_state["door_name"] = door.name
1154
+ session_state["door_orientation"] = door.orientation.name
1155
+ session_state["door_area"] = door.area
1156
+ session_state["door_u_value"] = door.u_value
1157
+ session_state["door_type"] = door.door_type
1158
+
1159
+ st.experimental_rerun()
1160
+
1161
+ with col2:
1162
+ door_to_delete = st.selectbox(
1163
+ "Select Door to Delete",
1164
+ options=[f"#{i+1}: {door.name}" for i, door in enumerate(session_state["components"]["doors"])],
1165
+ key="door_to_delete"
1166
+ )
1167
+
1168
+ if st.button("Delete Selected Door", key="delete_door_button"):
1169
+ # Get index of door to delete
1170
+ door_index = int(door_to_delete.split(":")[0][1:]) - 1
1171
+
1172
+ # Remove door from session state
1173
+ session_state["components"]["doors"].pop(door_index)
1174
+
1175
+ st.success(f"Door {door_to_delete} deleted successfully!")
1176
+ st.experimental_rerun()
1177
+
1178
+ # Add new door form
1179
+ st.write("Add New Door:")
1180
+
1181
+ # Initialize edit flag if not exists
1182
+ if "edit_door" not in session_state:
1183
+ session_state["edit_door"] = False
1184
+ session_state["edit_door_index"] = -1
1185
+
1186
+ # Initialize form values if not exists
1187
+ if "door_name" not in session_state:
1188
+ session_state["door_name"] = ""
1189
+ session_state["door_orientation"] = "NORTH"
1190
+ session_state["door_area"] = 0.0
1191
+ session_state["door_u_value"] = 2.0
1192
+ session_state["door_type"] = "Solid Wood"
1193
+
1194
+ # Create form
1195
+ with st.form("door_form"):
1196
+ col1, col2 = st.columns(2)
1197
+
1198
+ with col1:
1199
+ door_name = st.text_input(
1200
+ "Door Name",
1201
+ value=session_state["door_name"],
1202
+ key="door_name_input",
1203
+ help="Enter a descriptive name for the door"
1204
+ )
1205
+
1206
+ door_orientation = st.selectbox(
1207
+ "Orientation",
1208
+ options=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
1209
+ index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["door_orientation"]),
1210
+ key="door_orientation_input",
1211
+ help="Select the orientation of the door"
1212
+ )
1213
+
1214
+ door_area = st.number_input(
1215
+ "Area (m²)",
1216
+ min_value=0.0,
1217
+ value=session_state["door_area"],
1218
+ step=0.1,
1219
+ key="door_area_input",
1220
+ help="Enter the area of the door in square meters"
1221
+ )
1222
+
1223
+ with col2:
1224
+ # Option to select from preset or custom
1225
+ door_selection_method = st.radio(
1226
+ "Door Selection Method",
1227
+ options=["Select from Presets", "Custom U-Value"],
1228
+ key="door_selection_method"
1229
+ )
1230
+
1231
+ if door_selection_method == "Select from Presets":
1232
+ # Get preset door types from component library
1233
+ preset_doors = self.component_library.get_preset_doors()
1234
+ door_types = [door["name"] for door in preset_doors]
1235
+
1236
+ door_type = st.selectbox(
1237
+ "Door Type",
1238
+ options=door_types,
1239
+ index=door_types.index(session_state["door_type"]) if session_state["door_type"] in door_types else 0,
1240
+ key="door_type_input",
1241
+ help="Select the type of door"
1242
+ )
1243
+
1244
+ # Get U-value from selected preset
1245
+ selected_door = next((door for door in preset_doors if door["name"] == door_type), None)
1246
+ if selected_door:
1247
+ door_u_value = selected_door["u_value"]
1248
+ else:
1249
+ door_u_value = session_state["door_u_value"]
1250
+
1251
+ # Display U-value (read-only)
1252
+ st.number_input(
1253
+ "U-Value (W/m²·K)",
1254
+ min_value=0.0,
1255
+ value=door_u_value,
1256
+ step=0.01,
1257
+ key="door_u_value_display",
1258
+ disabled=True,
1259
+ help="U-value of the selected door type"
1260
+ )
1261
+ else:
1262
+ # Custom U-value input
1263
+ door_u_value = st.number_input(
1264
+ "U-Value (W/m²·K)",
1265
+ min_value=0.0,
1266
+ value=session_state["door_u_value"],
1267
+ step=0.01,
1268
+ key="door_u_value_input",
1269
+ help="Enter the U-value of the door"
1270
+ )
1271
+
1272
+ # Set door type to "Custom"
1273
+ door_type = "Custom"
1274
+
1275
+ # Submit button
1276
+ if session_state["edit_door"]:
1277
+ submit_label = "Update Door"
1278
+ else:
1279
+ submit_label = "Add Door"
1280
+
1281
+ submitted = st.form_submit_button(submit_label)
1282
+
1283
+ if submitted:
1284
+ # Validate inputs
1285
+ if not door_name:
1286
+ st.error("Door name is required!")
1287
+ elif door_area <= 0:
1288
+ st.error("Door area must be greater than zero!")
1289
+ elif door_u_value <= 0:
1290
+ st.error("Door U-value must be greater than zero!")
1291
+ else:
1292
+ # Create door object
1293
+ door = Door(
1294
+ id=str(uuid.uuid4()) if not session_state["edit_door"] else session_state["components"]["doors"][session_state["edit_door_index"]].id,
1295
+ name=door_name,
1296
+ component_type=ComponentType.DOOR,
1297
+ u_value=door_u_value,
1298
+ area=door_area,
1299
+ orientation=Orientation[door_orientation],
1300
+ door_type=door_type
1301
+ )
1302
+
1303
+ if session_state["edit_door"]:
1304
+ # Update existing door
1305
+ session_state["components"]["doors"][session_state["edit_door_index"]] = door
1306
+ st.success(f"Door '{door_name}' updated successfully!")
1307
+
1308
+ # Reset edit flag
1309
+ session_state["edit_door"] = False
1310
+ session_state["edit_door_index"] = -1
1311
+ else:
1312
+ # Add new door
1313
+ session_state["components"]["doors"].append(door)
1314
+ st.success(f"Door '{door_name}' added successfully!")
1315
+
1316
+ # Reset form values
1317
+ session_state["door_name"] = ""
1318
+ session_state["door_orientation"] = "NORTH"
1319
+ session_state["door_area"] = 0.0
1320
+ session_state["door_u_value"] = 2.0
1321
+ session_state["door_type"] = "Solid Wood"
1322
+
1323
+ # Save components to file
1324
+ self.save_components(session_state["components"])
1325
+
1326
+ st.experimental_rerun()
1327
+
1328
+ # Add U-value calculator button
1329
+ if st.button("Open U-Value Calculator", key="open_u_value_calc_door"):
1330
+ session_state["show_u_value_calculator"] = True
1331
+ session_state["u_value_calc_component_type"] = "door"
1332
+ st.experimental_rerun()
1333
+
1334
+ def display_u_value_calculator(self, session_state: Dict[str, Any]) -> None:
1335
+ """
1336
+ Display U-value calculator in Streamlit.
1337
+
1338
+ Args:
1339
+ session_state: Streamlit session state for storing form data
1340
+ """
1341
+ st.header("U-Value Calculator")
1342
+
1343
+ # Get component type
1344
+ component_type = session_state.get("u_value_calc_component_type", "wall")
1345
+
1346
+ # Display component type
1347
+ st.write(f"Calculating U-value for: **{component_type.title()}**")
1348
+
1349
+ # Initialize material layers if not exists
1350
+ if "material_layers" not in session_state:
1351
+ session_state["material_layers"] = []
1352
+
1353
+ # Display existing layers
1354
+ if session_state["material_layers"]:
1355
+ st.write("Current Material Layers (from outside to inside):")
1356
+
1357
+ # Create a DataFrame for display
1358
+ layer_data = []
1359
+ for i, layer in enumerate(session_state["material_layers"]):
1360
+ layer_data.append({
1361
+ "Layer": i + 1,
1362
+ "Material": layer["name"],
1363
+ "Thickness (mm)": layer["thickness"],
1364
+ "Conductivity (W/m·K)": layer["conductivity"],
1365
+ "R-Value (m²·K/W)": layer["thickness"] / 1000 / layer["conductivity"]
1366
+ })
1367
+
1368
+ layer_df = pd.DataFrame(layer_data)
1369
+ st.dataframe(layer_df, use_container_width=True)
1370
+
1371
+ # Calculate total R-value and U-value
1372
+ r_outside = 0.04 # m²·K/W (outside surface resistance)
1373
+ r_inside = 0.13 # m²·K/W (inside surface resistance)
1374
+
1375
+ r_layers = sum([layer["thickness"] / 1000 / layer["conductivity"] for layer in session_state["material_layers"]])
1376
+ r_total = r_outside + r_layers + r_inside
1377
+ u_value = 1 / r_total
1378
+
1379
+ # Display results
1380
+ col1, col2, col3 = st.columns(3)
1381
+
1382
+ with col1:
1383
+ st.metric("Total R-Value", f"{r_total:.3f} m²·K/W")
1384
+
1385
+ with col2:
1386
+ st.metric("U-Value", f"{u_value:.3f} W/m²·K")
1387
+
1388
+ with col3:
1389
+ if st.button("Use This U-Value"):
1390
+ # Set U-value in the appropriate form
1391
+ if component_type == "wall":
1392
+ session_state["wall_u_value"] = u_value
1393
+ elif component_type == "roof":
1394
+ session_state["roof_u_value"] = u_value
1395
+ elif component_type == "floor":
1396
+ session_state["floor_u_value"] = u_value
1397
+ elif component_type == "door":
1398
+ session_state["door_u_value"] = u_value
1399
+
1400
+ # Close calculator
1401
+ session_state["show_u_value_calculator"] = False
1402
+ st.success(f"U-value set to {u_value:.3f} W/m²·K")
1403
+ st.experimental_rerun()
1404
+
1405
+ # Add delete button
1406
+ if st.button("Remove Last Layer"):
1407
+ if session_state["material_layers"]:
1408
+ session_state["material_layers"].pop()
1409
+ st.experimental_rerun()
1410
+
1411
+ # Add new layer form
1412
+ st.write("Add New Material Layer:")
1413
+
1414
+ with st.form("material_layer_form"):
1415
+ col1, col2 = st.columns(2)
1416
+
1417
+ with col1:
1418
+ # Option to select from preset or custom
1419
+ material_selection_method = st.radio(
1420
+ "Material Selection Method",
1421
+ options=["Select from Presets", "Custom Material"],
1422
+ key="material_selection_method"
1423
+ )
1424
+
1425
+ if material_selection_method == "Select from Presets":
1426
+ # Get preset materials from U-value calculator
1427
+ preset_materials = self.u_value_calculator.get_preset_materials()
1428
+ material_names = [material["name"] for material in preset_materials]
1429
+
1430
+ material_name = st.selectbox(
1431
+ "Material",
1432
+ options=material_names,
1433
+ key="material_name_input",
1434
+ help="Select a material from the presets"
1435
+ )
1436
+
1437
+ # Get conductivity from selected preset
1438
+ selected_material = next((material for material in preset_materials if material["name"] == material_name), None)
1439
+ if selected_material:
1440
+ material_conductivity = selected_material["conductivity"]
1441
+ else:
1442
+ material_conductivity = 1.0
1443
+
1444
+ # Display conductivity (read-only)
1445
+ st.number_input(
1446
+ "Thermal Conductivity (W/m·K)",
1447
+ min_value=0.0,
1448
+ value=material_conductivity,
1449
+ step=0.01,
1450
+ key="material_conductivity_display",
1451
+ disabled=True,
1452
+ help="Thermal conductivity of the selected material"
1453
+ )
1454
+ else:
1455
+ # Custom material input
1456
+ material_name = st.text_input(
1457
+ "Material Name",
1458
+ key="custom_material_name_input",
1459
+ help="Enter a name for the custom material"
1460
+ )
1461
+
1462
+ material_conductivity = st.number_input(
1463
+ "Thermal Conductivity (W/m·K)",
1464
+ min_value=0.0,
1465
+ value=1.0,
1466
+ step=0.01,
1467
+ key="material_conductivity_input",
1468
+ help="Enter the thermal conductivity of the material"
1469
+ )
1470
+
1471
+ with col2:
1472
+ material_thickness = st.number_input(
1473
+ "Thickness (mm)",
1474
+ min_value=0.0,
1475
+ value=100.0,
1476
+ step=1.0,
1477
+ key="material_thickness_input",
1478
+ help="Enter the thickness of the material layer in millimeters"
1479
+ )
1480
+
1481
+ # Calculate and display R-value
1482
+ if material_conductivity > 0:
1483
+ r_value = material_thickness / 1000 / material_conductivity
1484
+ st.metric("Layer R-Value", f"{r_value:.3f} m²·K/W")
1485
+
1486
+ # Submit button
1487
+ submitted = st.form_submit_button("Add Layer")
1488
+
1489
+ if submitted:
1490
+ # Validate inputs
1491
+ if not material_name:
1492
+ st.error("Material name is required!")
1493
+ elif material_thickness <= 0:
1494
+ st.error("Material thickness must be greater than zero!")
1495
+ elif material_conductivity <= 0:
1496
+ st.error("Material conductivity must be greater than zero!")
1497
+ else:
1498
+ # Add material layer
1499
+ session_state["material_layers"].append({
1500
+ "name": material_name,
1501
+ "thickness": material_thickness,
1502
+ "conductivity": material_conductivity
1503
+ })
1504
+
1505
+ st.success(f"Material layer '{material_name}' added successfully!")
1506
+ st.experimental_rerun()
1507
+
1508
+ # Add close button
1509
+ if st.button("Close Calculator"):
1510
+ session_state["show_u_value_calculator"] = False
1511
+ session_state["material_layers"] = []
1512
+ st.experimental_rerun()
1513
+
1514
+ def save_components(self, components: Dict[str, List[Any]], file_path: str = "components.json") -> None:
1515
+ """
1516
+ Save components to a JSON file.
1517
+
1518
+ Args:
1519
+ components: Dictionary with building components
1520
+ file_path: Path to save the JSON file
1521
+ """
1522
+ try:
1523
+ # Convert components to serializable format
1524
+ serializable_components = {
1525
+ "walls": [wall.__dict__ for wall in components["walls"]],
1526
+ "roofs": [roof.__dict__ for roof in components["roofs"]],
1527
+ "floors": [floor.__dict__ for floor in components["floors"]],
1528
+ "windows": [window.__dict__ for window in components["windows"]],
1529
+ "doors": [door.__dict__ for door in components["doors"]]
1530
+ }
1531
+
1532
+ # Convert Orientation enum to string
1533
+ for component_type in serializable_components:
1534
+ for component in serializable_components[component_type]:
1535
+ if "orientation" in component and hasattr(component["orientation"], "name"):
1536
+ component["orientation"] = component["orientation"].name
1537
+ if "component_type" in component and hasattr(component["component_type"], "name"):
1538
+ component["component_type"] = component["component_type"].name
1539
+
1540
+ with open(file_path, "w") as f:
1541
+ json.dump(serializable_components, f, indent=4)
1542
+ except Exception as e:
1543
+ st.error(f"Error saving components: {e}")
1544
+
1545
+ def load_components(self, file_path: str = "components.json") -> Dict[str, List[Any]]:
1546
+ """
1547
+ Load components from a JSON file.
1548
+
1549
+ Args:
1550
+ file_path: Path to the JSON file
1551
+
1552
+ Returns:
1553
+ Dictionary with building components
1554
+ """
1555
+ if os.path.exists(file_path):
1556
+ try:
1557
+ with open(file_path, "r") as f:
1558
+ serialized_components = json.load(f)
1559
+
1560
+ # Convert serialized components back to objects
1561
+ components = {
1562
+ "walls": [],
1563
+ "roofs": [],
1564
+ "floors": [],
1565
+ "windows": [],
1566
+ "doors": []
1567
+ }
1568
+
1569
+ # Convert walls
1570
+ for wall_dict in serialized_components.get("walls", []):
1571
+ wall = Wall(
1572
+ id=wall_dict.get("id", str(uuid.uuid4())),
1573
+ name=wall_dict.get("name", ""),
1574
+ component_type=ComponentType[wall_dict.get("component_type", "WALL")],
1575
+ u_value=wall_dict.get("u_value", 0.0),
1576
+ area=wall_dict.get("area", 0.0),
1577
+ orientation=Orientation[wall_dict.get("orientation", "NORTH")],
1578
+ wall_type=wall_dict.get("wall_type", ""),
1579
+ wall_group=wall_dict.get("wall_group", "")
1580
+ )
1581
+ components["walls"].append(wall)
1582
+
1583
+ # Convert roofs
1584
+ for roof_dict in serialized_components.get("roofs", []):
1585
+ roof = Roof(
1586
+ id=roof_dict.get("id", str(uuid.uuid4())),
1587
+ name=roof_dict.get("name", ""),
1588
+ component_type=ComponentType[roof_dict.get("component_type", "ROOF")],
1589
+ u_value=roof_dict.get("u_value", 0.0),
1590
+ area=roof_dict.get("area", 0.0),
1591
+ orientation=Orientation[roof_dict.get("orientation", "HORIZONTAL")],
1592
+ roof_type=roof_dict.get("roof_type", ""),
1593
+ roof_group=roof_dict.get("roof_group", "")
1594
+ )
1595
+ components["roofs"].append(roof)
1596
+
1597
+ # Convert floors
1598
+ for floor_dict in serialized_components.get("floors", []):
1599
+ floor = Floor(
1600
+ id=floor_dict.get("id", str(uuid.uuid4())),
1601
+ name=floor_dict.get("name", ""),
1602
+ component_type=ComponentType[floor_dict.get("component_type", "FLOOR")],
1603
+ u_value=floor_dict.get("u_value", 0.0),
1604
+ area=floor_dict.get("area", 0.0),
1605
+ floor_type=floor_dict.get("floor_type", "")
1606
+ )
1607
+ components["floors"].append(floor)
1608
+
1609
+ # Convert windows
1610
+ for window_dict in serialized_components.get("windows", []):
1611
+ window = Window(
1612
+ id=window_dict.get("id", str(uuid.uuid4())),
1613
+ name=window_dict.get("name", ""),
1614
+ component_type=ComponentType[window_dict.get("component_type", "WINDOW")],
1615
+ u_value=window_dict.get("u_value", 0.0),
1616
+ area=window_dict.get("area", 0.0),
1617
+ orientation=Orientation[window_dict.get("orientation", "NORTH")],
1618
+ shgc=window_dict.get("shgc", 0.0),
1619
+ vt=window_dict.get("vt", 0.0),
1620
+ window_type=window_dict.get("window_type", ""),
1621
+ glazing_layers=window_dict.get("glazing_layers", 1),
1622
+ gas_fill=window_dict.get("gas_fill", ""),
1623
+ low_e_coating=window_dict.get("low_e_coating", False)
1624
+ )
1625
+ components["windows"].append(window)
1626
+
1627
+ # Convert doors
1628
+ for door_dict in serialized_components.get("doors", []):
1629
+ door = Door(
1630
+ id=door_dict.get("id", str(uuid.uuid4())),
1631
+ name=door_dict.get("name", ""),
1632
+ component_type=ComponentType[door_dict.get("component_type", "DOOR")],
1633
+ u_value=door_dict.get("u_value", 0.0),
1634
+ area=door_dict.get("area", 0.0),
1635
+ orientation=Orientation[door_dict.get("orientation", "NORTH")],
1636
+ door_type=door_dict.get("door_type", "")
1637
+ )
1638
+ components["doors"].append(door)
1639
+
1640
+ return components
1641
+ except Exception as e:
1642
+ st.error(f"Error loading components: {e}")
1643
+
1644
+ # Return empty components if file doesn't exist or error occurs
1645
+ return {
1646
+ "walls": [],
1647
+ "roofs": [],
1648
+ "floors": [],
1649
+ "windows": [],
1650
+ "doors": []
1651
+ }
1652
+
1653
+
1654
+ # Create a singleton instance
1655
+ component_selection = ComponentSelectionInterface()
1656
+
1657
+ # Example usage
1658
+ if __name__ == "__main__":
1659
+ import streamlit as st
1660
+
1661
+ # Initialize session state
1662
+ if "components" not in st.session_state:
1663
+ st.session_state["components"] = component_selection.load_components()
1664
+
1665
+ # Check if U-value calculator should be shown
1666
+ if "show_u_value_calculator" not in st.session_state:
1667
+ st.session_state["show_u_value_calculator"] = False
1668
+
1669
+ if st.session_state["show_u_value_calculator"]:
1670
+ # Display U-value calculator
1671
+ component_selection.display_u_value_calculator(st.session_state)
1672
+ else:
1673
+ # Display component selection interface
1674
+ component_selection.display_component_selection(st.session_state)
app/data_export.py ADDED
@@ -0,0 +1,870 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data export module for HVAC Load Calculator.
3
+ This module provides functionality for exporting calculation results.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ import json
11
+ import os
12
+ import base64
13
+ import io
14
+ from datetime import datetime
15
+ import xlsxwriter
16
+
17
+
18
+ class DataExport:
19
+ """Class for data export functionality."""
20
+
21
+ @staticmethod
22
+ def export_to_csv(data: Dict[str, Any], file_path: str = None) -> Optional[str]:
23
+ """
24
+ Export data to CSV format.
25
+
26
+ Args:
27
+ data: Dictionary with data to export
28
+ file_path: Optional path to save the CSV file
29
+
30
+ Returns:
31
+ CSV string if file_path is None, otherwise None
32
+ """
33
+ try:
34
+ # Create DataFrame from data
35
+ df = pd.DataFrame(data)
36
+
37
+ # Convert to CSV
38
+ csv_data = df.to_csv(index=False)
39
+
40
+ # Save to file if path provided
41
+ if file_path:
42
+ df.to_csv(file_path, index=False)
43
+ return None
44
+
45
+ # Return CSV string if no path provided
46
+ return csv_data
47
+
48
+ except Exception as e:
49
+ st.error(f"Error exporting to CSV: {e}")
50
+ return None
51
+
52
+ @staticmethod
53
+ def export_to_excel(data_dict: Dict[str, pd.DataFrame], file_path: str = None) -> Optional[bytes]:
54
+ """
55
+ Export data to Excel format.
56
+
57
+ Args:
58
+ data_dict: Dictionary with sheet names and DataFrames
59
+ file_path: Optional path to save the Excel file
60
+
61
+ Returns:
62
+ Excel bytes if file_path is None, otherwise None
63
+ """
64
+ try:
65
+ # Create Excel file in memory or on disk
66
+ if file_path:
67
+ writer = pd.ExcelWriter(file_path, engine='xlsxwriter')
68
+ else:
69
+ output = io.BytesIO()
70
+ writer = pd.ExcelWriter(output, engine='xlsxwriter')
71
+
72
+ # Write each DataFrame to a different sheet
73
+ for sheet_name, df in data_dict.items():
74
+ df.to_excel(writer, sheet_name=sheet_name, index=False)
75
+
76
+ # Auto-adjust column widths
77
+ worksheet = writer.sheets[sheet_name]
78
+ for i, col in enumerate(df.columns):
79
+ max_width = max(
80
+ df[col].astype(str).map(len).max(),
81
+ len(col)
82
+ ) + 2
83
+ worksheet.set_column(i, i, max_width)
84
+
85
+ # Save the Excel file
86
+ writer.close()
87
+
88
+ # Return Excel bytes if no path provided
89
+ if not file_path:
90
+ output.seek(0)
91
+ return output.getvalue()
92
+
93
+ return None
94
+
95
+ except Exception as e:
96
+ st.error(f"Error exporting to Excel: {e}")
97
+ return None
98
+
99
+ @staticmethod
100
+ def export_scenario_to_json(scenario: Dict[str, Any], file_path: str = None) -> Optional[str]:
101
+ """
102
+ Export scenario data to JSON format.
103
+
104
+ Args:
105
+ scenario: Dictionary with scenario data
106
+ file_path: Optional path to save the JSON file
107
+
108
+ Returns:
109
+ JSON string if file_path is None, otherwise None
110
+ """
111
+ try:
112
+ # Convert to JSON
113
+ json_data = json.dumps(scenario, indent=4)
114
+
115
+ # Save to file if path provided
116
+ if file_path:
117
+ with open(file_path, "w") as f:
118
+ f.write(json_data)
119
+ return None
120
+
121
+ # Return JSON string if no path provided
122
+ return json_data
123
+
124
+ except Exception as e:
125
+ st.error(f"Error exporting scenario to JSON: {e}")
126
+ return None
127
+
128
+ @staticmethod
129
+ def get_download_link(data: Any, filename: str, text: str, mime_type: str = "text/csv") -> str:
130
+ """
131
+ Generate a download link for data.
132
+
133
+ Args:
134
+ data: Data to download
135
+ filename: Name of the file to download
136
+ text: Text to display for the download link
137
+ mime_type: MIME type of the file
138
+
139
+ Returns:
140
+ HTML string with download link
141
+ """
142
+ if isinstance(data, str):
143
+ b64 = base64.b64encode(data.encode()).decode()
144
+ else:
145
+ b64 = base64.b64encode(data).decode()
146
+
147
+ href = f'<a href="data:{mime_type};base64,{b64}" download="{filename}">{text}</a>'
148
+ return href
149
+
150
+ @staticmethod
151
+ def create_cooling_load_dataframes(results: Dict[str, Any]) -> Dict[str, pd.DataFrame]:
152
+ """
153
+ Create DataFrames for cooling load results.
154
+
155
+ Args:
156
+ results: Dictionary with calculation results
157
+
158
+ Returns:
159
+ Dictionary with DataFrames for Excel export
160
+ """
161
+ dataframes = {}
162
+
163
+ # Create summary DataFrame
164
+ summary_data = {
165
+ "Metric": [
166
+ "Total Cooling Load",
167
+ "Sensible Cooling Load",
168
+ "Latent Cooling Load",
169
+ "Cooling Load per Area"
170
+ ],
171
+ "Value": [
172
+ results["cooling"]["total_load"],
173
+ results["cooling"]["sensible_load"],
174
+ results["cooling"]["latent_load"],
175
+ results["cooling"]["load_per_area"]
176
+ ],
177
+ "Unit": [
178
+ "kW",
179
+ "kW",
180
+ "kW",
181
+ "W/m²"
182
+ ]
183
+ }
184
+
185
+ dataframes["Cooling Summary"] = pd.DataFrame(summary_data)
186
+
187
+ # Create component breakdown DataFrame
188
+ component_data = {
189
+ "Component": [
190
+ "Walls",
191
+ "Roof",
192
+ "Windows",
193
+ "Doors",
194
+ "People",
195
+ "Lighting",
196
+ "Equipment",
197
+ "Infiltration",
198
+ "Ventilation"
199
+ ],
200
+ "Load (kW)": [
201
+ results["cooling"]["component_loads"]["walls"],
202
+ results["cooling"]["component_loads"]["roof"],
203
+ results["cooling"]["component_loads"]["windows"],
204
+ results["cooling"]["component_loads"]["doors"],
205
+ results["cooling"]["component_loads"]["people"],
206
+ results["cooling"]["component_loads"]["lighting"],
207
+ results["cooling"]["component_loads"]["equipment"],
208
+ results["cooling"]["component_loads"]["infiltration"],
209
+ results["cooling"]["component_loads"]["ventilation"]
210
+ ],
211
+ "Percentage (%)": [
212
+ results["cooling"]["component_loads"]["walls"] / results["cooling"]["total_load"] * 100,
213
+ results["cooling"]["component_loads"]["roof"] / results["cooling"]["total_load"] * 100,
214
+ results["cooling"]["component_loads"]["windows"] / results["cooling"]["total_load"] * 100,
215
+ results["cooling"]["component_loads"]["doors"] / results["cooling"]["total_load"] * 100,
216
+ results["cooling"]["component_loads"]["people"] / results["cooling"]["total_load"] * 100,
217
+ results["cooling"]["component_loads"]["lighting"] / results["cooling"]["total_load"] * 100,
218
+ results["cooling"]["component_loads"]["equipment"] / results["cooling"]["total_load"] * 100,
219
+ results["cooling"]["component_loads"]["infiltration"] / results["cooling"]["total_load"] * 100,
220
+ results["cooling"]["component_loads"]["ventilation"] / results["cooling"]["total_load"] * 100
221
+ ]
222
+ }
223
+
224
+ dataframes["Cooling Components"] = pd.DataFrame(component_data)
225
+
226
+ # Create detailed loads DataFrames
227
+
228
+ # Walls
229
+ wall_data = []
230
+ for wall in results["cooling"]["detailed_loads"]["walls"]:
231
+ wall_data.append({
232
+ "Name": wall["name"],
233
+ "Orientation": wall["orientation"],
234
+ "Area (m²)": wall["area"],
235
+ "U-Value (W/m²·K)": wall["u_value"],
236
+ "CLTD (°C)": wall["cltd"],
237
+ "Load (kW)": wall["load"]
238
+ })
239
+
240
+ if wall_data:
241
+ dataframes["Cooling Walls"] = pd.DataFrame(wall_data)
242
+
243
+ # Roofs
244
+ roof_data = []
245
+ for roof in results["cooling"]["detailed_loads"]["roofs"]:
246
+ roof_data.append({
247
+ "Name": roof["name"],
248
+ "Orientation": roof["orientation"],
249
+ "Area (m²)": roof["area"],
250
+ "U-Value (W/m²·K)": roof["u_value"],
251
+ "CLTD (°C)": roof["cltd"],
252
+ "Load (kW)": roof["load"]
253
+ })
254
+
255
+ if roof_data:
256
+ dataframes["Cooling Roofs"] = pd.DataFrame(roof_data)
257
+
258
+ # Windows
259
+ window_data = []
260
+ for window in results["cooling"]["detailed_loads"]["windows"]:
261
+ window_data.append({
262
+ "Name": window["name"],
263
+ "Orientation": window["orientation"],
264
+ "Area (m²)": window["area"],
265
+ "U-Value (W/m²·K)": window["u_value"],
266
+ "SHGC": window["shgc"],
267
+ "SCL (W/m²)": window["scl"],
268
+ "Load (kW)": window["load"]
269
+ })
270
+
271
+ if window_data:
272
+ dataframes["Cooling Windows"] = pd.DataFrame(window_data)
273
+
274
+ # Doors
275
+ door_data = []
276
+ for door in results["cooling"]["detailed_loads"]["doors"]:
277
+ door_data.append({
278
+ "Name": door["name"],
279
+ "Orientation": door["orientation"],
280
+ "Area (m²)": door["area"],
281
+ "U-Value (W/m²·K)": door["u_value"],
282
+ "CLTD (°C)": door["cltd"],
283
+ "Load (kW)": door["load"]
284
+ })
285
+
286
+ if door_data:
287
+ dataframes["Cooling Doors"] = pd.DataFrame(door_data)
288
+
289
+ # Internal loads
290
+ internal_data = []
291
+ for internal_load in results["cooling"]["detailed_loads"]["internal"]:
292
+ internal_data.append({
293
+ "Type": internal_load["type"],
294
+ "Name": internal_load["name"],
295
+ "Quantity": internal_load["quantity"],
296
+ "Heat Gain (W)": internal_load["heat_gain"],
297
+ "CLF": internal_load["clf"],
298
+ "Load (kW)": internal_load["load"]
299
+ })
300
+
301
+ if internal_data:
302
+ dataframes["Cooling Internal Loads"] = pd.DataFrame(internal_data)
303
+
304
+ # Infiltration and ventilation
305
+ air_data = [
306
+ {
307
+ "Type": "Infiltration",
308
+ "Air Flow (m³/s)": results["cooling"]["detailed_loads"]["infiltration"]["air_flow"],
309
+ "Sensible Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["sensible_load"],
310
+ "Latent Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["latent_load"],
311
+ "Total Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["total_load"]
312
+ },
313
+ {
314
+ "Type": "Ventilation",
315
+ "Air Flow (m³/s)": results["cooling"]["detailed_loads"]["ventilation"]["air_flow"],
316
+ "Sensible Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["sensible_load"],
317
+ "Latent Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["latent_load"],
318
+ "Total Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["total_load"]
319
+ }
320
+ ]
321
+
322
+ dataframes["Cooling Air Exchange"] = pd.DataFrame(air_data)
323
+
324
+ return dataframes
325
+
326
+ @staticmethod
327
+ def create_heating_load_dataframes(results: Dict[str, Any]) -> Dict[str, pd.DataFrame]:
328
+ """
329
+ Create DataFrames for heating load results.
330
+
331
+ Args:
332
+ results: Dictionary with calculation results
333
+
334
+ Returns:
335
+ Dictionary with DataFrames for Excel export
336
+ """
337
+ dataframes = {}
338
+
339
+ # Create summary DataFrame
340
+ summary_data = {
341
+ "Metric": [
342
+ "Total Heating Load",
343
+ "Heating Load per Area",
344
+ "Design Heat Loss",
345
+ "Safety Factor"
346
+ ],
347
+ "Value": [
348
+ results["heating"]["total_load"],
349
+ results["heating"]["load_per_area"],
350
+ results["heating"]["design_heat_loss"],
351
+ results["heating"]["safety_factor"]
352
+ ],
353
+ "Unit": [
354
+ "kW",
355
+ "W/m²",
356
+ "kW",
357
+ "%"
358
+ ]
359
+ }
360
+
361
+ dataframes["Heating Summary"] = pd.DataFrame(summary_data)
362
+
363
+ # Create component breakdown DataFrame
364
+ component_data = {
365
+ "Component": [
366
+ "Walls",
367
+ "Roof",
368
+ "Floor",
369
+ "Windows",
370
+ "Doors",
371
+ "Infiltration",
372
+ "Ventilation"
373
+ ],
374
+ "Load (kW)": [
375
+ results["heating"]["component_loads"]["walls"],
376
+ results["heating"]["component_loads"]["roof"],
377
+ results["heating"]["component_loads"]["floor"],
378
+ results["heating"]["component_loads"]["windows"],
379
+ results["heating"]["component_loads"]["doors"],
380
+ results["heating"]["component_loads"]["infiltration"],
381
+ results["heating"]["component_loads"]["ventilation"]
382
+ ],
383
+ "Percentage (%)": [
384
+ results["heating"]["component_loads"]["walls"] / results["heating"]["total_load"] * 100,
385
+ results["heating"]["component_loads"]["roof"] / results["heating"]["total_load"] * 100,
386
+ results["heating"]["component_loads"]["floor"] / results["heating"]["total_load"] * 100,
387
+ results["heating"]["component_loads"]["windows"] / results["heating"]["total_load"] * 100,
388
+ results["heating"]["component_loads"]["doors"] / results["heating"]["total_load"] * 100,
389
+ results["heating"]["component_loads"]["infiltration"] / results["heating"]["total_load"] * 100,
390
+ results["heating"]["component_loads"]["ventilation"] / results["heating"]["total_load"] * 100
391
+ ]
392
+ }
393
+
394
+ dataframes["Heating Components"] = pd.DataFrame(component_data)
395
+
396
+ # Create detailed loads DataFrames
397
+
398
+ # Walls
399
+ wall_data = []
400
+ for wall in results["heating"]["detailed_loads"]["walls"]:
401
+ wall_data.append({
402
+ "Name": wall["name"],
403
+ "Orientation": wall["orientation"],
404
+ "Area (m²)": wall["area"],
405
+ "U-Value (W/m²·K)": wall["u_value"],
406
+ "Temperature Difference (°C)": wall["delta_t"],
407
+ "Load (kW)": wall["load"]
408
+ })
409
+
410
+ if wall_data:
411
+ dataframes["Heating Walls"] = pd.DataFrame(wall_data)
412
+
413
+ # Roofs
414
+ roof_data = []
415
+ for roof in results["heating"]["detailed_loads"]["roofs"]:
416
+ roof_data.append({
417
+ "Name": roof["name"],
418
+ "Orientation": roof["orientation"],
419
+ "Area (m²)": roof["area"],
420
+ "U-Value (W/m²·K)": roof["u_value"],
421
+ "Temperature Difference (°C)": roof["delta_t"],
422
+ "Load (kW)": roof["load"]
423
+ })
424
+
425
+ if roof_data:
426
+ dataframes["Heating Roofs"] = pd.DataFrame(roof_data)
427
+
428
+ # Floors
429
+ floor_data = []
430
+ for floor in results["heating"]["detailed_loads"]["floors"]:
431
+ floor_data.append({
432
+ "Name": floor["name"],
433
+ "Area (m²)": floor["area"],
434
+ "U-Value (W/m²·K)": floor["u_value"],
435
+ "Temperature Difference (°C)": floor["delta_t"],
436
+ "Load (kW)": floor["load"]
437
+ })
438
+
439
+ if floor_data:
440
+ dataframes["Heating Floors"] = pd.DataFrame(floor_data)
441
+
442
+ # Windows
443
+ window_data = []
444
+ for window in results["heating"]["detailed_loads"]["windows"]:
445
+ window_data.append({
446
+ "Name": window["name"],
447
+ "Orientation": window["orientation"],
448
+ "Area (m²)": window["area"],
449
+ "U-Value (W/m²·K)": window["u_value"],
450
+ "Temperature Difference (°C)": window["delta_t"],
451
+ "Load (kW)": window["load"]
452
+ })
453
+
454
+ if window_data:
455
+ dataframes["Heating Windows"] = pd.DataFrame(window_data)
456
+
457
+ # Doors
458
+ door_data = []
459
+ for door in results["heating"]["detailed_loads"]["doors"]:
460
+ door_data.append({
461
+ "Name": door["name"],
462
+ "Orientation": door["orientation"],
463
+ "Area (m²)": door["area"],
464
+ "U-Value (W/m²·K)": door["u_value"],
465
+ "Temperature Difference (°C)": door["delta_t"],
466
+ "Load (kW)": door["load"]
467
+ })
468
+
469
+ if door_data:
470
+ dataframes["Heating Doors"] = pd.DataFrame(door_data)
471
+
472
+ # Infiltration and ventilation
473
+ air_data = [
474
+ {
475
+ "Type": "Infiltration",
476
+ "Air Flow (m³/s)": results["heating"]["detailed_loads"]["infiltration"]["air_flow"],
477
+ "Temperature Difference (°C)": results["heating"]["detailed_loads"]["infiltration"]["delta_t"],
478
+ "Load (kW)": results["heating"]["detailed_loads"]["infiltration"]["load"]
479
+ },
480
+ {
481
+ "Type": "Ventilation",
482
+ "Air Flow (m³/s)": results["heating"]["detailed_loads"]["ventilation"]["air_flow"],
483
+ "Temperature Difference (°C)": results["heating"]["detailed_loads"]["ventilation"]["delta_t"],
484
+ "Load (kW)": results["heating"]["detailed_loads"]["ventilation"]["load"]
485
+ }
486
+ ]
487
+
488
+ dataframes["Heating Air Exchange"] = pd.DataFrame(air_data)
489
+
490
+ return dataframes
491
+
492
+ @staticmethod
493
+ def display_export_interface(session_state: Dict[str, Any]) -> None:
494
+ """
495
+ Display export interface in Streamlit.
496
+
497
+ Args:
498
+ session_state: Streamlit session state containing calculation results
499
+ """
500
+ st.header("Export Results")
501
+
502
+ # Check if calculations have been performed
503
+ if "calculation_results" not in session_state or not session_state["calculation_results"]:
504
+ st.warning("No calculation results available. Please run calculations first.")
505
+ return
506
+
507
+ # Create tabs for different export options
508
+ tab1, tab2, tab3 = st.tabs(["CSV Export", "Excel Export", "Scenario Export"])
509
+
510
+ with tab1:
511
+ DataExport._display_csv_export(session_state)
512
+
513
+ with tab2:
514
+ DataExport._display_excel_export(session_state)
515
+
516
+ with tab3:
517
+ DataExport._display_scenario_export(session_state)
518
+
519
+ @staticmethod
520
+ def _display_csv_export(session_state: Dict[str, Any]) -> None:
521
+ """
522
+ Display CSV export interface.
523
+
524
+ Args:
525
+ session_state: Streamlit session state containing calculation results
526
+ """
527
+ st.subheader("CSV Export")
528
+
529
+ # Get results
530
+ results = session_state["calculation_results"]
531
+
532
+ # Create tabs for cooling and heating loads
533
+ tab1, tab2 = st.tabs(["Cooling Load CSV", "Heating Load CSV"])
534
+
535
+ with tab1:
536
+ # Create cooling load DataFrames
537
+ cooling_dfs = DataExport.create_cooling_load_dataframes(results)
538
+
539
+ # Display and export each DataFrame
540
+ for sheet_name, df in cooling_dfs.items():
541
+ st.write(f"### {sheet_name}")
542
+ st.dataframe(df)
543
+
544
+ # Add download button
545
+ csv_data = DataExport.export_to_csv(df)
546
+ if csv_data:
547
+ filename = f"{sheet_name.replace(' ', '_').lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
548
+ download_link = DataExport.get_download_link(csv_data, filename, f"Download {sheet_name} CSV")
549
+ st.markdown(download_link, unsafe_allow_html=True)
550
+
551
+ with tab2:
552
+ # Create heating load DataFrames
553
+ heating_dfs = DataExport.create_heating_load_dataframes(results)
554
+
555
+ # Display and export each DataFrame
556
+ for sheet_name, df in heating_dfs.items():
557
+ st.write(f"### {sheet_name}")
558
+ st.dataframe(df)
559
+
560
+ # Add download button
561
+ csv_data = DataExport.export_to_csv(df)
562
+ if csv_data:
563
+ filename = f"{sheet_name.replace(' ', '_').lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
564
+ download_link = DataExport.get_download_link(csv_data, filename, f"Download {sheet_name} CSV")
565
+ st.markdown(download_link, unsafe_allow_html=True)
566
+
567
+ @staticmethod
568
+ def _display_excel_export(session_state: Dict[str, Any]) -> None:
569
+ """
570
+ Display Excel export interface.
571
+
572
+ Args:
573
+ session_state: Streamlit session state containing calculation results
574
+ """
575
+ st.subheader("Excel Export")
576
+
577
+ # Get results
578
+ results = session_state["calculation_results"]
579
+
580
+ # Create tabs for cooling, heating, and combined loads
581
+ tab1, tab2, tab3 = st.tabs(["Cooling Load Excel", "Heating Load Excel", "Combined Excel"])
582
+
583
+ with tab1:
584
+ # Create cooling load DataFrames
585
+ cooling_dfs = DataExport.create_cooling_load_dataframes(results)
586
+
587
+ # Add download button
588
+ excel_data = DataExport.export_to_excel(cooling_dfs)
589
+ if excel_data:
590
+ filename = f"cooling_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
591
+ download_link = DataExport.get_download_link(
592
+ excel_data,
593
+ filename,
594
+ "Download Cooling Load Excel",
595
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
596
+ )
597
+ st.markdown(download_link, unsafe_allow_html=True)
598
+
599
+ # Display preview
600
+ st.write("### Excel Preview")
601
+ st.write("The Excel file will contain the following sheets:")
602
+ for sheet_name in cooling_dfs.keys():
603
+ st.write(f"- {sheet_name}")
604
+
605
+ with tab2:
606
+ # Create heating load DataFrames
607
+ heating_dfs = DataExport.create_heating_load_dataframes(results)
608
+
609
+ # Add download button
610
+ excel_data = DataExport.export_to_excel(heating_dfs)
611
+ if excel_data:
612
+ filename = f"heating_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
613
+ download_link = DataExport.get_download_link(
614
+ excel_data,
615
+ filename,
616
+ "Download Heating Load Excel",
617
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
618
+ )
619
+ st.markdown(download_link, unsafe_allow_html=True)
620
+
621
+ # Display preview
622
+ st.write("### Excel Preview")
623
+ st.write("The Excel file will contain the following sheets:")
624
+ for sheet_name in heating_dfs.keys():
625
+ st.write(f"- {sheet_name}")
626
+
627
+ with tab3:
628
+ # Create combined DataFrames
629
+ combined_dfs = {}
630
+
631
+ # Add project information
632
+ if "building_info" in session_state:
633
+ project_info = [
634
+ {"Parameter": "Project Name", "Value": session_state["building_info"].get("project_name", "")},
635
+ {"Parameter": "Building Name", "Value": session_state["building_info"].get("building_name", "")},
636
+ {"Parameter": "Location", "Value": session_state["building_info"].get("location", "")},
637
+ {"Parameter": "Climate Zone", "Value": session_state["building_info"].get("climate_zone", "")},
638
+ {"Parameter": "Building Type", "Value": session_state["building_info"].get("building_type", "")},
639
+ {"Parameter": "Floor Area", "Value": session_state["building_info"].get("floor_area", "")},
640
+ {"Parameter": "Number of Floors", "Value": session_state["building_info"].get("num_floors", "")},
641
+ {"Parameter": "Floor Height", "Value": session_state["building_info"].get("floor_height", "")},
642
+ {"Parameter": "Orientation", "Value": session_state["building_info"].get("orientation", "")},
643
+ {"Parameter": "Occupancy", "Value": session_state["building_info"].get("occupancy", "")},
644
+ {"Parameter": "Operating Hours", "Value": session_state["building_info"].get("operating_hours", "")},
645
+ {"Parameter": "Date", "Value": datetime.now().strftime("%Y-%m-%d")},
646
+ {"Parameter": "Time", "Value": datetime.now().strftime("%H:%M:%S")}
647
+ ]
648
+
649
+ combined_dfs["Project Information"] = pd.DataFrame(project_info)
650
+
651
+ # Add cooling load DataFrames
652
+ cooling_dfs = DataExport.create_cooling_load_dataframes(results)
653
+ for sheet_name, df in cooling_dfs.items():
654
+ combined_dfs[sheet_name] = df
655
+
656
+ # Add heating load DataFrames
657
+ heating_dfs = DataExport.create_heating_load_dataframes(results)
658
+ for sheet_name, df in heating_dfs.items():
659
+ combined_dfs[sheet_name] = df
660
+
661
+ # Add download button
662
+ excel_data = DataExport.export_to_excel(combined_dfs)
663
+ if excel_data:
664
+ filename = f"hvac_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
665
+ download_link = DataExport.get_download_link(
666
+ excel_data,
667
+ filename,
668
+ "Download Combined Excel Report",
669
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
670
+ )
671
+ st.markdown(download_link, unsafe_allow_html=True)
672
+
673
+ # Display preview
674
+ st.write("### Excel Preview")
675
+ st.write("The Excel file will contain the following sheets:")
676
+ for sheet_name in combined_dfs.keys():
677
+ st.write(f"- {sheet_name}")
678
+
679
+ @staticmethod
680
+ def _display_scenario_export(session_state: Dict[str, Any]) -> None:
681
+ """
682
+ Display scenario export interface.
683
+
684
+ Args:
685
+ session_state: Streamlit session state containing calculation results
686
+ """
687
+ st.subheader("Scenario Export")
688
+
689
+ # Check if there are saved scenarios
690
+ if "saved_scenarios" not in session_state or not session_state["saved_scenarios"]:
691
+ st.info("No saved scenarios available for export. Save the current results as a scenario to enable export.")
692
+
693
+ # Add button to save current results as a scenario
694
+ scenario_name = st.text_input("Scenario Name", value="Baseline")
695
+
696
+ if st.button("Save Current Results as Scenario"):
697
+ if "saved_scenarios" not in session_state:
698
+ session_state["saved_scenarios"] = {}
699
+
700
+ # Save current results as a scenario
701
+ session_state["saved_scenarios"][scenario_name] = {
702
+ "results": session_state["calculation_results"],
703
+ "building_info": session_state["building_info"],
704
+ "components": session_state["components"],
705
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
706
+ }
707
+
708
+ st.success(f"Scenario '{scenario_name}' saved successfully!")
709
+ st.experimental_rerun()
710
+ else:
711
+ # Display saved scenarios
712
+ st.write("### Saved Scenarios")
713
+
714
+ # Create selectbox for scenarios
715
+ scenario_names = list(session_state["saved_scenarios"].keys())
716
+ selected_scenario = st.selectbox("Select Scenario to Export", scenario_names)
717
+
718
+ if selected_scenario:
719
+ # Get selected scenario
720
+ scenario = session_state["saved_scenarios"][selected_scenario]
721
+
722
+ # Display scenario information
723
+ st.write(f"**Scenario:** {selected_scenario}")
724
+ st.write(f"**Timestamp:** {scenario['timestamp']}")
725
+
726
+ # Add download button
727
+ json_data = DataExport.export_scenario_to_json(scenario)
728
+ if json_data:
729
+ filename = f"{selected_scenario.replace(' ', '_').lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
730
+ download_link = DataExport.get_download_link(
731
+ json_data,
732
+ filename,
733
+ "Download Scenario JSON",
734
+ "application/json"
735
+ )
736
+ st.markdown(download_link, unsafe_allow_html=True)
737
+
738
+ # Add button to export all scenarios
739
+ if st.button("Export All Scenarios"):
740
+ # Create a zip file in memory
741
+ import zipfile
742
+ from io import BytesIO
743
+
744
+ zip_buffer = BytesIO()
745
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
746
+ for scenario_name, scenario in session_state["saved_scenarios"].items():
747
+ # Export scenario to JSON
748
+ json_data = DataExport.export_scenario_to_json(scenario)
749
+ if json_data:
750
+ filename = f"{scenario_name.replace(' ', '_').lower()}.json"
751
+ zip_file.writestr(filename, json_data)
752
+
753
+ # Add download button for zip file
754
+ zip_buffer.seek(0)
755
+ zip_data = zip_buffer.getvalue()
756
+
757
+ filename = f"all_scenarios_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
758
+ download_link = DataExport.get_download_link(
759
+ zip_data,
760
+ filename,
761
+ "Download All Scenarios (ZIP)",
762
+ "application/zip"
763
+ )
764
+ st.markdown(download_link, unsafe_allow_html=True)
765
+
766
+
767
+ # Create a singleton instance
768
+ data_export = DataExport()
769
+
770
+ # Example usage
771
+ if __name__ == "__main__":
772
+ import streamlit as st
773
+
774
+ # Initialize session state with dummy data for testing
775
+ if "calculation_results" not in st.session_state:
776
+ st.session_state["calculation_results"] = {
777
+ "cooling": {
778
+ "total_load": 25.5,
779
+ "sensible_load": 20.0,
780
+ "latent_load": 5.5,
781
+ "load_per_area": 85.0,
782
+ "component_loads": {
783
+ "walls": 5.0,
784
+ "roof": 3.0,
785
+ "windows": 8.0,
786
+ "doors": 1.0,
787
+ "people": 2.5,
788
+ "lighting": 2.0,
789
+ "equipment": 1.5,
790
+ "infiltration": 1.0,
791
+ "ventilation": 1.5
792
+ },
793
+ "detailed_loads": {
794
+ "walls": [
795
+ {"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "cltd": 10.0, "load": 1.0}
796
+ ],
797
+ "roofs": [
798
+ {"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "cltd": 15.0, "load": 3.0}
799
+ ],
800
+ "windows": [
801
+ {"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "shgc": 0.7, "scl": 800.0, "load": 8.0}
802
+ ],
803
+ "doors": [
804
+ {"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "cltd": 10.0, "load": 1.0}
805
+ ],
806
+ "internal": [
807
+ {"type": "People", "name": "Occupants", "quantity": 10, "heat_gain": 250, "clf": 1.0, "load": 2.5},
808
+ {"type": "Lighting", "name": "General Lighting", "quantity": 1000, "heat_gain": 2000, "clf": 1.0, "load": 2.0},
809
+ {"type": "Equipment", "name": "Office Equipment", "quantity": 5, "heat_gain": 300, "clf": 1.0, "load": 1.5}
810
+ ],
811
+ "infiltration": {
812
+ "air_flow": 0.05,
813
+ "sensible_load": 0.8,
814
+ "latent_load": 0.2,
815
+ "total_load": 1.0
816
+ },
817
+ "ventilation": {
818
+ "air_flow": 0.1,
819
+ "sensible_load": 1.0,
820
+ "latent_load": 0.5,
821
+ "total_load": 1.5
822
+ }
823
+ }
824
+ },
825
+ "heating": {
826
+ "total_load": 30.0,
827
+ "load_per_area": 100.0,
828
+ "design_heat_loss": 27.0,
829
+ "safety_factor": 10.0,
830
+ "component_loads": {
831
+ "walls": 8.0,
832
+ "roof": 5.0,
833
+ "floor": 4.0,
834
+ "windows": 7.0,
835
+ "doors": 1.0,
836
+ "infiltration": 2.0,
837
+ "ventilation": 3.0
838
+ },
839
+ "detailed_loads": {
840
+ "walls": [
841
+ {"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "delta_t": 25.0, "load": 8.0}
842
+ ],
843
+ "roofs": [
844
+ {"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "delta_t": 25.0, "load": 5.0}
845
+ ],
846
+ "floors": [
847
+ {"name": "Ground Floor", "area": 100.0, "u_value": 0.4, "delta_t": 10.0, "load": 4.0}
848
+ ],
849
+ "windows": [
850
+ {"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "delta_t": 25.0, "load": 7.0}
851
+ ],
852
+ "doors": [
853
+ {"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "delta_t": 25.0, "load": 1.0}
854
+ ],
855
+ "infiltration": {
856
+ "air_flow": 0.05,
857
+ "delta_t": 25.0,
858
+ "load": 2.0
859
+ },
860
+ "ventilation": {
861
+ "air_flow": 0.1,
862
+ "delta_t": 25.0,
863
+ "load": 3.0
864
+ }
865
+ }
866
+ }
867
+ }
868
+
869
+ # Display export interface
870
+ data_export.display_export_interface(st.session_state)
app/data_persistence.py ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data persistence module for HVAC Load Calculator.
3
+ This module provides functionality for saving and loading project data.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ import json
11
+ import os
12
+ import base64
13
+ import io
14
+ import pickle
15
+ from datetime import datetime
16
+
17
+ # Import data models
18
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
19
+
20
+
21
+ class DataPersistence:
22
+ """Class for data persistence functionality."""
23
+
24
+ @staticmethod
25
+ def save_project_to_json(session_state: Dict[str, Any], file_path: str = None) -> Optional[str]:
26
+ """
27
+ Save project data to a JSON file.
28
+
29
+ Args:
30
+ session_state: Streamlit session state containing project data
31
+ file_path: Optional path to save the JSON file
32
+
33
+ Returns:
34
+ JSON string if file_path is None, otherwise None
35
+ """
36
+ try:
37
+ # Create project data dictionary
38
+ project_data = {
39
+ "building_info": session_state.get("building_info", {}),
40
+ "components": DataPersistence._serialize_components(session_state.get("components", {})),
41
+ "internal_loads": session_state.get("internal_loads", {}),
42
+ "calculation_settings": session_state.get("calculation_settings", {}),
43
+ "saved_scenarios": DataPersistence._serialize_scenarios(session_state.get("saved_scenarios", {})),
44
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
45
+ }
46
+
47
+ # Convert to JSON
48
+ json_data = json.dumps(project_data, indent=4)
49
+
50
+ # Save to file if path provided
51
+ if file_path:
52
+ with open(file_path, "w") as f:
53
+ f.write(json_data)
54
+ return None
55
+
56
+ # Return JSON string if no path provided
57
+ return json_data
58
+
59
+ except Exception as e:
60
+ st.error(f"Error saving project data: {e}")
61
+ return None
62
+
63
+ @staticmethod
64
+ def load_project_from_json(json_data: str = None, file_path: str = None) -> Optional[Dict[str, Any]]:
65
+ """
66
+ Load project data from a JSON file or string.
67
+
68
+ Args:
69
+ json_data: Optional JSON string containing project data
70
+ file_path: Optional path to the JSON file
71
+
72
+ Returns:
73
+ Dictionary with project data if successful, None otherwise
74
+ """
75
+ try:
76
+ # Load from file if path provided
77
+ if file_path and not json_data:
78
+ with open(file_path, "r") as f:
79
+ json_data = f.read()
80
+
81
+ # Parse JSON data
82
+ if json_data:
83
+ project_data = json.loads(json_data)
84
+
85
+ # Deserialize components
86
+ if "components" in project_data:
87
+ project_data["components"] = DataPersistence._deserialize_components(project_data["components"])
88
+
89
+ # Deserialize scenarios
90
+ if "saved_scenarios" in project_data:
91
+ project_data["saved_scenarios"] = DataPersistence._deserialize_scenarios(project_data["saved_scenarios"])
92
+
93
+ return project_data
94
+
95
+ return None
96
+
97
+ except Exception as e:
98
+ st.error(f"Error loading project data: {e}")
99
+ return None
100
+
101
+ @staticmethod
102
+ def _serialize_components(components: Dict[str, List[Any]]) -> Dict[str, List[Dict[str, Any]]]:
103
+ """
104
+ Serialize components for JSON storage.
105
+
106
+ Args:
107
+ components: Dictionary with building components
108
+
109
+ Returns:
110
+ Dictionary with serialized components
111
+ """
112
+ serialized_components = {
113
+ "walls": [],
114
+ "roofs": [],
115
+ "floors": [],
116
+ "windows": [],
117
+ "doors": []
118
+ }
119
+
120
+ # Serialize walls
121
+ for wall in components.get("walls", []):
122
+ serialized_wall = wall.__dict__.copy()
123
+
124
+ # Convert enums to strings
125
+ if hasattr(serialized_wall["orientation"], "name"):
126
+ serialized_wall["orientation"] = serialized_wall["orientation"].name
127
+
128
+ if hasattr(serialized_wall["component_type"], "name"):
129
+ serialized_wall["component_type"] = serialized_wall["component_type"].name
130
+
131
+ serialized_components["walls"].append(serialized_wall)
132
+
133
+ # Serialize roofs
134
+ for roof in components.get("roofs", []):
135
+ serialized_roof = roof.__dict__.copy()
136
+
137
+ # Convert enums to strings
138
+ if hasattr(serialized_roof["orientation"], "name"):
139
+ serialized_roof["orientation"] = serialized_roof["orientation"].name
140
+
141
+ if hasattr(serialized_roof["component_type"], "name"):
142
+ serialized_roof["component_type"] = serialized_roof["component_type"].name
143
+
144
+ serialized_components["roofs"].append(serialized_roof)
145
+
146
+ # Serialize floors
147
+ for floor in components.get("floors", []):
148
+ serialized_floor = floor.__dict__.copy()
149
+
150
+ # Convert enums to strings
151
+ if hasattr(serialized_floor["component_type"], "name"):
152
+ serialized_floor["component_type"] = serialized_floor["component_type"].name
153
+
154
+ serialized_components["floors"].append(serialized_floor)
155
+
156
+ # Serialize windows
157
+ for window in components.get("windows", []):
158
+ serialized_window = window.__dict__.copy()
159
+
160
+ # Convert enums to strings
161
+ if hasattr(serialized_window["orientation"], "name"):
162
+ serialized_window["orientation"] = serialized_window["orientation"].name
163
+
164
+ if hasattr(serialized_window["component_type"], "name"):
165
+ serialized_window["component_type"] = serialized_window["component_type"].name
166
+
167
+ serialized_components["windows"].append(serialized_window)
168
+
169
+ # Serialize doors
170
+ for door in components.get("doors", []):
171
+ serialized_door = door.__dict__.copy()
172
+
173
+ # Convert enums to strings
174
+ if hasattr(serialized_door["orientation"], "name"):
175
+ serialized_door["orientation"] = serialized_door["orientation"].name
176
+
177
+ if hasattr(serialized_door["component_type"], "name"):
178
+ serialized_door["component_type"] = serialized_door["component_type"].name
179
+
180
+ serialized_components["doors"].append(serialized_door)
181
+
182
+ return serialized_components
183
+
184
+ @staticmethod
185
+ def _deserialize_components(serialized_components: Dict[str, List[Dict[str, Any]]]) -> Dict[str, List[Any]]:
186
+ """
187
+ Deserialize components from JSON storage.
188
+
189
+ Args:
190
+ serialized_components: Dictionary with serialized components
191
+
192
+ Returns:
193
+ Dictionary with deserialized components
194
+ """
195
+ components = {
196
+ "walls": [],
197
+ "roofs": [],
198
+ "floors": [],
199
+ "windows": [],
200
+ "doors": []
201
+ }
202
+
203
+ # Deserialize walls
204
+ for wall_dict in serialized_components.get("walls", []):
205
+ wall = Wall(
206
+ id=wall_dict.get("id", ""),
207
+ name=wall_dict.get("name", ""),
208
+ component_type=ComponentType[wall_dict.get("component_type", "WALL")],
209
+ u_value=wall_dict.get("u_value", 0.0),
210
+ area=wall_dict.get("area", 0.0),
211
+ orientation=Orientation[wall_dict.get("orientation", "NORTH")],
212
+ wall_type=wall_dict.get("wall_type", ""),
213
+ wall_group=wall_dict.get("wall_group", "")
214
+ )
215
+ components["walls"].append(wall)
216
+
217
+ # Deserialize roofs
218
+ for roof_dict in serialized_components.get("roofs", []):
219
+ roof = Roof(
220
+ id=roof_dict.get("id", ""),
221
+ name=roof_dict.get("name", ""),
222
+ component_type=ComponentType[roof_dict.get("component_type", "ROOF")],
223
+ u_value=roof_dict.get("u_value", 0.0),
224
+ area=roof_dict.get("area", 0.0),
225
+ orientation=Orientation[roof_dict.get("orientation", "HORIZONTAL")],
226
+ roof_type=roof_dict.get("roof_type", ""),
227
+ roof_group=roof_dict.get("roof_group", "")
228
+ )
229
+ components["roofs"].append(roof)
230
+
231
+ # Deserialize floors
232
+ for floor_dict in serialized_components.get("floors", []):
233
+ floor = Floor(
234
+ id=floor_dict.get("id", ""),
235
+ name=floor_dict.get("name", ""),
236
+ component_type=ComponentType[floor_dict.get("component_type", "FLOOR")],
237
+ u_value=floor_dict.get("u_value", 0.0),
238
+ area=floor_dict.get("area", 0.0),
239
+ floor_type=floor_dict.get("floor_type", "")
240
+ )
241
+ components["floors"].append(floor)
242
+
243
+ # Deserialize windows
244
+ for window_dict in serialized_components.get("windows", []):
245
+ window = Window(
246
+ id=window_dict.get("id", ""),
247
+ name=window_dict.get("name", ""),
248
+ component_type=ComponentType[window_dict.get("component_type", "WINDOW")],
249
+ u_value=window_dict.get("u_value", 0.0),
250
+ area=window_dict.get("area", 0.0),
251
+ orientation=Orientation[window_dict.get("orientation", "NORTH")],
252
+ shgc=window_dict.get("shgc", 0.0),
253
+ vt=window_dict.get("vt", 0.0),
254
+ window_type=window_dict.get("window_type", ""),
255
+ glazing_layers=window_dict.get("glazing_layers", 1),
256
+ gas_fill=window_dict.get("gas_fill", ""),
257
+ low_e_coating=window_dict.get("low_e_coating", False)
258
+ )
259
+ components["windows"].append(window)
260
+
261
+ # Deserialize doors
262
+ for door_dict in serialized_components.get("doors", []):
263
+ door = Door(
264
+ id=door_dict.get("id", ""),
265
+ name=door_dict.get("name", ""),
266
+ component_type=ComponentType[door_dict.get("component_type", "DOOR")],
267
+ u_value=door_dict.get("u_value", 0.0),
268
+ area=door_dict.get("area", 0.0),
269
+ orientation=Orientation[door_dict.get("orientation", "NORTH")],
270
+ door_type=door_dict.get("door_type", "")
271
+ )
272
+ components["doors"].append(door)
273
+
274
+ return components
275
+
276
+ @staticmethod
277
+ def _serialize_scenarios(scenarios: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
278
+ """
279
+ Serialize scenarios for JSON storage.
280
+
281
+ Args:
282
+ scenarios: Dictionary with saved scenarios
283
+
284
+ Returns:
285
+ Dictionary with serialized scenarios
286
+ """
287
+ serialized_scenarios = {}
288
+
289
+ for scenario_name, scenario_data in scenarios.items():
290
+ serialized_scenario = {
291
+ "results": scenario_data.get("results", {}),
292
+ "building_info": scenario_data.get("building_info", {}),
293
+ "components": DataPersistence._serialize_components(scenario_data.get("components", {})),
294
+ "timestamp": scenario_data.get("timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
295
+ }
296
+
297
+ serialized_scenarios[scenario_name] = serialized_scenario
298
+
299
+ return serialized_scenarios
300
+
301
+ @staticmethod
302
+ def _deserialize_scenarios(serialized_scenarios: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
303
+ """
304
+ Deserialize scenarios from JSON storage.
305
+
306
+ Args:
307
+ serialized_scenarios: Dictionary with serialized scenarios
308
+
309
+ Returns:
310
+ Dictionary with deserialized scenarios
311
+ """
312
+ scenarios = {}
313
+
314
+ for scenario_name, serialized_scenario in serialized_scenarios.items():
315
+ scenario = {
316
+ "results": serialized_scenario.get("results", {}),
317
+ "building_info": serialized_scenario.get("building_info", {}),
318
+ "components": DataPersistence._deserialize_components(serialized_scenario.get("components", {})),
319
+ "timestamp": serialized_scenario.get("timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
320
+ }
321
+
322
+ scenarios[scenario_name] = scenario
323
+
324
+ return scenarios
325
+
326
+ @staticmethod
327
+ def get_download_link(data: str, filename: str, text: str) -> str:
328
+ """
329
+ Generate a download link for data.
330
+
331
+ Args:
332
+ data: Data to download
333
+ filename: Name of the file to download
334
+ text: Text to display for the download link
335
+
336
+ Returns:
337
+ HTML string with download link
338
+ """
339
+ b64 = base64.b64encode(data.encode()).decode()
340
+ href = f'<a href="data:file/txt;base64,{b64}" download="{filename}">{text}</a>'
341
+ return href
342
+
343
+ @staticmethod
344
+ def display_project_management(session_state: Dict[str, Any]) -> None:
345
+ """
346
+ Display project management interface in Streamlit.
347
+
348
+ Args:
349
+ session_state: Streamlit session state containing project data
350
+ """
351
+ st.header("Project Management")
352
+
353
+ # Create tabs for different project management functions
354
+ tab1, tab2, tab3 = st.tabs(["Save Project", "Load Project", "Project History"])
355
+
356
+ with tab1:
357
+ DataPersistence._display_save_project(session_state)
358
+
359
+ with tab2:
360
+ DataPersistence._display_load_project(session_state)
361
+
362
+ with tab3:
363
+ DataPersistence._display_project_history(session_state)
364
+
365
+ @staticmethod
366
+ def _display_save_project(session_state: Dict[str, Any]) -> None:
367
+ """
368
+ Display save project interface.
369
+
370
+ Args:
371
+ session_state: Streamlit session state containing project data
372
+ """
373
+ st.subheader("Save Project")
374
+
375
+ # Get project name
376
+ project_name = st.text_input(
377
+ "Project Name",
378
+ value=session_state.get("building_info", {}).get("project_name", "HVAC_Project"),
379
+ key="save_project_name"
380
+ )
381
+
382
+ # Add description
383
+ project_description = st.text_area(
384
+ "Project Description",
385
+ value=session_state.get("project_description", ""),
386
+ key="save_project_description"
387
+ )
388
+
389
+ # Save project description
390
+ session_state["project_description"] = project_description
391
+
392
+ # Add save button
393
+ if st.button("Save Project"):
394
+ # Validate project data
395
+ if "building_info" not in session_state or not session_state["building_info"]:
396
+ st.error("No building information found. Please enter building information before saving.")
397
+ return
398
+
399
+ if "components" not in session_state or not any(session_state["components"].values()):
400
+ st.warning("No building components found. It's recommended to add components before saving.")
401
+
402
+ # Save project data to JSON
403
+ json_data = DataPersistence.save_project_to_json(session_state)
404
+
405
+ if json_data:
406
+ # Generate download link
407
+ filename = f"{project_name.replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.hvac"
408
+ download_link = DataPersistence.get_download_link(json_data, filename, "Download Project File")
409
+
410
+ # Display download link
411
+ st.success("Project saved successfully!")
412
+ st.markdown(download_link, unsafe_allow_html=True)
413
+
414
+ # Save to project history
415
+ if "project_history" not in session_state:
416
+ session_state["project_history"] = []
417
+
418
+ session_state["project_history"].append({
419
+ "name": project_name,
420
+ "description": project_description,
421
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
422
+ "data": json_data
423
+ })
424
+ else:
425
+ st.error("Error saving project data.")
426
+
427
+ @staticmethod
428
+ def _display_load_project(session_state: Dict[str, Any]) -> None:
429
+ """
430
+ Display load project interface.
431
+
432
+ Args:
433
+ session_state: Streamlit session state containing project data
434
+ """
435
+ st.subheader("Load Project")
436
+
437
+ # Add file uploader
438
+ uploaded_file = st.file_uploader("Upload Project File", type=["hvac", "json"])
439
+
440
+ if uploaded_file is not None:
441
+ # Read file content
442
+ json_data = uploaded_file.read().decode("utf-8")
443
+
444
+ # Load project data
445
+ project_data = DataPersistence.load_project_from_json(json_data)
446
+
447
+ if project_data:
448
+ # Add load button
449
+ if st.button("Load Project Data"):
450
+ # Update session state with project data
451
+ for key, value in project_data.items():
452
+ session_state[key] = value
453
+
454
+ st.success("Project loaded successfully!")
455
+ st.experimental_rerun()
456
+ else:
457
+ st.error("Error loading project data. Invalid file format.")
458
+
459
+ @staticmethod
460
+ def _display_project_history(session_state: Dict[str, Any]) -> None:
461
+ """
462
+ Display project history interface.
463
+
464
+ Args:
465
+ session_state: Streamlit session state containing project data
466
+ """
467
+ st.subheader("Project History")
468
+
469
+ # Check if project history exists
470
+ if "project_history" not in session_state or not session_state["project_history"]:
471
+ st.info("No project history found. Save a project to see it in the history.")
472
+ return
473
+
474
+ # Display project history
475
+ for i, project in enumerate(reversed(session_state["project_history"])):
476
+ with st.expander(f"{project['name']} - {project['timestamp']}"):
477
+ st.write(f"**Description:** {project['description']}")
478
+
479
+ # Add load button
480
+ if st.button(f"Load Project", key=f"load_history_{i}"):
481
+ # Load project data
482
+ project_data = DataPersistence.load_project_from_json(project["data"])
483
+
484
+ if project_data:
485
+ # Update session state with project data
486
+ for key, value in project_data.items():
487
+ session_state[key] = value
488
+
489
+ st.success("Project loaded successfully!")
490
+ st.experimental_rerun()
491
+
492
+ # Add download button
493
+ filename = f"{project['name'].replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.hvac"
494
+ download_link = DataPersistence.get_download_link(project["data"], filename, "Download Project File")
495
+ st.markdown(download_link, unsafe_allow_html=True)
496
+
497
+ # Add delete button
498
+ if st.button(f"Delete from History", key=f"delete_history_{i}"):
499
+ # Remove project from history
500
+ session_state["project_history"].remove(project)
501
+
502
+ st.success("Project removed from history.")
503
+ st.experimental_rerun()
504
+
505
+
506
+ # Create a singleton instance
507
+ data_persistence = DataPersistence()
508
+
509
+ # Example usage
510
+ if __name__ == "__main__":
511
+ import streamlit as st
512
+
513
+ # Initialize session state with dummy data for testing
514
+ if "building_info" not in st.session_state:
515
+ st.session_state["building_info"] = {
516
+ "project_name": "Test Project",
517
+ "building_name": "Test Building",
518
+ "location": "New York",
519
+ "climate_zone": "4A",
520
+ "building_type": "Office",
521
+ "floor_area": 1000.0,
522
+ "num_floors": 2,
523
+ "floor_height": 3.0,
524
+ "orientation": "NORTH",
525
+ "occupancy": 50,
526
+ "operating_hours": "8:00-18:00",
527
+ "design_conditions": {
528
+ "summer_outdoor_db": 35.0,
529
+ "summer_outdoor_wb": 25.0,
530
+ "summer_indoor_db": 24.0,
531
+ "summer_indoor_rh": 50.0,
532
+ "winter_outdoor_db": -5.0,
533
+ "winter_outdoor_rh": 80.0,
534
+ "winter_indoor_db": 21.0,
535
+ "winter_indoor_rh": 40.0
536
+ }
537
+ }
538
+
539
+ # Display project management interface
540
+ data_persistence.display_project_management(st.session_state)
app/data_validation.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data validation module for HVAC Load Calculator.
3
+ This module provides validation functions for user inputs.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple, Callable
10
+ import json
11
+ import os
12
+
13
+
14
+ class DataValidation:
15
+ """Class for data validation functionality."""
16
+
17
+ @staticmethod
18
+ def validate_building_info(building_info: Dict[str, Any]) -> Tuple[bool, List[str]]:
19
+ """
20
+ Validate building information inputs.
21
+
22
+ Args:
23
+ building_info: Dictionary with building information
24
+
25
+ Returns:
26
+ Tuple containing validation result (True if valid) and list of validation messages
27
+ """
28
+ is_valid = True
29
+ messages = []
30
+
31
+ # Check required fields
32
+ required_fields = [
33
+ ("project_name", "Project Name"),
34
+ ("building_name", "Building Name"),
35
+ ("location", "Location"),
36
+ ("climate_zone", "Climate Zone"),
37
+ ("building_type", "Building Type")
38
+ ]
39
+
40
+ for field, display_name in required_fields:
41
+ if field not in building_info or not building_info[field]:
42
+ is_valid = False
43
+ messages.append(f"{display_name} is required.")
44
+
45
+ # Check numeric fields
46
+ numeric_fields = [
47
+ ("floor_area", "Floor Area", 0, None),
48
+ ("num_floors", "Number of Floors", 1, None),
49
+ ("floor_height", "Floor Height", 2.0, 10.0),
50
+ ("occupancy", "Occupancy", 0, None)
51
+ ]
52
+
53
+ for field, display_name, min_val, max_val in numeric_fields:
54
+ if field in building_info:
55
+ try:
56
+ value = float(building_info[field])
57
+ if min_val is not None and value < min_val:
58
+ is_valid = False
59
+ messages.append(f"{display_name} must be at least {min_val}.")
60
+ if max_val is not None and value > max_val:
61
+ is_valid = False
62
+ messages.append(f"{display_name} must be at most {max_val}.")
63
+ except (ValueError, TypeError):
64
+ is_valid = False
65
+ messages.append(f"{display_name} must be a number.")
66
+
67
+ # Check design conditions
68
+ if "design_conditions" in building_info:
69
+ design_conditions = building_info["design_conditions"]
70
+
71
+ # Check summer conditions
72
+ summer_fields = [
73
+ ("summer_outdoor_db", "Summer Outdoor Dry-Bulb", -10.0, 50.0),
74
+ ("summer_outdoor_wb", "Summer Outdoor Wet-Bulb", -10.0, 40.0),
75
+ ("summer_indoor_db", "Summer Indoor Dry-Bulb", 18.0, 30.0),
76
+ ("summer_indoor_rh", "Summer Indoor RH", 30.0, 70.0)
77
+ ]
78
+
79
+ for field, display_name, min_val, max_val in summer_fields:
80
+ if field in design_conditions:
81
+ try:
82
+ value = float(design_conditions[field])
83
+ if min_val is not None and value < min_val:
84
+ is_valid = False
85
+ messages.append(f"{display_name} must be at least {min_val}.")
86
+ if max_val is not None and value > max_val:
87
+ is_valid = False
88
+ messages.append(f"{display_name} must be at most {max_val}.")
89
+ except (ValueError, TypeError):
90
+ is_valid = False
91
+ messages.append(f"{display_name} must be a number.")
92
+
93
+ # Check winter conditions
94
+ winter_fields = [
95
+ ("winter_outdoor_db", "Winter Outdoor Dry-Bulb", -40.0, 20.0),
96
+ ("winter_outdoor_rh", "Winter Outdoor RH", 0.0, 100.0),
97
+ ("winter_indoor_db", "Winter Indoor Dry-Bulb", 18.0, 25.0),
98
+ ("winter_indoor_rh", "Winter Indoor RH", 20.0, 60.0)
99
+ ]
100
+
101
+ for field, display_name, min_val, max_val in winter_fields:
102
+ if field in design_conditions:
103
+ try:
104
+ value = float(design_conditions[field])
105
+ if min_val is not None and value < min_val:
106
+ is_valid = False
107
+ messages.append(f"{display_name} must be at least {min_val}.")
108
+ if max_val is not None and value > max_val:
109
+ is_valid = False
110
+ messages.append(f"{display_name} must be at most {max_val}.")
111
+ except (ValueError, TypeError):
112
+ is_valid = False
113
+ messages.append(f"{display_name} must be a number.")
114
+
115
+ # Check that wet-bulb is less than dry-bulb
116
+ if "summer_outdoor_db" in design_conditions and "summer_outdoor_wb" in design_conditions:
117
+ try:
118
+ db = float(design_conditions["summer_outdoor_db"])
119
+ wb = float(design_conditions["summer_outdoor_wb"])
120
+ if wb > db:
121
+ is_valid = False
122
+ messages.append("Summer Outdoor Wet-Bulb temperature must be less than or equal to Dry-Bulb temperature.")
123
+ except (ValueError, TypeError):
124
+ pass # Already handled above
125
+
126
+ return is_valid, messages
127
+
128
+ @staticmethod
129
+ def validate_components(components: Dict[str, List[Any]]) -> Tuple[bool, List[str]]:
130
+ """
131
+ Validate building components.
132
+
133
+ Args:
134
+ components: Dictionary with building components
135
+
136
+ Returns:
137
+ Tuple containing validation result (True if valid) and list of validation messages
138
+ """
139
+ is_valid = True
140
+ messages = []
141
+
142
+ # Check if any components exist
143
+ if not any(components.values()):
144
+ is_valid = False
145
+ messages.append("At least one building component (wall, roof, floor, window, or door) is required.")
146
+
147
+ # Check wall components
148
+ for i, wall in enumerate(components.get("walls", [])):
149
+ # Check required fields
150
+ if not wall.name:
151
+ is_valid = False
152
+ messages.append(f"Wall #{i+1}: Name is required.")
153
+
154
+ # Check numeric fields
155
+ if wall.area <= 0:
156
+ is_valid = False
157
+ messages.append(f"Wall #{i+1}: Area must be greater than zero.")
158
+
159
+ if wall.u_value <= 0:
160
+ is_valid = False
161
+ messages.append(f"Wall #{i+1}: U-value must be greater than zero.")
162
+
163
+ # Check roof components
164
+ for i, roof in enumerate(components.get("roofs", [])):
165
+ # Check required fields
166
+ if not roof.name:
167
+ is_valid = False
168
+ messages.append(f"Roof #{i+1}: Name is required.")
169
+
170
+ # Check numeric fields
171
+ if roof.area <= 0:
172
+ is_valid = False
173
+ messages.append(f"Roof #{i+1}: Area must be greater than zero.")
174
+
175
+ if roof.u_value <= 0:
176
+ is_valid = False
177
+ messages.append(f"Roof #{i+1}: U-value must be greater than zero.")
178
+
179
+ # Check floor components
180
+ for i, floor in enumerate(components.get("floors", [])):
181
+ # Check required fields
182
+ if not floor.name:
183
+ is_valid = False
184
+ messages.append(f"Floor #{i+1}: Name is required.")
185
+
186
+ # Check numeric fields
187
+ if floor.area <= 0:
188
+ is_valid = False
189
+ messages.append(f"Floor #{i+1}: Area must be greater than zero.")
190
+
191
+ if floor.u_value <= 0:
192
+ is_valid = False
193
+ messages.append(f"Floor #{i+1}: U-value must be greater than zero.")
194
+
195
+ # Check window components
196
+ for i, window in enumerate(components.get("windows", [])):
197
+ # Check required fields
198
+ if not window.name:
199
+ is_valid = False
200
+ messages.append(f"Window #{i+1}: Name is required.")
201
+
202
+ # Check numeric fields
203
+ if window.area <= 0:
204
+ is_valid = False
205
+ messages.append(f"Window #{i+1}: Area must be greater than zero.")
206
+
207
+ if window.u_value <= 0:
208
+ is_valid = False
209
+ messages.append(f"Window #{i+1}: U-value must be greater than zero.")
210
+
211
+ if window.shgc <= 0 or window.shgc > 1:
212
+ is_valid = False
213
+ messages.append(f"Window #{i+1}: SHGC must be between 0 and 1.")
214
+
215
+ # Check door components
216
+ for i, door in enumerate(components.get("doors", [])):
217
+ # Check required fields
218
+ if not door.name:
219
+ is_valid = False
220
+ messages.append(f"Door #{i+1}: Name is required.")
221
+
222
+ # Check numeric fields
223
+ if door.area <= 0:
224
+ is_valid = False
225
+ messages.append(f"Door #{i+1}: Area must be greater than zero.")
226
+
227
+ if door.u_value <= 0:
228
+ is_valid = False
229
+ messages.append(f"Door #{i+1}: U-value must be greater than zero.")
230
+
231
+ # Check for minimum requirements
232
+ if not components.get("walls", []):
233
+ messages.append("Warning: No walls defined. At least one wall is recommended.")
234
+
235
+ if not components.get("roofs", []):
236
+ messages.append("Warning: No roofs defined. At least one roof is recommended.")
237
+
238
+ if not components.get("floors", []):
239
+ messages.append("Warning: No floors defined. At least one floor is recommended.")
240
+
241
+ return is_valid, messages
242
+
243
+ @staticmethod
244
+ def validate_internal_loads(internal_loads: Dict[str, Any]) -> Tuple[bool, List[str]]:
245
+ """
246
+ Validate internal loads inputs.
247
+
248
+ Args:
249
+ internal_loads: Dictionary with internal loads information
250
+
251
+ Returns:
252
+ Tuple containing validation result (True if valid) and list of validation messages
253
+ """
254
+ is_valid = True
255
+ messages = []
256
+
257
+ # Check people loads
258
+ people = internal_loads.get("people", [])
259
+ for i, person in enumerate(people):
260
+ # Check required fields
261
+ if not person.get("name"):
262
+ is_valid = False
263
+ messages.append(f"People Load #{i+1}: Name is required.")
264
+
265
+ # Check numeric fields
266
+ if person.get("quantity", 0) < 0:
267
+ is_valid = False
268
+ messages.append(f"People Load #{i+1}: Quantity must be non-negative.")
269
+
270
+ if person.get("sensible_heat", 0) < 0:
271
+ is_valid = False
272
+ messages.append(f"People Load #{i+1}: Sensible heat must be non-negative.")
273
+
274
+ if person.get("latent_heat", 0) < 0:
275
+ is_valid = False
276
+ messages.append(f"People Load #{i+1}: Latent heat must be non-negative.")
277
+
278
+ # Check lighting loads
279
+ lighting = internal_loads.get("lighting", [])
280
+ for i, light in enumerate(lighting):
281
+ # Check required fields
282
+ if not light.get("name"):
283
+ is_valid = False
284
+ messages.append(f"Lighting Load #{i+1}: Name is required.")
285
+
286
+ # Check numeric fields
287
+ if light.get("power", 0) < 0:
288
+ is_valid = False
289
+ messages.append(f"Lighting Load #{i+1}: Power must be non-negative.")
290
+
291
+ if light.get("usage_factor", 0) < 0 or light.get("usage_factor", 0) > 1:
292
+ is_valid = False
293
+ messages.append(f"Lighting Load #{i+1}: Usage factor must be between 0 and 1.")
294
+
295
+ # Check equipment loads
296
+ equipment = internal_loads.get("equipment", [])
297
+ for i, equip in enumerate(equipment):
298
+ # Check required fields
299
+ if not equip.get("name"):
300
+ is_valid = False
301
+ messages.append(f"Equipment Load #{i+1}: Name is required.")
302
+
303
+ # Check numeric fields
304
+ if equip.get("power", 0) < 0:
305
+ is_valid = False
306
+ messages.append(f"Equipment Load #{i+1}: Power must be non-negative.")
307
+
308
+ if equip.get("usage_factor", 0) < 0 or equip.get("usage_factor", 0) > 1:
309
+ is_valid = False
310
+ messages.append(f"Equipment Load #{i+1}: Usage factor must be between 0 and 1.")
311
+
312
+ if equip.get("radiation_fraction", 0) < 0 or equip.get("radiation_fraction", 0) > 1:
313
+ is_valid = False
314
+ messages.append(f"Equipment Load #{i+1}: Radiation fraction must be between 0 and 1.")
315
+
316
+ return is_valid, messages
317
+
318
+ @staticmethod
319
+ def validate_calculation_settings(settings: Dict[str, Any]) -> Tuple[bool, List[str]]:
320
+ """
321
+ Validate calculation settings.
322
+
323
+ Args:
324
+ settings: Dictionary with calculation settings
325
+
326
+ Returns:
327
+ Tuple containing validation result (True if valid) and list of validation messages
328
+ """
329
+ is_valid = True
330
+ messages = []
331
+
332
+ # Check infiltration rate
333
+ if "infiltration_rate" in settings:
334
+ try:
335
+ infiltration_rate = float(settings["infiltration_rate"])
336
+ if infiltration_rate < 0:
337
+ is_valid = False
338
+ messages.append("Infiltration rate must be non-negative.")
339
+ except (ValueError, TypeError):
340
+ is_valid = False
341
+ messages.append("Infiltration rate must be a number.")
342
+
343
+ # Check ventilation rate
344
+ if "ventilation_rate" in settings:
345
+ try:
346
+ ventilation_rate = float(settings["ventilation_rate"])
347
+ if ventilation_rate < 0:
348
+ is_valid = False
349
+ messages.append("Ventilation rate must be non-negative.")
350
+ except (ValueError, TypeError):
351
+ is_valid = False
352
+ messages.append("Ventilation rate must be a number.")
353
+
354
+ # Check safety factors
355
+ safety_factors = ["cooling_safety_factor", "heating_safety_factor"]
356
+ for factor in safety_factors:
357
+ if factor in settings:
358
+ try:
359
+ value = float(settings[factor])
360
+ if value < 0:
361
+ is_valid = False
362
+ messages.append(f"{factor.replace('_', ' ').title()} must be non-negative.")
363
+ except (ValueError, TypeError):
364
+ is_valid = False
365
+ messages.append(f"{factor.replace('_', ' ').title()} must be a number.")
366
+
367
+ return is_valid, messages
368
+
369
+ @staticmethod
370
+ def display_validation_messages(messages: List[str], container=None) -> None:
371
+ """
372
+ Display validation messages in Streamlit.
373
+
374
+ Args:
375
+ messages: List of validation messages
376
+ container: Optional Streamlit container to display messages in
377
+ """
378
+ if not messages:
379
+ return
380
+
381
+ # Separate errors and warnings
382
+ errors = [msg for msg in messages if not msg.startswith("Warning:")]
383
+ warnings = [msg for msg in messages if msg.startswith("Warning:")]
384
+
385
+ # Use provided container or st directly
386
+ display = container if container is not None else st
387
+
388
+ # Display errors
389
+ if errors:
390
+ error_msg = "Please fix the following errors:\n" + "\n".join([f"- {msg}" for msg in errors])
391
+ display.error(error_msg)
392
+
393
+ # Display warnings
394
+ if warnings:
395
+ warning_msg = "Warnings:\n" + "\n".join([f"- {msg[8:]}" for msg in warnings])
396
+ display.warning(warning_msg)
397
+
398
+ @staticmethod
399
+ def validate_and_proceed(
400
+ session_state: Dict[str, Any],
401
+ validation_function: Callable[[Dict[str, Any]], Tuple[bool, List[str]]],
402
+ data_key: str,
403
+ success_message: str = "Validation successful!",
404
+ proceed_callback: Optional[Callable] = None
405
+ ) -> bool:
406
+ """
407
+ Validate data and proceed if valid.
408
+
409
+ Args:
410
+ session_state: Streamlit session state
411
+ validation_function: Function to validate data
412
+ data_key: Key for data in session state
413
+ success_message: Message to display on success
414
+ proceed_callback: Optional callback function to execute if validation succeeds
415
+
416
+ Returns:
417
+ Boolean indicating whether validation succeeded
418
+ """
419
+ if data_key not in session_state:
420
+ st.error(f"No {data_key.replace('_', ' ')} data found.")
421
+ return False
422
+
423
+ # Validate data
424
+ is_valid, messages = validation_function(session_state[data_key])
425
+
426
+ # Display validation messages
427
+ DataValidation.display_validation_messages(messages)
428
+
429
+ # Proceed if valid
430
+ if is_valid:
431
+ st.success(success_message)
432
+
433
+ # Execute callback if provided
434
+ if proceed_callback is not None:
435
+ proceed_callback()
436
+
437
+ return True
438
+
439
+ return False
440
+
441
+
442
+ # Create a singleton instance
443
+ data_validation = DataValidation()
444
+
445
+ # Example usage
446
+ if __name__ == "__main__":
447
+ import streamlit as st
448
+
449
+ # Initialize session state with dummy data for testing
450
+ if "building_info" not in st.session_state:
451
+ st.session_state["building_info"] = {
452
+ "project_name": "Test Project",
453
+ "building_name": "Test Building",
454
+ "location": "New York",
455
+ "climate_zone": "4A",
456
+ "building_type": "Office",
457
+ "floor_area": 1000.0,
458
+ "num_floors": 2,
459
+ "floor_height": 3.0,
460
+ "orientation": "NORTH",
461
+ "occupancy": 50,
462
+ "operating_hours": "8:00-18:00",
463
+ "design_conditions": {
464
+ "summer_outdoor_db": 35.0,
465
+ "summer_outdoor_wb": 25.0,
466
+ "summer_indoor_db": 24.0,
467
+ "summer_indoor_rh": 50.0,
468
+ "winter_outdoor_db": -5.0,
469
+ "winter_outdoor_rh": 80.0,
470
+ "winter_indoor_db": 21.0,
471
+ "winter_indoor_rh": 40.0
472
+ }
473
+ }
474
+
475
+ # Test validation
476
+ st.header("Test Building Information Validation")
477
+
478
+ # Add some invalid data for testing
479
+ if st.button("Make Data Invalid"):
480
+ st.session_state["building_info"]["floor_area"] = -100.0
481
+ st.session_state["building_info"]["design_conditions"]["summer_outdoor_wb"] = 40.0
482
+
483
+ # Validate building info
484
+ if st.button("Validate Building Info"):
485
+ data_validation.validate_and_proceed(
486
+ st.session_state,
487
+ data_validation.validate_building_info,
488
+ "building_info",
489
+ "Building information is valid!"
490
+ )
app/main.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Calculator Code Documentation
3
+
4
+ This module contains the main Streamlit application for the HVAC Calculator.
5
+ It provides a comprehensive interface for calculating heating and cooling loads
6
+ using ASHRAE methods.
7
+
8
+ Author: Dr Majed Abuseif
9
+ Date: March 2025
10
+ Version: 1.0.0
11
+ """
12
+
13
+ import streamlit as st
14
+ import pandas as pd
15
+ import numpy as np
16
+ import plotly.graph_objects as go
17
+ import plotly.express as px
18
+ import matplotlib.pyplot as plt
19
+ import json
20
+ import os
21
+ import sys
22
+ from typing import Dict, List, Any, Optional, Tuple
23
+
24
+ # Import application modules
25
+ from app.building_info_form import BuildingInfoForm
26
+ from app.component_selection import ComponentSelection
27
+ from app.results_display import ResultsDisplay
28
+ from app.data_validation import DataValidation
29
+ from app.data_persistence import DataPersistence
30
+ from app.data_export import DataExport
31
+
32
+ # Import data modules
33
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
34
+ from data.reference_data import ReferenceData
35
+ from data.climate_data import ClimateData
36
+ from data.ashrae_tables import ASHRAETables
37
+
38
+ # Import utility modules
39
+ from utils.component_library import ComponentLibrary
40
+ from utils.u_value_calculator import UValueCalculator
41
+ from utils.shading_system import ShadingSystem
42
+ from utils.area_calculation_system import AreaCalculationSystem
43
+ from utils.psychrometrics import Psychrometrics
44
+ from utils.heat_transfer import HeatTransfer
45
+ from utils.cooling_load import CoolingLoad
46
+ from utils.heating_load import HeatingLoad
47
+ from utils.component_visualization import ComponentVisualization
48
+ from utils.scenario_comparison import ScenarioComparison
49
+ from utils.psychrometric_visualization import PsychrometricVisualization
50
+ from utils.time_based_visualization import TimeBasedVisualization
51
+
52
+
53
+ class HVACCalculator:
54
+ """
55
+ Main HVAC Calculator application class.
56
+
57
+ This class initializes the Streamlit application and manages the navigation
58
+ between different sections of the calculator.
59
+
60
+ Attributes:
61
+ building_info_form (BuildingInfoForm): Building information input form
62
+ component_selection (ComponentSelection): Component selection interface
63
+ results_display (ResultsDisplay): Results display module
64
+ data_validation (DataValidation): Data validation module
65
+ data_persistence (DataPersistence): Data persistence module
66
+ data_export (DataExport): Data export module
67
+ """
68
+
69
+ def __init__(self):
70
+ """Initialize the HVAC Calculator application."""
71
+ # Set page configuration
72
+ st.set_page_config(
73
+ page_title="HVAC Load Calculator",
74
+ page_icon="🌡️",
75
+ layout="wide",
76
+ initial_sidebar_state="expanded"
77
+ )
78
+
79
+ # Initialize session state if not exists
80
+ if 'page' not in st.session_state:
81
+ st.session_state.page = 'Building Information'
82
+
83
+ if 'building_info' not in st.session_state:
84
+ st.session_state.building_info = {}
85
+
86
+ if 'components' not in st.session_state:
87
+ st.session_state.components = {
88
+ 'walls': [],
89
+ 'roofs': [],
90
+ 'floors': [],
91
+ 'windows': [],
92
+ 'doors': []
93
+ }
94
+
95
+ if 'internal_loads' not in st.session_state:
96
+ st.session_state.internal_loads = {
97
+ 'people': [],
98
+ 'lighting': [],
99
+ 'equipment': []
100
+ }
101
+
102
+ if 'calculation_results' not in st.session_state:
103
+ st.session_state.calculation_results = {
104
+ 'cooling': {},
105
+ 'heating': {}
106
+ }
107
+
108
+ if 'scenarios' not in st.session_state:
109
+ st.session_state.scenarios = []
110
+
111
+ # Initialize application modules
112
+ self.building_info_form = BuildingInfoForm()
113
+ self.component_selection = ComponentSelection()
114
+ self.results_display = ResultsDisplay()
115
+ self.data_validation = DataValidation()
116
+ self.data_persistence = DataPersistence()
117
+ self.data_export = DataExport()
118
+
119
+ # Set up the application layout
120
+ self.setup_layout()
121
+
122
+ def setup_layout(self):
123
+ """Set up the application layout with sidebar navigation."""
124
+ # Application title
125
+ st.sidebar.title("HVAC Load Calculator")
126
+ st.sidebar.markdown("---")
127
+
128
+ # Navigation
129
+ st.sidebar.subheader("Navigation")
130
+ pages = [
131
+ "Building Information",
132
+ "Climate Data",
133
+ "Building Components",
134
+ "Internal Loads",
135
+ "Calculation Results",
136
+ "Export Data"
137
+ ]
138
+
139
+ selected_page = st.sidebar.radio("Go to", pages, index=pages.index(st.session_state.page))
140
+
141
+ # Update session state if page changed
142
+ if selected_page != st.session_state.page:
143
+ st.session_state.page = selected_page
144
+
145
+ # Display the selected page
146
+ self.display_page(st.session_state.page)
147
+
148
+ # Footer
149
+ st.sidebar.markdown("---")
150
+ st.sidebar.info(
151
+ "HVAC Load Calculator v1.0.0\n\n"
152
+ "Based on ASHRAE calculation methods\n\n"
153
+ "© 2025"
154
+ )
155
+
156
+ def display_page(self, page: str):
157
+ """
158
+ Display the selected page.
159
+
160
+ Args:
161
+ page (str): The page to display
162
+ """
163
+ if page == "Building Information":
164
+ self.building_info_form.display()
165
+ elif page == "Climate Data":
166
+ self.display_climate_data()
167
+ elif page == "Building Components":
168
+ self.component_selection.display()
169
+ elif page == "Internal Loads":
170
+ self.display_internal_loads()
171
+ elif page == "Calculation Results":
172
+ self.results_display.display()
173
+ elif page == "Export Data":
174
+ self.data_export.display()
175
+
176
+ def display_climate_data(self):
177
+ """Display the climate data page."""
178
+ st.title("Climate Data")
179
+
180
+ # Check if building information is available
181
+ if not st.session_state.building_info:
182
+ st.warning("Please enter building information first.")
183
+ st.button("Go to Building Information", on_click=self.navigate_to, args=["Building Information"])
184
+ return
185
+
186
+ # Get climate zone from building information
187
+ climate_zone = st.session_state.building_info.get('climate_zone')
188
+ if not climate_zone:
189
+ st.error("Climate zone not found in building information.")
190
+ st.button("Go to Building Information", on_click=self.navigate_to, args=["Building Information"])
191
+ return
192
+
193
+ # Display climate zone information
194
+ st.subheader(f"Climate Zone: {climate_zone}")
195
+
196
+ # Get design conditions
197
+ design_conditions = ClimateData.get_design_conditions(climate_zone)
198
+
199
+ # Display design conditions
200
+ st.subheader("Design Conditions")
201
+ col1, col2 = st.columns(2)
202
+
203
+ with col1:
204
+ st.write("Summer Design Conditions")
205
+ summer_data = pd.DataFrame({
206
+ "Parameter": ["Dry-Bulb Temperature", "Wet-Bulb Temperature", "Dew Point"],
207
+ "Value": [
208
+ f"{design_conditions['summer']['db']} °C",
209
+ f"{design_conditions['summer']['wb']} °C",
210
+ f"{design_conditions['summer']['dp']} °C"
211
+ ]
212
+ })
213
+ st.table(summer_data)
214
+
215
+ with col2:
216
+ st.write("Winter Design Conditions")
217
+ winter_data = pd.DataFrame({
218
+ "Parameter": ["Dry-Bulb Temperature", "Relative Humidity"],
219
+ "Value": [
220
+ f"{design_conditions['winter']['db']} °C",
221
+ f"{design_conditions['winter']['rh']} %"
222
+ ]
223
+ })
224
+ st.table(winter_data)
225
+
226
+ # Get monthly temperature data
227
+ monthly_temps = ClimateData.get_monthly_temperatures(climate_zone)
228
+
229
+ # Prepare data for plotting
230
+ months = list(range(1, 13))
231
+ avg_temps = [monthly_temps[m]['avg_db'] for m in months]
232
+ max_temps = [monthly_temps[m]['max_db'] for m in months]
233
+ min_temps = [monthly_temps[m]['min_db'] for m in months]
234
+
235
+ # Plot monthly temperature data
236
+ st.subheader("Monthly Temperature Data")
237
+ fig = go.Figure()
238
+
239
+ fig.add_trace(go.Scatter(
240
+ x=months,
241
+ y=max_temps,
242
+ mode='lines+markers',
243
+ name='Maximum Temperature',
244
+ line=dict(color='red')
245
+ ))
246
+
247
+ fig.add_trace(go.Scatter(
248
+ x=months,
249
+ y=avg_temps,
250
+ mode='lines+markers',
251
+ name='Average Temperature',
252
+ line=dict(color='green')
253
+ ))
254
+
255
+ fig.add_trace(go.Scatter(
256
+ x=months,
257
+ y=min_temps,
258
+ mode='lines+markers',
259
+ name='Minimum Temperature',
260
+ line=dict(color='blue')
261
+ ))
262
+
263
+ fig.update_layout(
264
+ title='Monthly Temperature Data',
265
+ xaxis_title='Month',
266
+ yaxis_title='Temperature (°C)',
267
+ xaxis=dict(
268
+ tickmode='array',
269
+ tickvals=months,
270
+ ticktext=['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
271
+ )
272
+ )
273
+
274
+ st.plotly_chart(fig, use_container_width=True)
275
+
276
+ # Navigation buttons
277
+ col1, col2 = st.columns(2)
278
+ with col1:
279
+ st.button("Back to Building Information", on_click=self.navigate_to, args=["Building Information"])
280
+ with col2:
281
+ st.button("Continue to Building Components", on_click=self.navigate_to, args=["Building Components"])
282
+
283
+ def display_internal_loads(self):
284
+ """Display the internal loads page."""
285
+ st.title("Internal Loads")
286
+
287
+ # Check if building components are available
288
+ if not any(st.session_state.components.values()):
289
+ st.warning("Please define building components first.")
290
+ st.button("Go to Building Components", on_click=self.navigate_to, args=["Building Components"])
291
+ return
292
+
293
+ # Tabs for different internal load types
294
+ tabs = st.tabs(["People", "Lighting", "Equipment"])
295
+
296
+ # People tab
297
+ with tabs[0]:
298
+ self.display_people_loads()
299
+
300
+ # Lighting tab
301
+ with tabs[1]:
302
+ self.display_lighting_loads()
303
+
304
+ # Equipment tab
305
+ with tabs[2]:
306
+ self.display_equipment_loads()
307
+
308
+ # Display summary of internal loads
309
+ self.display_internal_loads_summary()
310
+
311
+ # Navigation buttons
312
+ col1, col2 = st.columns(2)
313
+ with col1:
314
+ st.button("Back to Building Components", on_click=self.navigate_to, args=["Building Components"])
315
+ with col2:
316
+ # Validate internal loads before proceeding
317
+ if self.data_validation.validate_internal_loads():
318
+ st.button("Continue to Calculation Results", on_click=self.navigate_to, args=["Calculation Results"])
319
+ else:
320
+ st.button("Continue to Calculation Results", disabled=True)
321
+
322
+ def display_people_loads(self):
323
+ """Display the people loads section."""
324
+ st.subheader("People")
325
+
326
+ # Form for adding people loads
327
+ with st.form("people_load_form"):
328
+ col1, col2 = st.columns(2)
329
+
330
+ with col1:
331
+ name = st.text_input("Name", "Occupants")
332
+ num_people = st.number_input("Number of People", min_value=1, value=10)
333
+
334
+ with col2:
335
+ activity_level = st.selectbox(
336
+ "Activity Level",
337
+ options=["Seated, resting", "Seated, light work", "Office work", "Standing, light work", "Walking", "Heavy work"]
338
+ )
339
+ zone_type = st.selectbox(
340
+ "Zone Type",
341
+ options=["Office", "Residential", "Retail", "Educational", "Healthcare"]
342
+ )
343
+
344
+ hours_in_operation = st.slider("Hours in Operation", min_value=1, max_value=24, value=8)
345
+
346
+ submitted = st.form_submit_button("Add People Load")
347
+
348
+ if submitted:
349
+ # Create people load
350
+ people_load = {
351
+ "id": f"people_{len(st.session_state.internal_loads['people'])}",
352
+ "name": name,
353
+ "num_people": num_people,
354
+ "activity_level": activity_level,
355
+ "zone_type": zone_type,
356
+ "hours_in_operation": hours_in_operation
357
+ }
358
+
359
+ # Add to session state
360
+ st.session_state.internal_loads['people'].append(people_load)
361
+ st.success(f"Added {name} with {num_people} people")
362
+
363
+ # Display existing people loads
364
+ if st.session_state.internal_loads['people']:
365
+ st.subheader("Existing People Loads")
366
+
367
+ people_data = []
368
+ for load in st.session_state.internal_loads['people']:
369
+ people_data.append({
370
+ "Name": load['name'],
371
+ "Number of People": load['num_people'],
372
+ "Activity Level": load['activity_level'],
373
+ "Zone Type": load['zone_type'],
374
+ "Hours in Operation": load['hours_in_operation'],
375
+ "Actions": load['id']
376
+ })
377
+
378
+ df = pd.DataFrame(people_data)
379
+
380
+ # Display table with edit and delete buttons
381
+ for i, row in df.iterrows():
382
+ col1, col2, col3, col4, col5, col6, col7 = st.columns([2, 1, 2, 2, 1, 1, 1])
383
+
384
+ with col1:
385
+ st.write(row["Name"])
386
+ with col2:
387
+ st.write(row["Number of People"])
388
+ with col3:
389
+ st.write(row["Activity Level"])
390
+ with col4:
391
+ st.write(row["Zone Type"])
392
+ with col5:
393
+ st.write(row["Hours in Operation"])
394
+ with col6:
395
+ if st.button("Edit", key=f"edit_{row['Actions']}"):
396
+ # Set session state for editing
397
+ st.session_state.editing_people = row['Actions']
398
+ with col7:
399
+ if st.button("Delete", key=f"delete_{row['Actions']}"):
400
+ # Remove from session state
401
+ st.session_state.internal_loads['people'] = [
402
+ load for load in st.session_state.internal_loads['people']
403
+ if load['id'] != row['Actions']
404
+ ]
405
+ st.experimental_rerun()
406
+
407
+ def display_lighting_loads(self):
408
+ """Display the lighting loads section."""
409
+ st.subheader("Lighting")
410
+
411
+ # Form for adding lighting loads
412
+ with st.form("lighting_load_form"):
413
+ col1, col2 = st.columns(2)
414
+
415
+ with col1:
416
+ name = st.text_input("Name", "General Lighting")
417
+ power = st.number_input("Power (W)", min_value=0.0, value=1000.0)
418
+
419
+ with col2:
420
+ usage_factor = st.slider("Usage Factor", min_value=0.0, max_value=1.0, value=0.8, step=0.1)
421
+ zone_type = st.selectbox(
422
+ "Zone Type",
423
+ options=["Office", "Residential", "Retail", "Educational", "Healthcare"]
424
+ )
425
+
426
+ hours_in_operation = st.slider("Hours in Operation", min_value=1, max_value=24, value=8)
427
+
428
+ submitted = st.form_submit_button("Add Lighting Load")
429
+
430
+ if submitted:
431
+ # Create lighting load
432
+ lighting_load = {
433
+ "id": f"lighting_{len(st.session_state.internal_loads['lighting'])}",
434
+ "name": name,
435
+ "power": power,
436
+ "usage_factor": usage_factor,
437
+ "zone_type": zone_type,
438
+ "hours_in_operation": hours_in_operation
439
+ }
440
+
441
+ # Add to session state
442
+ st.session_state.internal_loads['lighting'].append(lighting_load)
443
+ st.success(f"Added {name} with {power} W")
444
+
445
+ # Display existing lighting loads
446
+ if st.session_state.internal_loads['lighting']:
447
+ st.subheader("Existing Lighting Loads")
448
+
449
+ lighting_data = []
450
+ for load in st.session_state.internal_loads['lighting']:
451
+ lighting_data.append({
452
+ "Name": load['name'],
453
+ "Power (W)": load['power'],
454
+ "Usage Factor": load['usage_factor'],
455
+ "Zone Type": load['zone_type'],
456
+ "Hours in Operation": load['hours_in_operation'],
457
+ "Actions": load['id']
458
+ })
459
+
460
+ df = pd.DataFrame(lighting_data)
461
+
462
+ # Display table with edit and delete buttons
463
+ for i, row in df.iterrows():
464
+ col1, col2, col3, col4, col5, col6, col7 = st.columns([2, 1, 1, 2, 1, 1, 1])
465
+
466
+ with col1:
467
+ st.write(row["Name"])
468
+ with col2:
469
+ st.write(f"{row['Power (W)']:.1f}")
470
+ with col3:
471
+ st.write(f"{row['Usage Factor']:.1f}")
472
+ with col4:
473
+ st.write(row["Zone Type"])
474
+ with col5:
475
+ st.write(row["Hours in Operation"])
476
+ with col6:
477
+ if st.button("Edit", key=f"edit_{row['Actions']}"):
478
+ # Set session state for editing
479
+ st.session_state.editing_lighting = row['Actions']
480
+ with col7:
481
+ if st.button("Delete", key=f"delete_{row['Actions']}"):
482
+ # Remove from session state
483
+ st.session_state.internal_loads['lighting'] = [
484
+ load for load in st.session_state.internal_loads['lighting']
485
+ if load['id'] != row['Actions']
486
+ ]
487
+ st.experimental_rerun()
488
+
489
+ def display_equipment_loads(self):
490
+ """Display the equipment loads section."""
491
+ st.subheader("Equipment")
492
+
493
+ # Form for adding equipment loads
494
+ with st.form("equipment_load_form"):
495
+ col1, col2 = st.columns(2)
496
+
497
+ with col1:
498
+ name = st.text_input("Name", "Office Equipment")
499
+ power = st.number_input("Power (W)", min_value=0.0, value=500.0)
500
+
501
+ with col2:
502
+ usage_factor = st.slider("Usage Factor", min_value=0.0, max_value=1.0, value=0.7, step=0.1)
503
+ radiation_fraction = st.slider("Radiation Fraction", min_value=0.0, max_value=1.0, value=0.3, step=0.1)
504
+
505
+ zone_type = st.selectbox(
506
+ "Zone Type",
507
+ options=["Office", "Residential", "Retail", "Educational", "Healthcare"]
508
+ )
509
+
510
+ hours_in_operation = st.slider("Hours in Operation", min_value=1, max_value=24, value=8)
511
+
512
+ submitted = st.form_submit_button("Add Equipment Load")
513
+
514
+ if submitted:
515
+ # Create equipment load
516
+ equipment_load = {
517
+ "id": f"equipment_{len(st.session_state.internal_loads['equipment'])}",
518
+ "name": name,
519
+ "power": power,
520
+ "usage_factor": usage_factor,
521
+ "radiation_fraction": radiation_fraction,
522
+ "zone_type": zone_type,
523
+ "hours_in_operation": hours_in_operation
524
+ }
525
+
526
+ # Add to session state
527
+ st.session_state.internal_loads['equipment'].append(equipment_load)
528
+ st.success(f"Added {name} with {power} W")
529
+
530
+ # Display existing equipment loads
531
+ if st.session_state.internal_loads['equipment']:
532
+ st.subheader("Existing Equipment Loads")
533
+
534
+ equipment_data = []
535
+ for load in st.session_state.internal_loads['equipment']:
536
+ equipment_data.append({
537
+ "Name": load['name'],
538
+ "Power (W)": load['power'],
539
+ "Usage Factor": load['usage_factor'],
540
+ "Radiation Fraction": load['radiation_fraction'],
541
+ "Zone Type": load['zone_type'],
542
+ "Hours in Operation": load['hours_in_operation'],
543
+ "Actions": load['id']
544
+ })
545
+
546
+ df = pd.DataFrame(equipment_data)
547
+
548
+ # Display table with edit and delete buttons
549
+ for i, row in df.iterrows():
550
+ col1, col2, col3, col4, col5, col6, col7, col8 = st.columns([2, 1, 1, 1, 2, 1, 1, 1])
551
+
552
+ with col1:
553
+ st.write(row["Name"])
554
+ with col2:
555
+ st.write(f"{row['Power (W)']:.1f}")
556
+ with col3:
557
+ st.write(f"{row['Usage Factor']:.1f}")
558
+ with col4:
559
+ st.write(f"{row['Radiation Fraction']:.1f}")
560
+ with col5:
561
+ st.write(row["Zone Type"])
562
+ with col6:
563
+ st.write(row["Hours in Operation"])
564
+ with col7:
565
+ if st.button("Edit", key=f"edit_{row['Actions']}"):
566
+ # Set session state for editing
567
+ st.session_state.editing_equipment = row['Actions']
568
+ with col8:
569
+ if st.button("Delete", key=f"delete_{row['Actions']}"):
570
+ # Remove from session state
571
+ st.session_state.internal_loads['equipment'] = [
572
+ load for load in st.session_state.internal_loads['equipment']
573
+ if load['id'] != row['Actions']
574
+ ]
575
+ st.experimental_rerun()
576
+
577
+ def display_internal_loads_summary(self):
578
+ """Display a summary of all internal loads."""
579
+ st.subheader("Internal Loads Summary")
580
+
581
+ # Check if any internal loads exist
582
+ if not any(st.session_state.internal_loads.values()):
583
+ st.info("No internal loads defined yet.")
584
+ return
585
+
586
+ # Calculate total loads
587
+ total_people = sum(load['num_people'] for load in st.session_state.internal_loads['people'])
588
+ total_lighting_power = sum(load['power'] * load['usage_factor'] for load in st.session_state.internal_loads['lighting'])
589
+ total_equipment_power = sum(load['power'] * load['usage_factor'] for load in st.session_state.internal_loads['equipment'])
590
+
591
+ # Display summary
592
+ col1, col2, col3 = st.columns(3)
593
+
594
+ with col1:
595
+ st.metric("Total People", f"{total_people}")
596
+
597
+ with col2:
598
+ st.metric("Total Lighting Power", f"{total_lighting_power:.1f} W")
599
+
600
+ with col3:
601
+ st.metric("Total Equipment Power", f"{total_equipment_power:.1f} W")
602
+
603
+ # Display pie chart of internal loads
604
+ if total_lighting_power > 0 or total_equipment_power > 0:
605
+ # Estimate people load (assuming 100W per person)
606
+ people_power = total_people * 100
607
+
608
+ # Create pie chart
609
+ fig = px.pie(
610
+ values=[people_power, total_lighting_power, total_equipment_power],
611
+ names=['People', 'Lighting', 'Equipment'],
612
+ title='Internal Loads Distribution'
613
+ )
614
+
615
+ st.plotly_chart(fig, use_container_width=True)
616
+
617
+ def navigate_to(self, page: str):
618
+ """
619
+ Navigate to the specified page.
620
+
621
+ Args:
622
+ page (str): The page to navigate to
623
+ """
624
+ st.session_state.page = page
625
+ st.experimental_rerun()
626
+
627
+
628
+ if __name__ == "__main__":
629
+ # Create and run the HVAC Calculator application
630
+ app = HVACCalculator()
app/results_display.py ADDED
@@ -0,0 +1,592 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Results display module for HVAC Load Calculator.
3
+ This module provides the UI components for displaying calculation results.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ import json
11
+ import os
12
+ import plotly.graph_objects as go
13
+ import plotly.express as px
14
+ from datetime import datetime
15
+
16
+ # Import visualization modules
17
+ from utils.component_visualization import ComponentVisualization
18
+ from utils.scenario_comparison import ScenarioComparison
19
+ from utils.psychrometric_visualization import PsychrometricVisualization
20
+ from utils.time_based_visualization import TimeBasedVisualization
21
+
22
+
23
+ class ResultsDisplay:
24
+ """Class for results display interface."""
25
+
26
+ def __init__(self):
27
+ """Initialize results display interface."""
28
+ self.component_visualization = ComponentVisualization()
29
+ self.scenario_comparison = ScenarioComparison()
30
+ self.psychrometric_visualization = PsychrometricVisualization()
31
+ self.time_based_visualization = TimeBasedVisualization()
32
+
33
+ def display_results(self, session_state: Dict[str, Any]) -> None:
34
+ """
35
+ Display calculation results in Streamlit.
36
+
37
+ Args:
38
+ session_state: Streamlit session state containing calculation results
39
+ """
40
+ st.header("Calculation Results")
41
+
42
+ # Check if calculations have been performed
43
+ if "calculation_results" not in session_state or not session_state["calculation_results"]:
44
+ st.warning("No calculation results available. Please run calculations first.")
45
+ return
46
+
47
+ # Create tabs for different result views
48
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
49
+ "Summary",
50
+ "Component Breakdown",
51
+ "Psychrometric Analysis",
52
+ "Time Analysis",
53
+ "Scenario Comparison"
54
+ ])
55
+
56
+ with tab1:
57
+ self._display_summary_results(session_state)
58
+
59
+ with tab2:
60
+ self._display_component_breakdown(session_state)
61
+
62
+ with tab3:
63
+ self._display_psychrometric_analysis(session_state)
64
+
65
+ with tab4:
66
+ self._display_time_analysis(session_state)
67
+
68
+ with tab5:
69
+ self._display_scenario_comparison(session_state)
70
+
71
+ def _display_summary_results(self, session_state: Dict[str, Any]) -> None:
72
+ """
73
+ Display summary of calculation results.
74
+
75
+ Args:
76
+ session_state: Streamlit session state containing calculation results
77
+ """
78
+ st.subheader("Summary Results")
79
+
80
+ results = session_state["calculation_results"]
81
+
82
+ # Display project information
83
+ if "building_info" in session_state:
84
+ st.write(f"**Project:** {session_state['building_info']['project_name']}")
85
+ st.write(f"**Building:** {session_state['building_info']['building_name']}")
86
+ st.write(f"**Location:** {session_state['building_info']['location']}")
87
+ st.write(f"**Climate Zone:** {session_state['building_info']['climate_zone']}")
88
+ st.write(f"**Floor Area:** {session_state['building_info']['floor_area']} m²")
89
+
90
+ # Create columns for cooling and heating loads
91
+ col1, col2 = st.columns(2)
92
+
93
+ with col1:
94
+ st.write("### Cooling Load Results")
95
+
96
+ # Display cooling load metrics
97
+ cooling_metrics = [
98
+ {"name": "Total Cooling Load", "value": results["cooling"]["total_load"], "unit": "kW"},
99
+ {"name": "Sensible Cooling Load", "value": results["cooling"]["sensible_load"], "unit": "kW"},
100
+ {"name": "Latent Cooling Load", "value": results["cooling"]["latent_load"], "unit": "kW"},
101
+ {"name": "Cooling Load per Area", "value": results["cooling"]["load_per_area"], "unit": "W/m²"}
102
+ ]
103
+
104
+ for metric in cooling_metrics:
105
+ st.metric(
106
+ label=metric["name"],
107
+ value=f"{metric['value']:.2f} {metric['unit']}"
108
+ )
109
+
110
+ # Display cooling load pie chart
111
+ cooling_breakdown = {
112
+ "Walls": results["cooling"]["component_loads"]["walls"],
113
+ "Roof": results["cooling"]["component_loads"]["roof"],
114
+ "Windows": results["cooling"]["component_loads"]["windows"],
115
+ "Doors": results["cooling"]["component_loads"]["doors"],
116
+ "People": results["cooling"]["component_loads"]["people"],
117
+ "Lighting": results["cooling"]["component_loads"]["lighting"],
118
+ "Equipment": results["cooling"]["component_loads"]["equipment"],
119
+ "Infiltration": results["cooling"]["component_loads"]["infiltration"],
120
+ "Ventilation": results["cooling"]["component_loads"]["ventilation"]
121
+ }
122
+
123
+ fig = px.pie(
124
+ values=list(cooling_breakdown.values()),
125
+ names=list(cooling_breakdown.keys()),
126
+ title="Cooling Load Breakdown",
127
+ color_discrete_sequence=px.colors.qualitative.Pastel
128
+ )
129
+
130
+ fig.update_traces(textposition='inside', textinfo='percent+label')
131
+ fig.update_layout(uniformtext_minsize=12, uniformtext_mode='hide')
132
+
133
+ st.plotly_chart(fig, use_container_width=True)
134
+
135
+ with col2:
136
+ st.write("### Heating Load Results")
137
+
138
+ # Display heating load metrics
139
+ heating_metrics = [
140
+ {"name": "Total Heating Load", "value": results["heating"]["total_load"], "unit": "kW"},
141
+ {"name": "Heating Load per Area", "value": results["heating"]["load_per_area"], "unit": "W/m²"},
142
+ {"name": "Design Heat Loss", "value": results["heating"]["design_heat_loss"], "unit": "kW"},
143
+ {"name": "Safety Factor", "value": results["heating"]["safety_factor"], "unit": "%"}
144
+ ]
145
+
146
+ for metric in heating_metrics:
147
+ st.metric(
148
+ label=metric["name"],
149
+ value=f"{metric['value']:.2f} {metric['unit']}"
150
+ )
151
+
152
+ # Display heating load pie chart
153
+ heating_breakdown = {
154
+ "Walls": results["heating"]["component_loads"]["walls"],
155
+ "Roof": results["heating"]["component_loads"]["roof"],
156
+ "Floor": results["heating"]["component_loads"]["floor"],
157
+ "Windows": results["heating"]["component_loads"]["windows"],
158
+ "Doors": results["heating"]["component_loads"]["doors"],
159
+ "Infiltration": results["heating"]["component_loads"]["infiltration"],
160
+ "Ventilation": results["heating"]["component_loads"]["ventilation"]
161
+ }
162
+
163
+ fig = px.pie(
164
+ values=list(heating_breakdown.values()),
165
+ names=list(heating_breakdown.keys()),
166
+ title="Heating Load Breakdown",
167
+ color_discrete_sequence=px.colors.qualitative.Pastel
168
+ )
169
+
170
+ fig.update_traces(textposition='inside', textinfo='percent+label')
171
+ fig.update_layout(uniformtext_minsize=12, uniformtext_mode='hide')
172
+
173
+ st.plotly_chart(fig, use_container_width=True)
174
+
175
+ # Display tabular results
176
+ st.subheader("Detailed Results")
177
+
178
+ # Create tabs for cooling and heating tables
179
+ tab1, tab2 = st.tabs(["Cooling Load Details", "Heating Load Details"])
180
+
181
+ with tab1:
182
+ # Create cooling load details table
183
+ cooling_details = []
184
+
185
+ # Add envelope components
186
+ for wall in results["cooling"]["detailed_loads"]["walls"]:
187
+ cooling_details.append({
188
+ "Component Type": "Wall",
189
+ "Name": wall["name"],
190
+ "Orientation": wall["orientation"],
191
+ "Area (m²)": wall["area"],
192
+ "U-Value (W/m²·K)": wall["u_value"],
193
+ "CLTD (°C)": wall["cltd"],
194
+ "Load (kW)": wall["load"]
195
+ })
196
+
197
+ for roof in results["cooling"]["detailed_loads"]["roofs"]:
198
+ cooling_details.append({
199
+ "Component Type": "Roof",
200
+ "Name": roof["name"],
201
+ "Orientation": roof["orientation"],
202
+ "Area (m²)": roof["area"],
203
+ "U-Value (W/m²·K)": roof["u_value"],
204
+ "CLTD (°C)": roof["cltd"],
205
+ "Load (kW)": roof["load"]
206
+ })
207
+
208
+ for window in results["cooling"]["detailed_loads"]["windows"]:
209
+ cooling_details.append({
210
+ "Component Type": "Window",
211
+ "Name": window["name"],
212
+ "Orientation": window["orientation"],
213
+ "Area (m²)": window["area"],
214
+ "U-Value (W/m²·K)": window["u_value"],
215
+ "SHGC": window["shgc"],
216
+ "SCL (W/m²)": window["scl"],
217
+ "Load (kW)": window["load"]
218
+ })
219
+
220
+ for door in results["cooling"]["detailed_loads"]["doors"]:
221
+ cooling_details.append({
222
+ "Component Type": "Door",
223
+ "Name": door["name"],
224
+ "Orientation": door["orientation"],
225
+ "Area (m²)": door["area"],
226
+ "U-Value (W/m²·K)": door["u_value"],
227
+ "CLTD (°C)": door["cltd"],
228
+ "Load (kW)": door["load"]
229
+ })
230
+
231
+ # Add internal loads
232
+ for internal_load in results["cooling"]["detailed_loads"]["internal"]:
233
+ cooling_details.append({
234
+ "Component Type": internal_load["type"],
235
+ "Name": internal_load["name"],
236
+ "Quantity": internal_load["quantity"],
237
+ "Heat Gain (W)": internal_load["heat_gain"],
238
+ "CLF": internal_load["clf"],
239
+ "Load (kW)": internal_load["load"]
240
+ })
241
+
242
+ # Add infiltration and ventilation
243
+ cooling_details.append({
244
+ "Component Type": "Infiltration",
245
+ "Name": "Air Infiltration",
246
+ "Air Flow (m³/s)": results["cooling"]["detailed_loads"]["infiltration"]["air_flow"],
247
+ "Sensible Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["sensible_load"],
248
+ "Latent Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["latent_load"],
249
+ "Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["total_load"]
250
+ })
251
+
252
+ cooling_details.append({
253
+ "Component Type": "Ventilation",
254
+ "Name": "Fresh Air",
255
+ "Air Flow (m³/s)": results["cooling"]["detailed_loads"]["ventilation"]["air_flow"],
256
+ "Sensible Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["sensible_load"],
257
+ "Latent Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["latent_load"],
258
+ "Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["total_load"]
259
+ })
260
+
261
+ # Display cooling details table
262
+ cooling_df = pd.DataFrame(cooling_details)
263
+ st.dataframe(cooling_df, use_container_width=True)
264
+
265
+ with tab2:
266
+ # Create heating load details table
267
+ heating_details = []
268
+
269
+ # Add envelope components
270
+ for wall in results["heating"]["detailed_loads"]["walls"]:
271
+ heating_details.append({
272
+ "Component Type": "Wall",
273
+ "Name": wall["name"],
274
+ "Orientation": wall["orientation"],
275
+ "Area (m²)": wall["area"],
276
+ "U-Value (W/m²·K)": wall["u_value"],
277
+ "Temperature Difference (°C)": wall["delta_t"],
278
+ "Load (kW)": wall["load"]
279
+ })
280
+
281
+ for roof in results["heating"]["detailed_loads"]["roofs"]:
282
+ heating_details.append({
283
+ "Component Type": "Roof",
284
+ "Name": roof["name"],
285
+ "Orientation": roof["orientation"],
286
+ "Area (m²)": roof["area"],
287
+ "U-Value (W/m²·K)": roof["u_value"],
288
+ "Temperature Difference (°C)": roof["delta_t"],
289
+ "Load (kW)": roof["load"]
290
+ })
291
+
292
+ for floor in results["heating"]["detailed_loads"]["floors"]:
293
+ heating_details.append({
294
+ "Component Type": "Floor",
295
+ "Name": floor["name"],
296
+ "Area (m²)": floor["area"],
297
+ "U-Value (W/m²·K)": floor["u_value"],
298
+ "Temperature Difference (°C)": floor["delta_t"],
299
+ "Load (kW)": floor["load"]
300
+ })
301
+
302
+ for window in results["heating"]["detailed_loads"]["windows"]:
303
+ heating_details.append({
304
+ "Component Type": "Window",
305
+ "Name": window["name"],
306
+ "Orientation": window["orientation"],
307
+ "Area (m²)": window["area"],
308
+ "U-Value (W/m²·K)": window["u_value"],
309
+ "Temperature Difference (°C)": window["delta_t"],
310
+ "Load (kW)": window["load"]
311
+ })
312
+
313
+ for door in results["heating"]["detailed_loads"]["doors"]:
314
+ heating_details.append({
315
+ "Component Type": "Door",
316
+ "Name": door["name"],
317
+ "Orientation": door["orientation"],
318
+ "Area (m²)": door["area"],
319
+ "U-Value (W/m²·K)": door["u_value"],
320
+ "Temperature Difference (°C)": door["delta_t"],
321
+ "Load (kW)": door["load"]
322
+ })
323
+
324
+ # Add infiltration and ventilation
325
+ heating_details.append({
326
+ "Component Type": "Infiltration",
327
+ "Name": "Air Infiltration",
328
+ "Air Flow (m³/s)": results["heating"]["detailed_loads"]["infiltration"]["air_flow"],
329
+ "Temperature Difference (°C)": results["heating"]["detailed_loads"]["infiltration"]["delta_t"],
330
+ "Load (kW)": results["heating"]["detailed_loads"]["infiltration"]["load"]
331
+ })
332
+
333
+ heating_details.append({
334
+ "Component Type": "Ventilation",
335
+ "Name": "Fresh Air",
336
+ "Air Flow (m³/s)": results["heating"]["detailed_loads"]["ventilation"]["air_flow"],
337
+ "Temperature Difference (°C)": results["heating"]["detailed_loads"]["ventilation"]["delta_t"],
338
+ "Load (kW)": results["heating"]["detailed_loads"]["ventilation"]["load"]
339
+ })
340
+
341
+ # Display heating details table
342
+ heating_df = pd.DataFrame(heating_details)
343
+ st.dataframe(heating_df, use_container_width=True)
344
+
345
+ # Add download buttons for results
346
+ st.subheader("Download Results")
347
+
348
+ col1, col2 = st.columns(2)
349
+
350
+ with col1:
351
+ if st.button("Download Cooling Load Results (CSV)"):
352
+ cooling_csv = cooling_df.to_csv(index=False)
353
+ st.download_button(
354
+ label="Download CSV",
355
+ data=cooling_csv,
356
+ file_name=f"cooling_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
357
+ mime="text/csv"
358
+ )
359
+
360
+ with col2:
361
+ if st.button("Download Heating Load Results (CSV)"):
362
+ heating_csv = heating_df.to_csv(index=False)
363
+ st.download_button(
364
+ label="Download CSV",
365
+ data=heating_csv,
366
+ file_name=f"heating_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
367
+ mime="text/csv"
368
+ )
369
+
370
+ # Add button to download full report
371
+ if st.button("Generate Full Report (Excel)"):
372
+ # This would be implemented with the export functionality
373
+ st.info("Excel report generation will be implemented in the Export module.")
374
+
375
+ def _display_component_breakdown(self, session_state: Dict[str, Any]) -> None:
376
+ """
377
+ Display component breakdown visualization.
378
+
379
+ Args:
380
+ session_state: Streamlit session state containing calculation results
381
+ """
382
+ st.subheader("Component Breakdown")
383
+
384
+ # Use component visualization module
385
+ self.component_visualization.display_component_breakdown(
386
+ session_state["calculation_results"],
387
+ session_state["components"]
388
+ )
389
+
390
+ def _display_psychrometric_analysis(self, session_state: Dict[str, Any]) -> None:
391
+ """
392
+ Display psychrometric analysis visualization.
393
+
394
+ Args:
395
+ session_state: Streamlit session state containing calculation results
396
+ """
397
+ st.subheader("Psychrometric Analysis")
398
+
399
+ # Use psychrometric visualization module
400
+ self.psychrometric_visualization.display_psychrometric_chart(
401
+ session_state["calculation_results"],
402
+ session_state["building_info"]
403
+ )
404
+
405
+ def _display_time_analysis(self, session_state: Dict[str, Any]) -> None:
406
+ """
407
+ Display time-based analysis visualization.
408
+
409
+ Args:
410
+ session_state: Streamlit session state containing calculation results
411
+ """
412
+ st.subheader("Time Analysis")
413
+
414
+ # Use time-based visualization module
415
+ self.time_based_visualization.display_time_analysis(
416
+ session_state["calculation_results"]
417
+ )
418
+
419
+ def _display_scenario_comparison(self, session_state: Dict[str, Any]) -> None:
420
+ """
421
+ Display scenario comparison visualization.
422
+
423
+ Args:
424
+ session_state: Streamlit session state containing calculation results
425
+ """
426
+ st.subheader("Scenario Comparison")
427
+
428
+ # Check if there are saved scenarios
429
+ if "saved_scenarios" not in session_state or not session_state["saved_scenarios"]:
430
+ st.info("No saved scenarios available for comparison. Save the current results as a scenario to enable comparison.")
431
+
432
+ # Add button to save current results as a scenario
433
+ scenario_name = st.text_input("Scenario Name", value="Baseline")
434
+
435
+ if st.button("Save Current Results as Scenario"):
436
+ if "saved_scenarios" not in session_state:
437
+ session_state["saved_scenarios"] = {}
438
+
439
+ # Save current results as a scenario
440
+ session_state["saved_scenarios"][scenario_name] = {
441
+ "results": session_state["calculation_results"],
442
+ "building_info": session_state["building_info"],
443
+ "components": session_state["components"],
444
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
445
+ }
446
+
447
+ st.success(f"Scenario '{scenario_name}' saved successfully!")
448
+ st.experimental_rerun()
449
+ else:
450
+ # Use scenario comparison module
451
+ self.scenario_comparison.display_scenario_comparison(
452
+ session_state["calculation_results"],
453
+ session_state["saved_scenarios"]
454
+ )
455
+
456
+ # Add button to save current results as a new scenario
457
+ st.write("### Save Current Results as New Scenario")
458
+
459
+ scenario_name = st.text_input("Scenario Name", value="Scenario " + str(len(session_state["saved_scenarios"]) + 1))
460
+
461
+ if st.button("Save Current Results as Scenario"):
462
+ # Save current results as a scenario
463
+ session_state["saved_scenarios"][scenario_name] = {
464
+ "results": session_state["calculation_results"],
465
+ "building_info": session_state["building_info"],
466
+ "components": session_state["components"],
467
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
468
+ }
469
+
470
+ st.success(f"Scenario '{scenario_name}' saved successfully!")
471
+ st.experimental_rerun()
472
+
473
+ # Add button to delete a scenario
474
+ st.write("### Delete Scenario")
475
+
476
+ scenario_to_delete = st.selectbox(
477
+ "Select Scenario to Delete",
478
+ options=list(session_state["saved_scenarios"].keys())
479
+ )
480
+
481
+ if st.button("Delete Selected Scenario"):
482
+ # Delete selected scenario
483
+ del session_state["saved_scenarios"][scenario_to_delete]
484
+
485
+ st.success(f"Scenario '{scenario_to_delete}' deleted successfully!")
486
+ st.experimental_rerun()
487
+
488
+
489
+ # Create a singleton instance
490
+ results_display = ResultsDisplay()
491
+
492
+ # Example usage
493
+ if __name__ == "__main__":
494
+ import streamlit as st
495
+
496
+ # Initialize session state with dummy data for testing
497
+ if "calculation_results" not in st.session_state:
498
+ st.session_state["calculation_results"] = {
499
+ "cooling": {
500
+ "total_load": 25.5,
501
+ "sensible_load": 20.0,
502
+ "latent_load": 5.5,
503
+ "load_per_area": 85.0,
504
+ "component_loads": {
505
+ "walls": 5.0,
506
+ "roof": 3.0,
507
+ "windows": 8.0,
508
+ "doors": 1.0,
509
+ "people": 2.5,
510
+ "lighting": 2.0,
511
+ "equipment": 1.5,
512
+ "infiltration": 1.0,
513
+ "ventilation": 1.5
514
+ },
515
+ "detailed_loads": {
516
+ "walls": [
517
+ {"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "cltd": 10.0, "load": 1.0}
518
+ ],
519
+ "roofs": [
520
+ {"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "cltd": 15.0, "load": 3.0}
521
+ ],
522
+ "windows": [
523
+ {"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "shgc": 0.7, "scl": 800.0, "load": 8.0}
524
+ ],
525
+ "doors": [
526
+ {"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "cltd": 10.0, "load": 1.0}
527
+ ],
528
+ "internal": [
529
+ {"type": "People", "name": "Occupants", "quantity": 10, "heat_gain": 250, "clf": 1.0, "load": 2.5},
530
+ {"type": "Lighting", "name": "General Lighting", "quantity": 1000, "heat_gain": 2000, "clf": 1.0, "load": 2.0},
531
+ {"type": "Equipment", "name": "Office Equipment", "quantity": 5, "heat_gain": 300, "clf": 1.0, "load": 1.5}
532
+ ],
533
+ "infiltration": {
534
+ "air_flow": 0.05,
535
+ "sensible_load": 0.8,
536
+ "latent_load": 0.2,
537
+ "total_load": 1.0
538
+ },
539
+ "ventilation": {
540
+ "air_flow": 0.1,
541
+ "sensible_load": 1.0,
542
+ "latent_load": 0.5,
543
+ "total_load": 1.5
544
+ }
545
+ }
546
+ },
547
+ "heating": {
548
+ "total_load": 30.0,
549
+ "load_per_area": 100.0,
550
+ "design_heat_loss": 27.0,
551
+ "safety_factor": 10.0,
552
+ "component_loads": {
553
+ "walls": 8.0,
554
+ "roof": 5.0,
555
+ "floor": 4.0,
556
+ "windows": 7.0,
557
+ "doors": 1.0,
558
+ "infiltration": 2.0,
559
+ "ventilation": 3.0
560
+ },
561
+ "detailed_loads": {
562
+ "walls": [
563
+ {"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "delta_t": 25.0, "load": 8.0}
564
+ ],
565
+ "roofs": [
566
+ {"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "delta_t": 25.0, "load": 5.0}
567
+ ],
568
+ "floors": [
569
+ {"name": "Ground Floor", "area": 100.0, "u_value": 0.4, "delta_t": 10.0, "load": 4.0}
570
+ ],
571
+ "windows": [
572
+ {"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "delta_t": 25.0, "load": 7.0}
573
+ ],
574
+ "doors": [
575
+ {"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "delta_t": 25.0, "load": 1.0}
576
+ ],
577
+ "infiltration": {
578
+ "air_flow": 0.05,
579
+ "delta_t": 25.0,
580
+ "load": 2.0
581
+ },
582
+ "ventilation": {
583
+ "air_flow": 0.1,
584
+ "delta_t": 25.0,
585
+ "load": 3.0
586
+ }
587
+ }
588
+ }
589
+ }
590
+
591
+ # Display results
592
+ results_display.display_results(st.session_state)
data/ashrae_tables.py ADDED
@@ -0,0 +1,702 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ASHRAE tables module for HVAC Load Calculator.
3
+ This module implements CLTD, SCL, CLF tables and interpolation functions for load calculations.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ from enum import Enum
12
+
13
+ # Define paths
14
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
15
+
16
+
17
+ class WallGroup(Enum):
18
+ """Enumeration for ASHRAE wall groups."""
19
+ A = "A" # Light construction
20
+ B = "B"
21
+ C = "C"
22
+ D = "D"
23
+ E = "E"
24
+ F = "F"
25
+ G = "G"
26
+ H = "H" # Heavy construction
27
+
28
+
29
+ class RoofGroup(Enum):
30
+ """Enumeration for ASHRAE roof groups."""
31
+ A = "A" # Light construction
32
+ B = "B"
33
+ C = "C"
34
+ D = "D"
35
+ E = "E"
36
+ F = "F"
37
+ G = "G" # Heavy construction
38
+
39
+
40
+ class Orientation(Enum):
41
+ """Enumeration for building component orientations."""
42
+ N = "North"
43
+ NE = "Northeast"
44
+ E = "East"
45
+ SE = "Southeast"
46
+ S = "South"
47
+ SW = "Southwest"
48
+ W = "West"
49
+ NW = "Northwest"
50
+ HOR = "Horizontal" # For roofs and floors
51
+
52
+
53
+ class ASHRAETables:
54
+ """Class for managing ASHRAE tables for load calculations."""
55
+
56
+ def __init__(self):
57
+ """Initialize ASHRAE tables."""
58
+ # Load tables
59
+ self.cltd_wall = self._load_cltd_wall_table()
60
+ self.cltd_roof = self._load_cltd_roof_table()
61
+ self.scl = self._load_scl_table()
62
+ self.clf_lights = self._load_clf_lights_table()
63
+ self.clf_people = self._load_clf_people_table()
64
+ self.clf_equipment = self._load_clf_equipment_table()
65
+
66
+ # Load correction factors
67
+ self.latitude_correction = self._load_latitude_correction()
68
+ self.color_correction = self._load_color_correction()
69
+ self.month_correction = self._load_month_correction()
70
+
71
+ def _load_cltd_wall_table(self) -> Dict[str, pd.DataFrame]:
72
+ """
73
+ Load CLTD tables for walls.
74
+
75
+ Returns:
76
+ Dictionary of DataFrames with CLTD values for each wall group
77
+ """
78
+ # This would typically load from CSV files with ASHRAE data
79
+ # For now, we'll define sample tables inline
80
+
81
+ # Hours of the day (0-23)
82
+ hours = list(range(24))
83
+
84
+ # Sample CLTD values for wall group A (light construction)
85
+ # Values for different orientations (N, NE, E, SE, S, SW, W, NW)
86
+ wall_a_data = {
87
+ "N": [2, 1, 0, -1, -2, -2, -2, -1, 0, 1, 3, 4, 6, 7, 8, 9, 9, 8, 7, 6, 5, 4, 3, 3],
88
+ "NE": [1, 0, -1, -1, -2, -1, 1, 4, 7, 9, 10, 11, 11, 10, 9, 8, 7, 6, 5, 4, 3, 3, 2, 1],
89
+ "E": [1, 0, -1, -1, -2, -1, 2, 6, 10, 13, 15, 16, 15, 14, 12, 10, 8, 6, 5, 4, 3, 2, 2, 1],
90
+ "SE": [1, 0, -1, -1, -2, -1, 1, 4, 8, 11, 14, 15, 16, 15, 14, 12, 10, 8, 6, 5, 4, 3, 2, 1],
91
+ "S": [1, 0, -1, -1, -2, -2, -1, 0, 2, 4, 7, 10, 12, 14, 15, 15, 14, 12, 9, 7, 5, 3, 2, 2],
92
+ "SW": [2, 1, 0, -1, -1, -2, -2, -1, 0, 2, 4, 6, 8, 11, 13, 15, 16, 15, 13, 10, 8, 6, 4, 3],
93
+ "W": [2, 1, 0, -1, -1, -2, -2, -1, 0, 1, 3, 4, 6, 8, 10, 13, 15, 16, 15, 13, 10, 7, 5, 3],
94
+ "NW": [2, 1, 0, -1, -1, -2, -2, -1, 0, 1, 2, 3, 5, 6, 7, 9, 11, 12, 12, 11, 9, 7, 5, 3]
95
+ }
96
+
97
+ # Sample CLTD values for wall group B
98
+ wall_b_data = {
99
+ "N": [3, 2, 1, 0, -1, -1, -1, 0, 1, 2, 4, 5, 7, 8, 9, 10, 10, 9, 8, 7, 6, 5, 4, 4],
100
+ "NE": [2, 1, 0, 0, -1, 0, 2, 5, 8, 10, 11, 12, 12, 11, 10, 9, 8, 7, 6, 5, 4, 4, 3, 2],
101
+ "E": [2, 1, 0, 0, -1, 0, 3, 7, 11, 14, 16, 17, 16, 15, 13, 11, 9, 7, 6, 5, 4, 3, 3, 2],
102
+ "SE": [2, 1, 0, 0, -1, 0, 2, 5, 9, 12, 15, 16, 17, 16, 15, 13, 11, 9, 7, 6, 5, 4, 3, 2],
103
+ "S": [2, 1, 0, 0, -1, -1, 0, 1, 3, 5, 8, 11, 13, 15, 16, 16, 15, 13, 10, 8, 6, 4, 3, 3],
104
+ "SW": [3, 2, 1, 0, 0, -1, -1, 0, 1, 3, 5, 7, 9, 12, 14, 16, 17, 16, 14, 11, 9, 7, 5, 4],
105
+ "W": [3, 2, 1, 0, 0, -1, -1, 0, 1, 2, 4, 5, 7, 9, 11, 14, 16, 17, 16, 14, 11, 8, 6, 4],
106
+ "NW": [3, 2, 1, 0, 0, -1, -1, 0, 1, 2, 3, 4, 6, 7, 8, 10, 12, 13, 13, 12, 10, 8, 6, 4]
107
+ }
108
+
109
+ # Sample CLTD values for wall group C
110
+ wall_c_data = {
111
+ "N": [4, 3, 2, 1, 0, 0, 0, 1, 2, 3, 5, 6, 8, 9, 10, 11, 11, 10, 9, 8, 7, 6, 5, 5],
112
+ "NE": [3, 2, 1, 1, 0, 1, 3, 6, 9, 11, 12, 13, 13, 12, 11, 10, 9, 8, 7, 6, 5, 5, 4, 3],
113
+ "E": [3, 2, 1, 1, 0, 1, 4, 8, 12, 15, 17, 18, 17, 16, 14, 12, 10, 8, 7, 6, 5, 4, 4, 3],
114
+ "SE": [3, 2, 1, 1, 0, 1, 3, 6, 10, 13, 16, 17, 18, 17, 16, 14, 12, 10, 8, 7, 6, 5, 4, 3],
115
+ "S": [3, 2, 1, 1, 0, 0, 1, 2, 4, 6, 9, 12, 14, 16, 17, 17, 16, 14, 11, 9, 7, 5, 4, 4],
116
+ "SW": [4, 3, 2, 1, 1, 0, 0, 1, 2, 4, 6, 8, 10, 13, 15, 17, 18, 17, 15, 12, 10, 8, 6, 5],
117
+ "W": [4, 3, 2, 1, 1, 0, 0, 1, 2, 3, 5, 6, 8, 10, 12, 15, 17, 18, 17, 15, 12, 9, 7, 5],
118
+ "NW": [4, 3, 2, 1, 1, 0, 0, 1, 2, 3, 4, 5, 7, 8, 9, 11, 13, 14, 14, 13, 11, 9, 7, 5]
119
+ }
120
+
121
+ # Sample CLTD values for wall group D
122
+ wall_d_data = {
123
+ "N": [5, 4, 3, 2, 1, 1, 1, 2, 3, 4, 6, 7, 9, 10, 11, 12, 12, 11, 10, 9, 8, 7, 6, 6],
124
+ "NE": [4, 3, 2, 2, 1, 2, 4, 7, 10, 12, 13, 14, 14, 13, 12, 11, 10, 9, 8, 7, 6, 6, 5, 4],
125
+ "E": [4, 3, 2, 2, 1, 2, 5, 9, 13, 16, 18, 19, 18, 17, 15, 13, 11, 9, 8, 7, 6, 5, 5, 4],
126
+ "SE": [4, 3, 2, 2, 1, 2, 4, 7, 11, 14, 17, 18, 19, 18, 17, 15, 13, 11, 9, 8, 7, 6, 5, 4],
127
+ "S": [4, 3, 2, 2, 1, 1, 2, 3, 5, 7, 10, 13, 15, 17, 18, 18, 17, 15, 12, 10, 8, 6, 5, 5],
128
+ "SW": [5, 4, 3, 2, 2, 1, 1, 2, 3, 5, 7, 9, 11, 14, 16, 18, 19, 18, 16, 13, 11, 9, 7, 6],
129
+ "W": [5, 4, 3, 2, 2, 1, 1, 2, 3, 4, 6, 7, 9, 11, 13, 16, 18, 19, 18, 16, 13, 10, 8, 6],
130
+ "NW": [5, 4, 3, 2, 2, 1, 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 14, 15, 15, 14, 12, 10, 8, 6]
131
+ }
132
+
133
+ # Create DataFrames for each wall group
134
+ wall_groups = {
135
+ "A": pd.DataFrame(wall_a_data, index=hours),
136
+ "B": pd.DataFrame(wall_b_data, index=hours),
137
+ "C": pd.DataFrame(wall_c_data, index=hours),
138
+ "D": pd.DataFrame(wall_d_data, index=hours),
139
+ # Groups E-H would be defined similarly
140
+ # Using group D values for now as placeholders
141
+ "E": pd.DataFrame(wall_d_data, index=hours),
142
+ "F": pd.DataFrame(wall_d_data, index=hours),
143
+ "G": pd.DataFrame(wall_d_data, index=hours),
144
+ "H": pd.DataFrame(wall_d_data, index=hours)
145
+ }
146
+
147
+ return wall_groups
148
+
149
+ def _load_cltd_roof_table(self) -> Dict[str, pd.DataFrame]:
150
+ """
151
+ Load CLTD tables for roofs.
152
+
153
+ Returns:
154
+ Dictionary of DataFrames with CLTD values for each roof group
155
+ """
156
+ # Hours of the day (0-23)
157
+ hours = list(range(24))
158
+
159
+ # Sample CLTD values for roof group A (light construction)
160
+ roof_a_data = [6, 3, 1, -1, -2, -3, 0, 5, 11, 17, 23, 28, 32, 34, 35, 34, 31, 27, 22, 18, 14, 12, 9, 7]
161
+
162
+ # Sample CLTD values for roof group B
163
+ roof_b_data = [9, 7, 5, 3, 2, 1, 1, 3, 7, 12, 17, 22, 26, 30, 33, 34, 34, 32, 29, 25, 21, 17, 14, 11]
164
+
165
+ # Sample CLTD values for roof group C
166
+ roof_c_data = [13, 11, 9, 7, 6, 5, 4, 4, 6, 9, 13, 17, 21, 25, 28, 31, 32, 32, 30, 27, 24, 21, 18, 15]
167
+
168
+ # Sample CLTD values for roof group D
169
+ roof_d_data = [14, 13, 12, 10, 9, 8, 7, 7, 7, 9, 11, 14, 17, 20, 23, 26, 28, 29, 29, 27, 25, 22, 19, 17]
170
+
171
+ # Sample CLTD values for roof group E
172
+ roof_e_data = [17, 16, 15, 14, 13, 12, 11, 11, 11, 11, 12, 14, 16, 18, 21, 23, 25, 26, 27, 26, 25, 23, 21, 19]
173
+
174
+ # Sample CLTD values for roof group F
175
+ roof_f_data = [19, 18, 18, 17, 16, 16, 15, 15, 14, 14, 15, 16, 17, 19, 20, 22, 23, 24, 25, 25, 24, 23, 22, 20]
176
+
177
+ # Sample CLTD values for roof group G (heavy construction)
178
+ roof_g_data = [20, 20, 19, 19, 19, 18, 18, 18, 18, 18, 18, 18, 19, 19, 20, 21, 22, 22, 23, 23, 23, 22, 22, 21]
179
+
180
+ # Create DataFrames for each roof group
181
+ roof_groups = {
182
+ "A": pd.DataFrame({"HOR": roof_a_data}, index=hours),
183
+ "B": pd.DataFrame({"HOR": roof_b_data}, index=hours),
184
+ "C": pd.DataFrame({"HOR": roof_c_data}, index=hours),
185
+ "D": pd.DataFrame({"HOR": roof_d_data}, index=hours),
186
+ "E": pd.DataFrame({"HOR": roof_e_data}, index=hours),
187
+ "F": pd.DataFrame({"HOR": roof_f_data}, index=hours),
188
+ "G": pd.DataFrame({"HOR": roof_g_data}, index=hours)
189
+ }
190
+
191
+ return roof_groups
192
+
193
+ def _load_scl_table(self) -> Dict[str, pd.DataFrame]:
194
+ """
195
+ Load SCL (Solar Cooling Load) tables for windows.
196
+
197
+ Returns:
198
+ Dictionary of DataFrames with SCL values for each orientation
199
+ """
200
+ # Hours of the day (0-23)
201
+ hours = list(range(24))
202
+
203
+ # Sample SCL values for different orientations
204
+ # Values are for 40° North latitude in July
205
+ scl_data = {
206
+ "N": [11, 8, 6, 6, 6, 9, 13, 16, 19, 21, 22, 23, 23, 22, 20, 17, 14, 11, 11, 11, 11, 11, 11, 11],
207
+ "NE": [11, 8, 6, 6, 6, 19, 75, 113, 121, 103, 75, 40, 31, 27, 23, 19, 14, 11, 11, 11, 11, 11, 11, 11],
208
+ "E": [11, 8, 6, 6, 6, 13, 55, 159, 232, 251, 222, 157, 82, 43, 32, 24, 17, 11, 11, 11, 11, 11, 11, 11],
209
+ "SE": [11, 8, 6, 6, 6, 10, 33, 98, 187, 251, 276, 264, 214, 139, 74, 37, 21, 11, 11, 11, 11, 11, 11, 11],
210
+ "S": [11, 8, 6, 6, 6, 8, 14, 27, 66, 139, 209, 254, 268, 251, 203, 139, 66, 27, 14, 11, 11, 11, 11, 11],
211
+ "SW": [11, 8, 6, 6, 6, 8, 14, 19, 24, 37, 74, 139, 214, 264, 276, 251, 187, 98, 33, 14, 11, 11, 11, 11],
212
+ "W": [11, 8, 6, 6, 6, 8, 14, 19, 24, 32, 43, 82, 157, 222, 251, 232, 159, 55, 13, 11, 11, 11, 11, 11],
213
+ "NW": [11, 8, 6, 6, 6, 8, 14, 19, 24, 27, 31, 40, 75, 103, 121, 113, 75, 19, 11, 11, 11, 11, 11, 11],
214
+ "HOR": [11, 8, 6, 6, 6, 19, 69, 135, 201, 254, 290, 308, 308, 290, 254, 201, 135, 69, 19, 11, 11, 11, 11, 11]
215
+ }
216
+
217
+ # Create DataFrame for SCL values
218
+ scl_table = pd.DataFrame(scl_data, index=hours)
219
+
220
+ # Return as a dictionary with a single entry for now
221
+ # In a real implementation, there would be tables for different latitudes and months
222
+ return {"40N_JUL": scl_table}
223
+
224
+ def _load_clf_lights_table(self) -> pd.DataFrame:
225
+ """
226
+ Load CLF (Cooling Load Factor) table for lights.
227
+
228
+ Returns:
229
+ DataFrame with CLF values for lights
230
+ """
231
+ # Hours of the day (0-23)
232
+ hours = list(range(24))
233
+
234
+ # Sample CLF values for lights
235
+ # Values for different hours of operation
236
+ clf_lights_data = {
237
+ "8h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.24, 0.20, 0.17, 0.14, 0.12, 0.10, 0.08],
238
+ "10h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.17, 0.14, 0.12, 0.10, 0.08],
239
+ "12h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.71, 0.14, 0.12, 0.10, 0.08],
240
+ "14h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.71, 0.61, 0.12, 0.10, 0.08],
241
+ "16h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.71, 0.61, 0.53, 0.10, 0.08],
242
+ "18h": [0.45, 0.27, 0.16, 0.10, 0.06, 0.04, 0.02, 0.01, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.39, 0.33, 0.28, 0.88, 0.82, 0.71, 0.61, 0.53, 0.45, 0.08],
243
+ "24h": [0.18, 0.16, 0.14, 0.12, 0.11, 0.10, 0.09, 0.08, 0.07, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06]
244
+ }
245
+
246
+ # Create DataFrame for CLF values for lights
247
+ return pd.DataFrame(clf_lights_data, index=hours)
248
+
249
+ def _load_clf_people_table(self) -> pd.DataFrame:
250
+ """
251
+ Load CLF (Cooling Load Factor) table for people.
252
+
253
+ Returns:
254
+ DataFrame with CLF values for people
255
+ """
256
+ # Hours of the day (0-23)
257
+ hours = list(range(24))
258
+
259
+ # Sample CLF values for people
260
+ # Values for different hours of occupancy
261
+ clf_people_data = {
262
+ "8h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.50, 0.47, 0.44, 0.41, 0.38, 0.36, 0.33],
263
+ "10h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.44, 0.41, 0.38, 0.36, 0.33],
264
+ "12h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.80, 0.41, 0.38, 0.36, 0.33],
265
+ "14h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.80, 0.75, 0.38, 0.36, 0.33],
266
+ "16h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.80, 0.75, 0.70, 0.36, 0.33],
267
+ "18h": [0.58, 0.42, 0.30, 0.22, 0.16, 0.12, 0.08, 0.06, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.62, 0.58, 0.54, 0.93, 0.85, 0.80, 0.75, 0.70, 0.66, 0.33],
268
+ "24h": [0.27, 0.25, 0.23, 0.21, 0.19, 0.17, 0.16, 0.14, 0.13, 0.12, 0.11, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10]
269
+ }
270
+
271
+ # Create DataFrame for CLF values for people
272
+ return pd.DataFrame(clf_people_data, index=hours)
273
+
274
+ def _load_clf_equipment_table(self) -> pd.DataFrame:
275
+ """
276
+ Load CLF (Cooling Load Factor) table for equipment.
277
+
278
+ Returns:
279
+ DataFrame with CLF values for equipment
280
+ """
281
+ # Hours of the day (0-23)
282
+ hours = list(range(24))
283
+
284
+ # Sample CLF values for equipment
285
+ # Values for different hours of operation
286
+ clf_equipment_data = {
287
+ "8h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.30, 0.26, 0.22, 0.18, 0.16, 0.14, 0.12],
288
+ "10h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.22, 0.18, 0.16, 0.14, 0.12],
289
+ "12h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.76, 0.18, 0.16, 0.14, 0.12],
290
+ "14h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.76, 0.69, 0.16, 0.14, 0.12],
291
+ "16h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.76, 0.69, 0.62, 0.14, 0.12],
292
+ "18h": [0.50, 0.35, 0.25, 0.18, 0.13, 0.09, 0.06, 0.04, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.48, 0.41, 0.34, 0.90, 0.83, 0.76, 0.69, 0.62, 0.55, 0.12],
293
+ "24h": [0.23, 0.21, 0.19, 0.17, 0.15, 0.13, 0.12, 0.10, 0.09, 0.08, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07]
294
+ }
295
+
296
+ # Create DataFrame for CLF values for equipment
297
+ return pd.DataFrame(clf_equipment_data, index=hours)
298
+
299
+ def _load_latitude_correction(self) -> Dict[str, Dict[str, float]]:
300
+ """
301
+ Load latitude correction factors for CLTD/SCL values.
302
+
303
+ Returns:
304
+ Dictionary of correction factors for different latitudes and months
305
+ """
306
+ # Sample latitude correction factors
307
+ # Values for different latitudes and months
308
+ return {
309
+ "24N": {
310
+ "Jan": -5.0, "Feb": -3.5, "Mar": -1.0, "Apr": 2.0, "May": 4.0,
311
+ "Jun": 5.0, "Jul": 4.5, "Aug": 3.0, "Sep": 1.0, "Oct": -1.5,
312
+ "Nov": -4.0, "Dec": -5.5
313
+ },
314
+ "32N": {
315
+ "Jan": -4.0, "Feb": -2.5, "Mar": 0.0, "Apr": 2.5, "May": 4.5,
316
+ "Jun": 5.5, "Jul": 5.0, "Aug": 3.5, "Sep": 1.5, "Oct": -0.5,
317
+ "Nov": -3.0, "Dec": -4.5
318
+ },
319
+ "40N": {
320
+ "Jan": -3.0, "Feb": -1.5, "Mar": 1.0, "Apr": 3.0, "May": 5.0,
321
+ "Jun": 6.0, "Jul": 5.5, "Aug": 4.0, "Sep": 2.0, "Oct": 0.0,
322
+ "Nov": -2.0, "Dec": -3.5
323
+ },
324
+ "48N": {
325
+ "Jan": -2.0, "Feb": -0.5, "Mar": 2.0, "Apr": 4.0, "May": 6.0,
326
+ "Jun": 7.0, "Jul": 6.5, "Aug": 5.0, "Sep": 3.0, "Oct": 1.0,
327
+ "Nov": -1.0, "Dec": -2.5
328
+ },
329
+ "56N": {
330
+ "Jan": -1.0, "Feb": 0.5, "Mar": 3.0, "Apr": 5.0, "May": 7.0,
331
+ "Jun": 8.0, "Jul": 7.5, "Aug": 6.0, "Sep": 4.0, "Oct": 2.0,
332
+ "Nov": 0.0, "Dec": -1.5
333
+ }
334
+ }
335
+
336
+ def _load_color_correction(self) -> Dict[str, float]:
337
+ """
338
+ Load color correction factors for CLTD values.
339
+
340
+ Returns:
341
+ Dictionary of correction factors for different colors
342
+ """
343
+ # Color correction factors
344
+ return {
345
+ "Dark": 0.0, # No correction for dark colors (default)
346
+ "Medium": -1.0, # Correction for medium colors
347
+ "Light": -2.0 # Correction for light colors
348
+ }
349
+
350
+ def _load_month_correction(self) -> Dict[str, float]:
351
+ """
352
+ Load month correction factors for CLTD values.
353
+
354
+ Returns:
355
+ Dictionary of correction factors for different months
356
+ """
357
+ # Month correction factors (relative to July/August)
358
+ return {
359
+ "Jan": -6.0, "Feb": -5.0, "Mar": -3.0, "Apr": -1.0, "May": 1.0,
360
+ "Jun": 2.0, "Jul": 2.0, "Aug": 2.0, "Sep": 1.0, "Oct": -1.0,
361
+ "Nov": -3.0, "Dec": -5.0
362
+ }
363
+
364
+ def get_cltd_wall(self, wall_group: str, orientation: str, hour: int) -> float:
365
+ """
366
+ Get CLTD value for a wall.
367
+
368
+ Args:
369
+ wall_group: ASHRAE wall group (A-H)
370
+ orientation: Wall orientation (N, NE, E, SE, S, SW, W, NW)
371
+ hour: Hour of the day (0-23)
372
+
373
+ Returns:
374
+ CLTD value for the specified wall and hour
375
+ """
376
+ # Validate inputs
377
+ if wall_group not in self.cltd_wall:
378
+ raise ValueError(f"Invalid wall group: {wall_group}")
379
+
380
+ # Convert orientation to abbreviation if needed
381
+ orientation_map = {
382
+ "North": "N", "Northeast": "NE", "East": "E", "Southeast": "SE",
383
+ "South": "S", "Southwest": "SW", "West": "W", "Northwest": "NW"
384
+ }
385
+ orientation_abbr = orientation_map.get(orientation, orientation)
386
+
387
+ if orientation_abbr not in self.cltd_wall[wall_group].columns:
388
+ raise ValueError(f"Invalid orientation: {orientation}")
389
+
390
+ if hour < 0 or hour > 23:
391
+ raise ValueError(f"Invalid hour: {hour}")
392
+
393
+ # Get CLTD value
394
+ return self.cltd_wall[wall_group].loc[hour, orientation_abbr]
395
+
396
+ def get_cltd_roof(self, roof_group: str, hour: int) -> float:
397
+ """
398
+ Get CLTD value for a roof.
399
+
400
+ Args:
401
+ roof_group: ASHRAE roof group (A-G)
402
+ hour: Hour of the day (0-23)
403
+
404
+ Returns:
405
+ CLTD value for the specified roof and hour
406
+ """
407
+ # Validate inputs
408
+ if roof_group not in self.cltd_roof:
409
+ raise ValueError(f"Invalid roof group: {roof_group}")
410
+
411
+ if hour < 0 or hour > 23:
412
+ raise ValueError(f"Invalid hour: {hour}")
413
+
414
+ # Get CLTD value
415
+ return self.cltd_roof[roof_group].loc[hour, "HOR"]
416
+
417
+ def get_scl(self, orientation: str, hour: int, latitude: str = "40N_JUL") -> float:
418
+ """
419
+ Get SCL value for a window.
420
+
421
+ Args:
422
+ orientation: Window orientation (N, NE, E, SE, S, SW, W, NW, HOR)
423
+ hour: Hour of the day (0-23)
424
+ latitude: Latitude and month key (default: "40N_JUL")
425
+
426
+ Returns:
427
+ SCL value for the specified window and hour
428
+ """
429
+ # Validate inputs
430
+ if latitude not in self.scl:
431
+ raise ValueError(f"Invalid latitude: {latitude}")
432
+
433
+ # Convert orientation to abbreviation if needed
434
+ orientation_map = {
435
+ "North": "N", "Northeast": "NE", "East": "E", "Southeast": "SE",
436
+ "South": "S", "Southwest": "SW", "West": "W", "Northwest": "NW",
437
+ "Horizontal": "HOR"
438
+ }
439
+ orientation_abbr = orientation_map.get(orientation, orientation)
440
+
441
+ if orientation_abbr not in self.scl[latitude].columns:
442
+ raise ValueError(f"Invalid orientation: {orientation}")
443
+
444
+ if hour < 0 or hour > 23:
445
+ raise ValueError(f"Invalid hour: {hour}")
446
+
447
+ # Get SCL value
448
+ return self.scl[latitude].loc[hour, orientation_abbr]
449
+
450
+ def get_clf_lights(self, hour: int, hours_operation: str) -> float:
451
+ """
452
+ Get CLF value for lights.
453
+
454
+ Args:
455
+ hour: Hour of the day (0-23)
456
+ hours_operation: Hours of operation (8h, 10h, 12h, 14h, 16h, 18h, 24h)
457
+
458
+ Returns:
459
+ CLF value for lights at the specified hour
460
+ """
461
+ # Validate inputs
462
+ if hours_operation not in self.clf_lights.columns:
463
+ raise ValueError(f"Invalid hours of operation: {hours_operation}")
464
+
465
+ if hour < 0 or hour > 23:
466
+ raise ValueError(f"Invalid hour: {hour}")
467
+
468
+ # Get CLF value
469
+ return self.clf_lights.loc[hour, hours_operation]
470
+
471
+ def get_clf_people(self, hour: int, hours_occupancy: str) -> float:
472
+ """
473
+ Get CLF value for people.
474
+
475
+ Args:
476
+ hour: Hour of the day (0-23)
477
+ hours_occupancy: Hours of occupancy (8h, 10h, 12h, 14h, 16h, 18h, 24h)
478
+
479
+ Returns:
480
+ CLF value for people at the specified hour
481
+ """
482
+ # Validate inputs
483
+ if hours_occupancy not in self.clf_people.columns:
484
+ raise ValueError(f"Invalid hours of occupancy: {hours_occupancy}")
485
+
486
+ if hour < 0 or hour > 23:
487
+ raise ValueError(f"Invalid hour: {hour}")
488
+
489
+ # Get CLF value
490
+ return self.clf_people.loc[hour, hours_occupancy]
491
+
492
+ def get_clf_equipment(self, hour: int, hours_operation: str) -> float:
493
+ """
494
+ Get CLF value for equipment.
495
+
496
+ Args:
497
+ hour: Hour of the day (0-23)
498
+ hours_operation: Hours of operation (8h, 10h, 12h, 14h, 16h, 18h, 24h)
499
+
500
+ Returns:
501
+ CLF value for equipment at the specified hour
502
+ """
503
+ # Validate inputs
504
+ if hours_operation not in self.clf_equipment.columns:
505
+ raise ValueError(f"Invalid hours of operation: {hours_operation}")
506
+
507
+ if hour < 0 or hour > 23:
508
+ raise ValueError(f"Invalid hour: {hour}")
509
+
510
+ # Get CLF value
511
+ return self.clf_equipment.loc[hour, hours_operation]
512
+
513
+ def get_latitude_correction(self, latitude: str, month: str) -> float:
514
+ """
515
+ Get latitude correction factor for CLTD/SCL values.
516
+
517
+ Args:
518
+ latitude: Latitude (24N, 32N, 40N, 48N, 56N)
519
+ month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
520
+
521
+ Returns:
522
+ Latitude correction factor
523
+ """
524
+ # Validate inputs
525
+ if latitude not in self.latitude_correction:
526
+ raise ValueError(f"Invalid latitude: {latitude}")
527
+
528
+ if month not in self.latitude_correction[latitude]:
529
+ raise ValueError(f"Invalid month: {month}")
530
+
531
+ # Get correction factor
532
+ return self.latitude_correction[latitude][month]
533
+
534
+ def get_color_correction(self, color: str) -> float:
535
+ """
536
+ Get color correction factor for CLTD values.
537
+
538
+ Args:
539
+ color: Surface color (Dark, Medium, Light)
540
+
541
+ Returns:
542
+ Color correction factor
543
+ """
544
+ # Validate inputs
545
+ if color not in self.color_correction:
546
+ raise ValueError(f"Invalid color: {color}")
547
+
548
+ # Get correction factor
549
+ return self.color_correction[color]
550
+
551
+ def get_month_correction(self, month: str) -> float:
552
+ """
553
+ Get month correction factor for CLTD values.
554
+
555
+ Args:
556
+ month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
557
+
558
+ Returns:
559
+ Month correction factor
560
+ """
561
+ # Validate inputs
562
+ if month not in self.month_correction:
563
+ raise ValueError(f"Invalid month: {month}")
564
+
565
+ # Get correction factor
566
+ return self.month_correction[month]
567
+
568
+ def calculate_corrected_cltd_wall(self, wall_group: str, orientation: str, hour: int,
569
+ color: str = "Dark", month: str = "Jul",
570
+ latitude: str = "40N", indoor_temp: float = 25.5,
571
+ outdoor_temp: float = 35.0) -> float:
572
+ """
573
+ Calculate corrected CLTD value for a wall.
574
+
575
+ Args:
576
+ wall_group: ASHRAE wall group (A-H)
577
+ orientation: Wall orientation (N, NE, E, SE, S, SW, W, NW)
578
+ hour: Hour of the day (0-23)
579
+ color: Surface color (Dark, Medium, Light)
580
+ month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
581
+ latitude: Latitude (24N, 32N, 40N, 48N, 56N)
582
+ indoor_temp: Indoor design temperature (°C)
583
+ outdoor_temp: Outdoor design temperature (°C)
584
+
585
+ Returns:
586
+ Corrected CLTD value for the specified wall and hour
587
+ """
588
+ # Get base CLTD value
589
+ cltd = self.get_cltd_wall(wall_group, orientation, hour)
590
+
591
+ # Apply corrections
592
+ color_correction = self.get_color_correction(color)
593
+ month_correction = self.get_month_correction(month)
594
+ latitude_correction = self.get_latitude_correction(latitude, month)
595
+
596
+ # Temperature correction
597
+ temp_correction = (indoor_temp - 25.5) + (outdoor_temp - 35.0)
598
+
599
+ # Calculate corrected CLTD
600
+ corrected_cltd = cltd + color_correction + month_correction + latitude_correction + temp_correction
601
+
602
+ return max(0, corrected_cltd) # Ensure non-negative value
603
+
604
+ def calculate_corrected_cltd_roof(self, roof_group: str, hour: int,
605
+ color: str = "Dark", month: str = "Jul",
606
+ latitude: str = "40N", indoor_temp: float = 25.5,
607
+ outdoor_temp: float = 35.0) -> float:
608
+ """
609
+ Calculate corrected CLTD value for a roof.
610
+
611
+ Args:
612
+ roof_group: ASHRAE roof group (A-G)
613
+ hour: Hour of the day (0-23)
614
+ color: Surface color (Dark, Medium, Light)
615
+ month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
616
+ latitude: Latitude (24N, 32N, 40N, 48N, 56N)
617
+ indoor_temp: Indoor design temperature (°C)
618
+ outdoor_temp: Outdoor design temperature (°C)
619
+
620
+ Returns:
621
+ Corrected CLTD value for the specified roof and hour
622
+ """
623
+ # Get base CLTD value
624
+ cltd = self.get_cltd_roof(roof_group, hour)
625
+
626
+ # Apply corrections
627
+ color_correction = self.get_color_correction(color)
628
+ month_correction = self.get_month_correction(month)
629
+ latitude_correction = self.get_latitude_correction(latitude, month)
630
+
631
+ # Temperature correction
632
+ temp_correction = (indoor_temp - 25.5) + (outdoor_temp - 35.0)
633
+
634
+ # Calculate corrected CLTD
635
+ corrected_cltd = cltd + color_correction + month_correction + latitude_correction + temp_correction
636
+
637
+ return max(0, corrected_cltd) # Ensure non-negative value
638
+
639
+ def interpolate_cltd(self, cltd1: float, cltd2: float, factor: float) -> float:
640
+ """
641
+ Interpolate between two CLTD values.
642
+
643
+ Args:
644
+ cltd1: First CLTD value
645
+ cltd2: Second CLTD value
646
+ factor: Interpolation factor (0-1)
647
+
648
+ Returns:
649
+ Interpolated CLTD value
650
+ """
651
+ return cltd1 + factor * (cltd2 - cltd1)
652
+
653
+ def interpolate_scl(self, scl1: float, scl2: float, factor: float) -> float:
654
+ """
655
+ Interpolate between two SCL values.
656
+
657
+ Args:
658
+ scl1: First SCL value
659
+ scl2: Second SCL value
660
+ factor: Interpolation factor (0-1)
661
+
662
+ Returns:
663
+ Interpolated SCL value
664
+ """
665
+ return scl1 + factor * (scl2 - scl1)
666
+
667
+ def interpolate_clf(self, clf1: float, clf2: float, factor: float) -> float:
668
+ """
669
+ Interpolate between two CLF values.
670
+
671
+ Args:
672
+ clf1: First CLF value
673
+ clf2: Second CLF value
674
+ factor: Interpolation factor (0-1)
675
+
676
+ Returns:
677
+ Interpolated CLF value
678
+ """
679
+ return clf1 + factor * (clf2 - clf1)
680
+
681
+
682
+ # Create a singleton instance
683
+ ashrae_tables = ASHRAETables()
684
+
685
+ # Export tables to CSV if needed
686
+ if __name__ == "__main__":
687
+ # Export CLTD tables for walls
688
+ for group, df in ashrae_tables.cltd_wall.items():
689
+ df.to_csv(os.path.join(DATA_DIR, f"cltd_wall_group_{group}.csv"))
690
+
691
+ # Export CLTD tables for roofs
692
+ for group, df in ashrae_tables.cltd_roof.items():
693
+ df.to_csv(os.path.join(DATA_DIR, f"cltd_roof_group_{group}.csv"))
694
+
695
+ # Export SCL table
696
+ for key, df in ashrae_tables.scl.items():
697
+ df.to_csv(os.path.join(DATA_DIR, f"scl_{key}.csv"))
698
+
699
+ # Export CLF tables
700
+ ashrae_tables.clf_lights.to_csv(os.path.join(DATA_DIR, "clf_lights.csv"))
701
+ ashrae_tables.clf_people.to_csv(os.path.join(DATA_DIR, "clf_people.csv"))
702
+ ashrae_tables.clf_equipment.to_csv(os.path.join(DATA_DIR, "clf_equipment.csv"))
data/building_components.py ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Building component data models for HVAC Load Calculator.
3
+ This module defines the data structures for walls, roofs, floors, windows, doors, and other building components.
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import List, Dict, Optional, Union
9
+ import numpy as np
10
+
11
+
12
+ class Orientation(Enum):
13
+ """Enumeration for building component orientations."""
14
+ NORTH = "North"
15
+ NORTHEAST = "Northeast"
16
+ EAST = "East"
17
+ SOUTHEAST = "Southeast"
18
+ SOUTH = "South"
19
+ SOUTHWEST = "Southwest"
20
+ WEST = "West"
21
+ NORTHWEST = "Northwest"
22
+ HORIZONTAL = "Horizontal" # For roofs and floors
23
+ NOT_APPLICABLE = "N/A" # For components without orientation
24
+
25
+
26
+ class ComponentType(Enum):
27
+ """Enumeration for building component types."""
28
+ WALL = "Wall"
29
+ ROOF = "Roof"
30
+ FLOOR = "Floor"
31
+ WINDOW = "Window"
32
+ DOOR = "Door"
33
+ SKYLIGHT = "Skylight"
34
+
35
+
36
+ class MaterialLayer:
37
+ """Class representing a single material layer in a building component."""
38
+
39
+ def __init__(self, name: str, thickness: float, conductivity: float,
40
+ density: float = None, specific_heat: float = None):
41
+ """
42
+ Initialize a material layer.
43
+
44
+ Args:
45
+ name: Name of the material
46
+ thickness: Thickness of the layer in meters
47
+ conductivity: Thermal conductivity in W/(m·K)
48
+ density: Density in kg/m³ (optional)
49
+ specific_heat: Specific heat capacity in J/(kg·K) (optional)
50
+ """
51
+ self.name = name
52
+ self.thickness = thickness # m
53
+ self.conductivity = conductivity # W/(m·K)
54
+ self.density = density # kg/m³
55
+ self.specific_heat = specific_heat # J/(kg·K)
56
+
57
+ @property
58
+ def r_value(self) -> float:
59
+ """Calculate the thermal resistance (R-value) of the layer in m²·K/W."""
60
+ if self.conductivity == 0:
61
+ return float('inf') # Avoid division by zero
62
+ return self.thickness / self.conductivity
63
+
64
+ @property
65
+ def thermal_mass(self) -> Optional[float]:
66
+ """Calculate the thermal mass of the layer in J/(m²·K)."""
67
+ if self.density is None or self.specific_heat is None:
68
+ return None
69
+ return self.thickness * self.density * self.specific_heat
70
+
71
+ def to_dict(self) -> Dict:
72
+ """Convert the material layer to a dictionary."""
73
+ return {
74
+ "name": self.name,
75
+ "thickness": self.thickness,
76
+ "conductivity": self.conductivity,
77
+ "density": self.density,
78
+ "specific_heat": self.specific_heat,
79
+ "r_value": self.r_value,
80
+ "thermal_mass": self.thermal_mass
81
+ }
82
+
83
+
84
+ @dataclass
85
+ class BuildingComponent:
86
+ """Base class for all building components."""
87
+
88
+ id: str
89
+ name: str
90
+ component_type: ComponentType
91
+ u_value: float # W/(m²·K)
92
+ area: float # m²
93
+ orientation: Orientation = Orientation.NOT_APPLICABLE
94
+ color: str = "Medium" # Light, Medium, Dark
95
+ material_layers: List[MaterialLayer] = field(default_factory=list)
96
+
97
+ def __post_init__(self):
98
+ """Validate component data after initialization."""
99
+ if self.area <= 0:
100
+ raise ValueError("Area must be greater than zero")
101
+ if self.u_value < 0:
102
+ raise ValueError("U-value cannot be negative")
103
+
104
+ @property
105
+ def r_value(self) -> float:
106
+ """Calculate the total thermal resistance (R-value) in m²·K/W."""
107
+ return 1 / self.u_value if self.u_value > 0 else float('inf')
108
+
109
+ @property
110
+ def total_r_value_from_layers(self) -> Optional[float]:
111
+ """Calculate the total R-value from material layers if available."""
112
+ if not self.material_layers:
113
+ return None
114
+
115
+ # Add surface resistances (interior and exterior)
116
+ r_si = 0.13 # m²·K/W (interior surface resistance)
117
+ r_se = 0.04 # m²·K/W (exterior surface resistance)
118
+
119
+ # Sum the R-values of all layers
120
+ r_layers = sum(layer.r_value for layer in self.material_layers)
121
+
122
+ return r_si + r_layers + r_se
123
+
124
+ @property
125
+ def calculated_u_value(self) -> Optional[float]:
126
+ """Calculate U-value from material layers if available."""
127
+ total_r = self.total_r_value_from_layers
128
+ if total_r is None or total_r == 0:
129
+ return None
130
+ return 1 / total_r
131
+
132
+ def heat_transfer_rate(self, delta_t: float) -> float:
133
+ """
134
+ Calculate heat transfer rate through the component.
135
+
136
+ Args:
137
+ delta_t: Temperature difference across the component in K or °C
138
+
139
+ Returns:
140
+ Heat transfer rate in Watts
141
+ """
142
+ return self.u_value * self.area * delta_t
143
+
144
+ def to_dict(self) -> Dict:
145
+ """Convert the building component to a dictionary."""
146
+ return {
147
+ "id": self.id,
148
+ "name": self.name,
149
+ "component_type": self.component_type.value,
150
+ "u_value": self.u_value,
151
+ "area": self.area,
152
+ "orientation": self.orientation.value,
153
+ "color": self.color,
154
+ "r_value": self.r_value,
155
+ "material_layers": [layer.to_dict() for layer in self.material_layers],
156
+ "calculated_u_value": self.calculated_u_value,
157
+ "total_r_value_from_layers": self.total_r_value_from_layers
158
+ }
159
+
160
+
161
+ @dataclass
162
+ class Wall(BuildingComponent):
163
+ """Class representing a wall component."""
164
+
165
+ has_sun_exposure: bool = True
166
+ wall_type: str = "Custom" # Brick, Concrete, Wood Frame, etc.
167
+ wall_group: str = "A" # ASHRAE wall group (A, B, C, D, E, F, G, H)
168
+ gross_area: float = None # m² (before subtracting windows/doors)
169
+ net_area: float = None # m² (after subtracting windows/doors)
170
+ windows: List[str] = field(default_factory=list) # List of window IDs
171
+ doors: List[str] = field(default_factory=list) # List of door IDs
172
+
173
+ def __post_init__(self):
174
+ """Initialize wall-specific attributes."""
175
+ super().__post_init__()
176
+ self.component_type = ComponentType.WALL
177
+
178
+ # Set net area equal to area if not specified
179
+ if self.net_area is None:
180
+ self.net_area = self.area
181
+
182
+ # Set gross area equal to net area if not specified
183
+ if self.gross_area is None:
184
+ self.gross_area = self.net_area
185
+
186
+ def update_net_area(self, window_areas: Dict[str, float], door_areas: Dict[str, float]):
187
+ """
188
+ Update the net wall area by subtracting windows and doors.
189
+
190
+ Args:
191
+ window_areas: Dictionary mapping window IDs to areas
192
+ door_areas: Dictionary mapping door IDs to areas
193
+ """
194
+ total_window_area = sum(window_areas.get(window_id, 0) for window_id in self.windows)
195
+ total_door_area = sum(door_areas.get(door_id, 0) for door_id in self.doors)
196
+
197
+ self.net_area = self.gross_area - total_window_area - total_door_area
198
+ self.area = self.net_area # Update the main area property
199
+
200
+ if self.net_area <= 0:
201
+ raise ValueError("Net wall area cannot be negative or zero")
202
+
203
+ def to_dict(self) -> Dict:
204
+ """Convert the wall to a dictionary."""
205
+ wall_dict = super().to_dict()
206
+ wall_dict.update({
207
+ "has_sun_exposure": self.has_sun_exposure,
208
+ "wall_type": self.wall_type,
209
+ "wall_group": self.wall_group,
210
+ "gross_area": self.gross_area,
211
+ "net_area": self.net_area,
212
+ "windows": self.windows,
213
+ "doors": self.doors
214
+ })
215
+ return wall_dict
216
+
217
+
218
+ @dataclass
219
+ class Roof(BuildingComponent):
220
+ """Class representing a roof component."""
221
+
222
+ roof_type: str = "Custom" # Flat, Pitched, etc.
223
+ roof_group: str = "A" # ASHRAE roof group
224
+ pitch: float = 0.0 # Roof pitch in degrees
225
+ has_suspended_ceiling: bool = False
226
+ ceiling_plenum_height: float = 0.0 # m
227
+
228
+ def __post_init__(self):
229
+ """Initialize roof-specific attributes."""
230
+ super().__post_init__()
231
+ self.component_type = ComponentType.ROOF
232
+ self.orientation = Orientation.HORIZONTAL
233
+
234
+ def to_dict(self) -> Dict:
235
+ """Convert the roof to a dictionary."""
236
+ roof_dict = super().to_dict()
237
+ roof_dict.update({
238
+ "roof_type": self.roof_type,
239
+ "roof_group": self.roof_group,
240
+ "pitch": self.pitch,
241
+ "has_suspended_ceiling": self.has_suspended_ceiling,
242
+ "ceiling_plenum_height": self.ceiling_plenum_height
243
+ })
244
+ return roof_dict
245
+
246
+
247
+ @dataclass
248
+ class Floor(BuildingComponent):
249
+ """Class representing a floor component."""
250
+
251
+ floor_type: str = "Custom" # Slab-on-grade, Raised, etc.
252
+ is_ground_contact: bool = False
253
+ perimeter_length: float = 0.0 # m (for slab-on-grade floors)
254
+
255
+ def __post_init__(self):
256
+ """Initialize floor-specific attributes."""
257
+ super().__post_init__()
258
+ self.component_type = ComponentType.FLOOR
259
+ self.orientation = Orientation.HORIZONTAL
260
+
261
+ def to_dict(self) -> Dict:
262
+ """Convert the floor to a dictionary."""
263
+ floor_dict = super().to_dict()
264
+ floor_dict.update({
265
+ "floor_type": self.floor_type,
266
+ "is_ground_contact": self.is_ground_contact,
267
+ "perimeter_length": self.perimeter_length
268
+ })
269
+ return floor_dict
270
+
271
+
272
+ @dataclass
273
+ class Fenestration(BuildingComponent):
274
+ """Base class for fenestration components (windows, doors, skylights)."""
275
+
276
+ shgc: float = 0.7 # Solar Heat Gain Coefficient
277
+ vt: float = 0.7 # Visible Transmittance
278
+ frame_type: str = "Aluminum" # Aluminum, Wood, Vinyl, etc.
279
+ frame_width: float = 0.05 # m
280
+ has_shading: bool = False
281
+ shading_type: str = None # Internal, External, Between-glass
282
+ shading_coefficient: float = 1.0 # 0-1 (1 = no shading)
283
+
284
+ def __post_init__(self):
285
+ """Initialize fenestration-specific attributes."""
286
+ super().__post_init__()
287
+
288
+ if self.shgc < 0 or self.shgc > 1:
289
+ raise ValueError("SHGC must be between 0 and 1")
290
+ if self.vt < 0 or self.vt > 1:
291
+ raise ValueError("VT must be between 0 and 1")
292
+ if self.shading_coefficient < 0 or self.shading_coefficient > 1:
293
+ raise ValueError("Shading coefficient must be between 0 and 1")
294
+
295
+ @property
296
+ def effective_shgc(self) -> float:
297
+ """Calculate the effective SHGC considering shading."""
298
+ return self.shgc * self.shading_coefficient
299
+
300
+ def to_dict(self) -> Dict:
301
+ """Convert the fenestration to a dictionary."""
302
+ fenestration_dict = super().to_dict()
303
+ fenestration_dict.update({
304
+ "shgc": self.shgc,
305
+ "vt": self.vt,
306
+ "frame_type": self.frame_type,
307
+ "frame_width": self.frame_width,
308
+ "has_shading": self.has_shading,
309
+ "shading_type": self.shading_type,
310
+ "shading_coefficient": self.shading_coefficient,
311
+ "effective_shgc": self.effective_shgc
312
+ })
313
+ return fenestration_dict
314
+
315
+
316
+ @dataclass
317
+ class Window(Fenestration):
318
+ """Class representing a window component."""
319
+
320
+ window_type: str = "Custom" # Single, Double, Triple glazed, etc.
321
+ glazing_layers: int = 2 # Number of glazing layers
322
+ gas_fill: str = "Air" # Air, Argon, Krypton, etc.
323
+ low_e_coating: bool = False
324
+ width: float = 1.0 # m
325
+ height: float = 1.0 # m
326
+ wall_id: str = None # ID of the wall containing this window
327
+
328
+ def __post_init__(self):
329
+ """Initialize window-specific attributes."""
330
+ super().__post_init__()
331
+ self.component_type = ComponentType.WINDOW
332
+
333
+ # Calculate area from width and height if not provided
334
+ if self.area <= 0 and self.width > 0 and self.height > 0:
335
+ self.area = self.width * self.height
336
+
337
+ def to_dict(self) -> Dict:
338
+ """Convert the window to a dictionary."""
339
+ window_dict = super().to_dict()
340
+ window_dict.update({
341
+ "window_type": self.window_type,
342
+ "glazing_layers": self.glazing_layers,
343
+ "gas_fill": self.gas_fill,
344
+ "low_e_coating": self.low_e_coating,
345
+ "width": self.width,
346
+ "height": self.height,
347
+ "wall_id": self.wall_id
348
+ })
349
+ return window_dict
350
+
351
+
352
+ @dataclass
353
+ class Door(Fenestration):
354
+ """Class representing a door component."""
355
+
356
+ door_type: str = "Custom" # Solid, Partially glazed, etc.
357
+ glazing_percentage: float = 0.0 # Percentage of door area that is glazed (0-100)
358
+ width: float = 0.9 # m
359
+ height: float = 2.1 # m
360
+ wall_id: str = None # ID of the wall containing this door
361
+
362
+ def __post_init__(self):
363
+ """Initialize door-specific attributes."""
364
+ super().__post_init__()
365
+ self.component_type = ComponentType.DOOR
366
+
367
+ # Calculate area from width and height if not provided
368
+ if self.area <= 0 and self.width > 0 and self.height > 0:
369
+ self.area = self.width * self.height
370
+
371
+ if self.glazing_percentage < 0 or self.glazing_percentage > 100:
372
+ raise ValueError("Glazing percentage must be between 0 and 100")
373
+
374
+ @property
375
+ def glazing_area(self) -> float:
376
+ """Calculate the glazed area of the door in m²."""
377
+ return self.area * (self.glazing_percentage / 100)
378
+
379
+ @property
380
+ def opaque_area(self) -> float:
381
+ """Calculate the opaque area of the door in m²."""
382
+ return self.area - self.glazing_area
383
+
384
+ def to_dict(self) -> Dict:
385
+ """Convert the door to a dictionary."""
386
+ door_dict = super().to_dict()
387
+ door_dict.update({
388
+ "door_type": self.door_type,
389
+ "glazing_percentage": self.glazing_percentage,
390
+ "width": self.width,
391
+ "height": self.height,
392
+ "wall_id": self.wall_id,
393
+ "glazing_area": self.glazing_area,
394
+ "opaque_area": self.opaque_area
395
+ })
396
+ return door_dict
397
+
398
+
399
+ @dataclass
400
+ class Skylight(Fenestration):
401
+ """Class representing a skylight component."""
402
+
403
+ skylight_type: str = "Custom" # Flat, Domed, etc.
404
+ glazing_layers: int = 2 # Number of glazing layers
405
+ gas_fill: str = "Air" # Air, Argon, Krypton, etc.
406
+ low_e_coating: bool = False
407
+ width: float = 1.0 # m
408
+ length: float = 1.0 # m
409
+ roof_id: str = None # ID of the roof containing this skylight
410
+
411
+ def __post_init__(self):
412
+ """Initialize skylight-specific attributes."""
413
+ super().__post_init__()
414
+ self.component_type = ComponentType.SKYLIGHT
415
+ self.orientation = Orientation.HORIZONTAL
416
+
417
+ # Calculate area from width and length if not provided
418
+ if self.area <= 0 and self.width > 0 and self.length > 0:
419
+ self.area = self.width * self.length
420
+
421
+ def to_dict(self) -> Dict:
422
+ """Convert the skylight to a dictionary."""
423
+ skylight_dict = super().to_dict()
424
+ skylight_dict.update({
425
+ "skylight_type": self.skylight_type,
426
+ "glazing_layers": self.glazing_layers,
427
+ "gas_fill": self.gas_fill,
428
+ "low_e_coating": self.low_e_coating,
429
+ "width": self.width,
430
+ "length": self.length,
431
+ "roof_id": self.roof_id
432
+ })
433
+ return skylight_dict
434
+
435
+
436
+ class BuildingComponentFactory:
437
+ """Factory class for creating building components."""
438
+
439
+ @staticmethod
440
+ def create_component(component_data: Dict) -> BuildingComponent:
441
+ """
442
+ Create a building component from a dictionary of data.
443
+
444
+ Args:
445
+ component_data: Dictionary containing component data
446
+
447
+ Returns:
448
+ A BuildingComponent object of the appropriate type
449
+ """
450
+ component_type = component_data.get("component_type")
451
+
452
+ if component_type == ComponentType.WALL.value:
453
+ return Wall(**component_data)
454
+ elif component_type == ComponentType.ROOF.value:
455
+ return Roof(**component_data)
456
+ elif component_type == ComponentType.FLOOR.value:
457
+ return Floor(**component_data)
458
+ elif component_type == ComponentType.WINDOW.value:
459
+ return Window(**component_data)
460
+ elif component_type == ComponentType.DOOR.value:
461
+ return Door(**component_data)
462
+ elif component_type == ComponentType.SKYLIGHT.value:
463
+ return Skylight(**component_data)
464
+ else:
465
+ raise ValueError(f"Unknown component type: {component_type}")
data/climate_data.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ASHRAE 169 climate data module for HVAC Load Calculator.
3
+ This module provides access to climate data for various locations based on ASHRAE 169 standard.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ from dataclasses import dataclass
12
+
13
+ # Define paths
14
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
15
+
16
+
17
+ @dataclass
18
+ class ClimateLocation:
19
+ """Class representing a climate location with ASHRAE 169 data."""
20
+
21
+ id: str
22
+ country: str
23
+ state_province: str
24
+ city: str
25
+ latitude: float
26
+ longitude: float
27
+ elevation: float # meters
28
+ climate_zone: str
29
+ heating_degree_days: float # base 18°C
30
+ cooling_degree_days: float # base 18°C
31
+
32
+ # Design conditions
33
+ winter_design_temp: float # 99.6% heating design temperature (°C)
34
+ summer_design_temp_db: float # 0.4% cooling design dry-bulb temperature (°C)
35
+ summer_design_temp_wb: float # 0.4% cooling design wet-bulb temperature (°C)
36
+ summer_daily_range: float # Mean daily temperature range in summer (°C)
37
+
38
+ # Monthly data
39
+ monthly_temps: Dict[str, float] # Average monthly temperatures (°C)
40
+ monthly_humidity: Dict[str, float] # Average monthly relative humidity (%)
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ """Convert the climate location to a dictionary."""
44
+ return {
45
+ "id": self.id,
46
+ "country": self.country,
47
+ "state_province": self.state_province,
48
+ "city": self.city,
49
+ "latitude": self.latitude,
50
+ "longitude": self.longitude,
51
+ "elevation": self.elevation,
52
+ "climate_zone": self.climate_zone,
53
+ "heating_degree_days": self.heating_degree_days,
54
+ "cooling_degree_days": self.cooling_degree_days,
55
+ "winter_design_temp": self.winter_design_temp,
56
+ "summer_design_temp_db": self.summer_design_temp_db,
57
+ "summer_design_temp_wb": self.summer_design_temp_wb,
58
+ "summer_daily_range": self.summer_daily_range,
59
+ "monthly_temps": self.monthly_temps,
60
+ "monthly_humidity": self.monthly_humidity
61
+ }
62
+
63
+
64
+ class ClimateData:
65
+ """Class for managing ASHRAE 169 climate data."""
66
+
67
+ def __init__(self):
68
+ """Initialize climate data."""
69
+ self.locations = self._load_climate_locations()
70
+ self.countries = sorted(list(set(loc.country for loc in self.locations.values())))
71
+ self.country_states = self._group_locations_by_country_state()
72
+
73
+ def _load_climate_locations(self) -> Dict[str, ClimateLocation]:
74
+ """
75
+ Load climate location data.
76
+
77
+ Returns:
78
+ Dictionary of climate locations indexed by ID
79
+ """
80
+ # This would typically load from a JSON or CSV file with ASHRAE 169 data
81
+ # For now, we'll define some sample locations inline
82
+
83
+ # Sample monthly data (for all locations in this example)
84
+ months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
85
+
86
+ # New York monthly temperatures (°C)
87
+ ny_temps = {
88
+ "Jan": 0.5, "Feb": 2.1, "Mar": 6.3, "Apr": 12.5, "May": 18.2,
89
+ "Jun": 23.1, "Jul": 25.8, "Aug": 24.9, "Sep": 20.7, "Oct": 14.3,
90
+ "Nov": 8.2, "Dec": 2.4
91
+ }
92
+
93
+ # New York monthly humidity (%)
94
+ ny_humidity = {
95
+ "Jan": 65, "Feb": 62, "Mar": 58, "Apr": 55, "May": 60,
96
+ "Jun": 65, "Jul": 68, "Aug": 70, "Sep": 68, "Oct": 63,
97
+ "Nov": 67, "Dec": 68
98
+ }
99
+
100
+ # Los Angeles monthly temperatures (°C)
101
+ la_temps = {
102
+ "Jan": 14.6, "Feb": 15.1, "Mar": 15.8, "Apr": 17.1, "May": 18.3,
103
+ "Jun": 20.1, "Jul": 22.3, "Aug": 22.9, "Sep": 22.1, "Oct": 20.3,
104
+ "Nov": 17.2, "Dec": 14.9
105
+ }
106
+
107
+ # Los Angeles monthly humidity (%)
108
+ la_humidity = {
109
+ "Jan": 63, "Feb": 67, "Mar": 70, "Apr": 71, "May": 74,
110
+ "Jun": 75, "Jul": 76, "Aug": 76, "Sep": 74, "Oct": 70,
111
+ "Nov": 65, "Dec": 63
112
+ }
113
+
114
+ # Chicago monthly temperatures (°C)
115
+ chi_temps = {
116
+ "Jan": -3.5, "Feb": -1.2, "Mar": 4.1, "Apr": 10.3, "May": 16.5,
117
+ "Jun": 22.1, "Jul": 24.8, "Aug": 23.9, "Sep": 19.7, "Oct": 12.8,
118
+ "Nov": 5.2, "Dec": -1.4
119
+ }
120
+
121
+ # Chicago monthly humidity (%)
122
+ chi_humidity = {
123
+ "Jan": 72, "Feb": 70, "Mar": 65, "Apr": 60, "May": 64,
124
+ "Jun": 67, "Jul": 70, "Aug": 73, "Sep": 71, "Oct": 68,
125
+ "Nov": 72, "Dec": 75
126
+ }
127
+
128
+ # London monthly temperatures (°C)
129
+ lon_temps = {
130
+ "Jan": 5.2, "Feb": 5.5, "Mar": 7.4, "Apr": 9.9, "May": 13.3,
131
+ "Jun": 16.7, "Jul": 18.7, "Aug": 18.3, "Sep": 15.9, "Oct": 12.2,
132
+ "Nov": 8.3, "Dec": 5.9
133
+ }
134
+
135
+ # London monthly humidity (%)
136
+ lon_humidity = {
137
+ "Jan": 84, "Feb": 80, "Mar": 76, "Apr": 72, "May": 70,
138
+ "Jun": 70, "Jul": 71, "Aug": 72, "Sep": 75, "Oct": 80,
139
+ "Nov": 84, "Dec": 86
140
+ }
141
+
142
+ # Sydney monthly temperatures (°C)
143
+ syd_temps = {
144
+ "Jan": 23.5, "Feb": 23.4, "Mar": 22.1, "Apr": 19.5, "May": 16.5,
145
+ "Jun": 14.1, "Jul": 13.4, "Aug": 14.5, "Sep": 16.6, "Oct": 18.8,
146
+ "Nov": 20.6, "Dec": 22.6
147
+ }
148
+
149
+ # Sydney monthly humidity (%)
150
+ syd_humidity = {
151
+ "Jan": 65, "Feb": 68, "Mar": 68, "Apr": 67, "May": 70,
152
+ "Jun": 70, "Jul": 68, "Aug": 63, "Sep": 60, "Oct": 60,
153
+ "Nov": 62, "Dec": 63
154
+ }
155
+
156
+ # Create sample locations
157
+ locations = {
158
+ "US-NY-NYC": ClimateLocation(
159
+ id="US-NY-NYC",
160
+ country="United States",
161
+ state_province="New York",
162
+ city="New York",
163
+ latitude=40.7128,
164
+ longitude=-74.0060,
165
+ elevation=10.0,
166
+ climate_zone="4A",
167
+ heating_degree_days=2600,
168
+ cooling_degree_days=1200,
169
+ winter_design_temp=-8.3,
170
+ summer_design_temp_db=32.8,
171
+ summer_design_temp_wb=25.6,
172
+ summer_daily_range=8.3,
173
+ monthly_temps=ny_temps,
174
+ monthly_humidity=ny_humidity
175
+ ),
176
+ "US-CA-LAX": ClimateLocation(
177
+ id="US-CA-LAX",
178
+ country="United States",
179
+ state_province="California",
180
+ city="Los Angeles",
181
+ latitude=34.0522,
182
+ longitude=-118.2437,
183
+ elevation=93.0,
184
+ climate_zone="3B",
185
+ heating_degree_days=800,
186
+ cooling_degree_days=1200,
187
+ winter_design_temp=8.3,
188
+ summer_design_temp_db=32.2,
189
+ summer_design_temp_wb=23.3,
190
+ summer_daily_range=6.7,
191
+ monthly_temps=la_temps,
192
+ monthly_humidity=la_humidity
193
+ ),
194
+ "US-IL-CHI": ClimateLocation(
195
+ id="US-IL-CHI",
196
+ country="United States",
197
+ state_province="Illinois",
198
+ city="Chicago",
199
+ latitude=41.8781,
200
+ longitude=-87.6298,
201
+ elevation=179.0,
202
+ climate_zone="5A",
203
+ heating_degree_days=3500,
204
+ cooling_degree_days=1000,
205
+ winter_design_temp=-16.7,
206
+ summer_design_temp_db=33.3,
207
+ summer_design_temp_wb=25.6,
208
+ summer_daily_range=8.9,
209
+ monthly_temps=chi_temps,
210
+ monthly_humidity=chi_humidity
211
+ ),
212
+ "UK-LDN": ClimateLocation(
213
+ id="UK-LDN",
214
+ country="United Kingdom",
215
+ state_province="England",
216
+ city="London",
217
+ latitude=51.5074,
218
+ longitude=-0.1278,
219
+ elevation=35.0,
220
+ climate_zone="4A",
221
+ heating_degree_days=2500,
222
+ cooling_degree_days=200,
223
+ winter_design_temp=-3.9,
224
+ summer_design_temp_db=28.3,
225
+ summer_design_temp_wb=20.0,
226
+ summer_daily_range=10.0,
227
+ monthly_temps=lon_temps,
228
+ monthly_humidity=lon_humidity
229
+ ),
230
+ "AU-NSW-SYD": ClimateLocation(
231
+ id="AU-NSW-SYD",
232
+ country="Australia",
233
+ state_province="New South Wales",
234
+ city="Sydney",
235
+ latitude=-33.8688,
236
+ longitude=151.2093,
237
+ elevation=3.0,
238
+ climate_zone="3C",
239
+ heating_degree_days=600,
240
+ cooling_degree_days=900,
241
+ winter_design_temp=7.2,
242
+ summer_design_temp_db=31.1,
243
+ summer_design_temp_wb=24.4,
244
+ summer_daily_range=7.8,
245
+ monthly_temps=syd_temps,
246
+ monthly_humidity=syd_humidity
247
+ )
248
+ }
249
+
250
+ return locations
251
+
252
+ def _group_locations_by_country_state(self) -> Dict[str, Dict[str, List[str]]]:
253
+ """
254
+ Group locations by country and state/province.
255
+
256
+ Returns:
257
+ Nested dictionary of countries, states, and cities
258
+ """
259
+ result = {}
260
+
261
+ for loc in self.locations.values():
262
+ if loc.country not in result:
263
+ result[loc.country] = {}
264
+
265
+ if loc.state_province not in result[loc.country]:
266
+ result[loc.country][loc.state_province] = []
267
+
268
+ result[loc.country][loc.state_province].append(loc.city)
269
+
270
+ # Sort states and cities
271
+ for country in result:
272
+ for state in result[country]:
273
+ result[country][state] = sorted(result[country][state])
274
+
275
+ return result
276
+
277
+ def get_location(self, location_id: str) -> Optional[ClimateLocation]:
278
+ """
279
+ Get climate location by ID.
280
+
281
+ Args:
282
+ location_id: Location identifier
283
+
284
+ Returns:
285
+ ClimateLocation object or None if not found
286
+ """
287
+ return self.locations.get(location_id)
288
+
289
+ def find_location(self, country: str, state_province: str = None, city: str = None) -> Optional[ClimateLocation]:
290
+ """
291
+ Find a climate location by country, state/province, and city.
292
+
293
+ Args:
294
+ country: Country name
295
+ state_province: State or province name (optional)
296
+ city: City name (optional)
297
+
298
+ Returns:
299
+ ClimateLocation object or None if not found
300
+ """
301
+ for loc in self.locations.values():
302
+ if loc.country == country:
303
+ if state_province is None or loc.state_province == state_province:
304
+ if city is None or loc.city == city:
305
+ return loc
306
+ return None
307
+
308
+ def find_locations_by_climate_zone(self, climate_zone: str) -> List[ClimateLocation]:
309
+ """
310
+ Find climate locations by climate zone.
311
+
312
+ Args:
313
+ climate_zone: ASHRAE climate zone
314
+
315
+ Returns:
316
+ List of ClimateLocation objects
317
+ """
318
+ return [loc for loc in self.locations.values() if loc.climate_zone == climate_zone]
319
+
320
+ def get_states_for_country(self, country: str) -> List[str]:
321
+ """
322
+ Get states/provinces for a country.
323
+
324
+ Args:
325
+ country: Country name
326
+
327
+ Returns:
328
+ List of state/province names
329
+ """
330
+ if country in self.country_states:
331
+ return sorted(self.country_states[country].keys())
332
+ return []
333
+
334
+ def get_cities_for_state(self, country: str, state_province: str) -> List[str]:
335
+ """
336
+ Get cities for a state/province.
337
+
338
+ Args:
339
+ country: Country name
340
+ state_province: State or province name
341
+
342
+ Returns:
343
+ List of city names
344
+ """
345
+ if country in self.country_states and state_province in self.country_states[country]:
346
+ return self.country_states[country][state_province]
347
+ return []
348
+
349
+ def get_location_id(self, country: str, state_province: str, city: str) -> Optional[str]:
350
+ """
351
+ Get location ID for a city.
352
+
353
+ Args:
354
+ country: Country name
355
+ state_province: State or province name
356
+ city: City name
357
+
358
+ Returns:
359
+ Location ID or None if not found
360
+ """
361
+ for loc_id, loc in self.locations.items():
362
+ if (loc.country == country and
363
+ loc.state_province == state_province and
364
+ loc.city == city):
365
+ return loc_id
366
+ return None
367
+
368
+ def export_to_json(self, file_path: str) -> None:
369
+ """
370
+ Export all climate data to a JSON file.
371
+
372
+ Args:
373
+ file_path: Path to the output JSON file
374
+ """
375
+ data = {loc_id: loc.to_dict() for loc_id, loc in self.locations.items()}
376
+
377
+ with open(file_path, 'w') as f:
378
+ json.dump(data, f, indent=4)
379
+
380
+ @classmethod
381
+ def from_json(cls, file_path: str) -> 'ClimateData':
382
+ """
383
+ Create a ClimateData instance from a JSON file.
384
+
385
+ Args:
386
+ file_path: Path to the input JSON file
387
+
388
+ Returns:
389
+ A new ClimateData instance
390
+ """
391
+ with open(file_path, 'r') as f:
392
+ data = json.load(f)
393
+
394
+ climate_data = cls()
395
+ climate_data.locations = {}
396
+
397
+ for loc_id, loc_dict in data.items():
398
+ climate_data.locations[loc_id] = ClimateLocation(
399
+ id=loc_dict["id"],
400
+ country=loc_dict["country"],
401
+ state_province=loc_dict["state_province"],
402
+ city=loc_dict["city"],
403
+ latitude=loc_dict["latitude"],
404
+ longitude=loc_dict["longitude"],
405
+ elevation=loc_dict["elevation"],
406
+ climate_zone=loc_dict["climate_zone"],
407
+ heating_degree_days=loc_dict["heating_degree_days"],
408
+ cooling_degree_days=loc_dict["cooling_degree_days"],
409
+ winter_design_temp=loc_dict["winter_design_temp"],
410
+ summer_design_temp_db=loc_dict["summer_design_temp_db"],
411
+ summer_design_temp_wb=loc_dict["summer_design_temp_wb"],
412
+ summer_daily_range=loc_dict["summer_daily_range"],
413
+ monthly_temps=loc_dict["monthly_temps"],
414
+ monthly_humidity=loc_dict["monthly_humidity"]
415
+ )
416
+
417
+ climate_data.countries = sorted(list(set(loc.country for loc in climate_data.locations.values())))
418
+ climate_data.country_states = climate_data._group_locations_by_country_state()
419
+
420
+ return climate_data
421
+
422
+
423
+ # Create a singleton instance
424
+ climate_data = ClimateData()
425
+
426
+ # Export climate data to JSON if needed
427
+ if __name__ == "__main__":
428
+ climate_data.export_to_json(os.path.join(DATA_DIR, "climate_data.json"))
data/reference_data.py ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reference data structures for HVAC Load Calculator.
3
+ This module contains reference data for materials, construction types, and other HVAC-related data.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional
7
+ import pandas as pd
8
+ import json
9
+ import os
10
+
11
+ # Define paths
12
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
13
+
14
+
15
+ class ReferenceData:
16
+ """Class for managing reference data for the HVAC calculator."""
17
+
18
+ def __init__(self):
19
+ """Initialize reference data structures."""
20
+ self.materials = self._load_materials()
21
+ self.wall_types = self._load_wall_types()
22
+ self.roof_types = self._load_roof_types()
23
+ self.floor_types = self._load_floor_types()
24
+ self.window_types = self._load_window_types()
25
+ self.door_types = self._load_door_types()
26
+ self.internal_loads = self._load_internal_loads()
27
+
28
+ def _load_materials(self) -> Dict[str, Dict[str, Any]]:
29
+ """
30
+ Load material properties from reference data.
31
+
32
+ Returns:
33
+ Dictionary of material properties
34
+ """
35
+ # This would typically load from a JSON or CSV file
36
+ # For now, we'll define some common materials inline
37
+ return {
38
+ "brick": {
39
+ "name": "Common Brick",
40
+ "conductivity": 0.72, # W/(m·K)
41
+ "density": 1920, # kg/m³
42
+ "specific_heat": 840, # J/(kg·K)
43
+ "typical_thickness": 0.1 # m
44
+ },
45
+ "concrete": {
46
+ "name": "Concrete",
47
+ "conductivity": 1.4, # W/(m·K)
48
+ "density": 2300, # kg/m³
49
+ "specific_heat": 880, # J/(kg·K)
50
+ "typical_thickness": 0.2 # m
51
+ },
52
+ "mineral_wool": {
53
+ "name": "Mineral Wool Insulation",
54
+ "conductivity": 0.04, # W/(m·K)
55
+ "density": 30, # kg/m³
56
+ "specific_heat": 840, # J/(kg·K)
57
+ "typical_thickness": 0.1 # m
58
+ },
59
+ "eps_insulation": {
60
+ "name": "EPS Insulation",
61
+ "conductivity": 0.035, # W/(m·K)
62
+ "density": 25, # kg/m³
63
+ "specific_heat": 1400, # J/(kg·K)
64
+ "typical_thickness": 0.1 # m
65
+ },
66
+ "gypsum_board": {
67
+ "name": "Gypsum Board",
68
+ "conductivity": 0.25, # W/(m·K)
69
+ "density": 900, # kg/m³
70
+ "specific_heat": 1000, # J/(kg·K)
71
+ "typical_thickness": 0.0125 # m
72
+ },
73
+ "wood": {
74
+ "name": "Wood (Pine)",
75
+ "conductivity": 0.14, # W/(m·K)
76
+ "density": 500, # kg/m³
77
+ "specific_heat": 1600, # J/(kg·K)
78
+ "typical_thickness": 0.025 # m
79
+ },
80
+ "steel": {
81
+ "name": "Steel",
82
+ "conductivity": 50, # W/(m·K)
83
+ "density": 7800, # kg/m³
84
+ "specific_heat": 450, # J/(kg·K)
85
+ "typical_thickness": 0.005 # m
86
+ },
87
+ "glass": {
88
+ "name": "Glass",
89
+ "conductivity": 1.0, # W/(m·K)
90
+ "density": 2500, # kg/m³
91
+ "specific_heat": 840, # J/(kg·K)
92
+ "typical_thickness": 0.006 # m
93
+ },
94
+ "air_gap": {
95
+ "name": "Air Gap",
96
+ "conductivity": 0.024, # W/(m·K)
97
+ "density": 1.2, # kg/m³
98
+ "specific_heat": 1000, # J/(kg·K)
99
+ "typical_thickness": 0.025 # m
100
+ },
101
+ "concrete_block": {
102
+ "name": "Concrete Block",
103
+ "conductivity": 0.51, # W/(m·K)
104
+ "density": 1400, # kg/m³
105
+ "specific_heat": 1000, # J/(kg·K)
106
+ "typical_thickness": 0.2 # m
107
+ },
108
+ "asphalt_shingle": {
109
+ "name": "Asphalt Shingle",
110
+ "conductivity": 0.7, # W/(m·K)
111
+ "density": 1100, # kg/m³
112
+ "specific_heat": 1260, # J/(kg·K)
113
+ "typical_thickness": 0.006 # m
114
+ },
115
+ "carpet": {
116
+ "name": "Carpet",
117
+ "conductivity": 0.06, # W/(m·K)
118
+ "density": 200, # kg/m³
119
+ "specific_heat": 1300, # J/(kg·K)
120
+ "typical_thickness": 0.01 # m
121
+ },
122
+ "vinyl_flooring": {
123
+ "name": "Vinyl Flooring",
124
+ "conductivity": 0.17, # W/(m·K)
125
+ "density": 1200, # kg/m³
126
+ "specific_heat": 1460, # J/(kg·K)
127
+ "typical_thickness": 0.003 # m
128
+ }
129
+ }
130
+
131
+ def _load_wall_types(self) -> Dict[str, Dict[str, Any]]:
132
+ """
133
+ Load predefined wall types from reference data.
134
+
135
+ Returns:
136
+ Dictionary of wall types with properties
137
+ """
138
+ return {
139
+ "brick_veneer_wood_frame": {
140
+ "name": "Brick veneer with wood frame",
141
+ "description": "Brick veneer with wood frame, insulation, and gypsum board",
142
+ "u_value": 0.35, # W/(m²·K)
143
+ "wall_group": "B", # ASHRAE wall group
144
+ "layers": [
145
+ {"material": "brick", "thickness": 0.1},
146
+ {"material": "air_gap", "thickness": 0.025},
147
+ {"material": "wood", "thickness": 0.038},
148
+ {"material": "mineral_wool", "thickness": 0.089},
149
+ {"material": "gypsum_board", "thickness": 0.0125}
150
+ ]
151
+ },
152
+ "concrete_block_insulated": {
153
+ "name": "Concrete block with interior insulation",
154
+ "description": "Concrete block wall with interior insulation and gypsum board",
155
+ "u_value": 0.48, # W/(m²·K)
156
+ "wall_group": "C", # ASHRAE wall group
157
+ "layers": [
158
+ {"material": "concrete_block", "thickness": 0.2},
159
+ {"material": "eps_insulation", "thickness": 0.05},
160
+ {"material": "gypsum_board", "thickness": 0.0125}
161
+ ]
162
+ },
163
+ "precast_concrete_panel": {
164
+ "name": "Precast concrete panel",
165
+ "description": "Precast concrete panel with insulation and gypsum board",
166
+ "u_value": 0.45, # W/(m²·K)
167
+ "wall_group": "D", # ASHRAE wall group
168
+ "layers": [
169
+ {"material": "concrete", "thickness": 0.1},
170
+ {"material": "eps_insulation", "thickness": 0.075},
171
+ {"material": "gypsum_board", "thickness": 0.0125}
172
+ ]
173
+ },
174
+ "metal_panel_insulated": {
175
+ "name": "Metal panel with insulation",
176
+ "description": "Metal panel wall with insulation and interior finish",
177
+ "u_value": 0.4, # W/(m²·K)
178
+ "wall_group": "E", # ASHRAE wall group
179
+ "layers": [
180
+ {"material": "steel", "thickness": 0.001},
181
+ {"material": "mineral_wool", "thickness": 0.1},
182
+ {"material": "gypsum_board", "thickness": 0.0125}
183
+ ]
184
+ },
185
+ "wood_frame_wall": {
186
+ "name": "Wood frame wall",
187
+ "description": "Wood frame wall with insulation and gypsum board",
188
+ "u_value": 0.3, # W/(m²·K)
189
+ "wall_group": "A", # ASHRAE wall group
190
+ "layers": [
191
+ {"material": "wood", "thickness": 0.019},
192
+ {"material": "air_gap", "thickness": 0.025},
193
+ {"material": "wood", "thickness": 0.038},
194
+ {"material": "mineral_wool", "thickness": 0.14},
195
+ {"material": "gypsum_board", "thickness": 0.0125}
196
+ ]
197
+ }
198
+ }
199
+
200
+ def _load_roof_types(self) -> Dict[str, Dict[str, Any]]:
201
+ """
202
+ Load predefined roof types from reference data.
203
+
204
+ Returns:
205
+ Dictionary of roof types with properties
206
+ """
207
+ return {
208
+ "flat_roof_concrete": {
209
+ "name": "Flat concrete roof with insulation",
210
+ "description": "Flat concrete roof with insulation and ceiling",
211
+ "u_value": 0.25, # W/(m²·K)
212
+ "roof_group": "B", # ASHRAE roof group
213
+ "layers": [
214
+ {"material": "concrete", "thickness": 0.15},
215
+ {"material": "eps_insulation", "thickness": 0.15},
216
+ {"material": "gypsum_board", "thickness": 0.0125}
217
+ ]
218
+ },
219
+ "pitched_roof_wood": {
220
+ "name": "Pitched wood roof with insulation",
221
+ "description": "Pitched wood roof with insulation and ceiling",
222
+ "u_value": 0.2, # W/(m²·K)
223
+ "roof_group": "A", # ASHRAE roof group
224
+ "layers": [
225
+ {"material": "asphalt_shingle", "thickness": 0.006},
226
+ {"material": "wood", "thickness": 0.019},
227
+ {"material": "air_gap", "thickness": 0.025},
228
+ {"material": "mineral_wool", "thickness": 0.2},
229
+ {"material": "gypsum_board", "thickness": 0.0125}
230
+ ]
231
+ },
232
+ "metal_deck_roof": {
233
+ "name": "Metal deck roof with insulation",
234
+ "description": "Metal deck roof with insulation and ceiling",
235
+ "u_value": 0.3, # W/(m²·K)
236
+ "roof_group": "C", # ASHRAE roof group
237
+ "layers": [
238
+ {"material": "steel", "thickness": 0.001},
239
+ {"material": "eps_insulation", "thickness": 0.1},
240
+ {"material": "air_gap", "thickness": 0.1},
241
+ {"material": "gypsum_board", "thickness": 0.0125}
242
+ ]
243
+ }
244
+ }
245
+
246
+ def _load_floor_types(self) -> Dict[str, Dict[str, Any]]:
247
+ """
248
+ Load predefined floor types from reference data.
249
+
250
+ Returns:
251
+ Dictionary of floor types with properties
252
+ """
253
+ return {
254
+ "concrete_slab_on_grade": {
255
+ "name": "Concrete slab on grade",
256
+ "description": "Concrete slab on grade with insulation",
257
+ "u_value": 0.3, # W/(m²·K)
258
+ "is_ground_contact": True,
259
+ "layers": [
260
+ {"material": "concrete", "thickness": 0.1},
261
+ {"material": "eps_insulation", "thickness": 0.05}
262
+ ]
263
+ },
264
+ "suspended_concrete_floor": {
265
+ "name": "Suspended concrete floor",
266
+ "description": "Suspended concrete floor with insulation",
267
+ "u_value": 0.25, # W/(m²·K)
268
+ "is_ground_contact": False,
269
+ "layers": [
270
+ {"material": "concrete", "thickness": 0.15},
271
+ {"material": "eps_insulation", "thickness": 0.1},
272
+ {"material": "gypsum_board", "thickness": 0.0125}
273
+ ]
274
+ },
275
+ "wood_joist_floor": {
276
+ "name": "Wood joist floor",
277
+ "description": "Wood joist floor with insulation",
278
+ "u_value": 0.22, # W/(m²·K)
279
+ "is_ground_contact": False,
280
+ "layers": [
281
+ {"material": "wood", "thickness": 0.025},
282
+ {"material": "air_gap", "thickness": 0.15},
283
+ {"material": "mineral_wool", "thickness": 0.15},
284
+ {"material": "gypsum_board", "thickness": 0.0125}
285
+ ]
286
+ },
287
+ "carpet_on_concrete": {
288
+ "name": "Carpet on concrete",
289
+ "description": "Carpet on concrete slab with insulation",
290
+ "u_value": 0.28, # W/(m²·K)
291
+ "is_ground_contact": True,
292
+ "layers": [
293
+ {"material": "carpet", "thickness": 0.01},
294
+ {"material": "concrete", "thickness": 0.1},
295
+ {"material": "eps_insulation", "thickness": 0.05}
296
+ ]
297
+ }
298
+ }
299
+
300
+ def _load_window_types(self) -> Dict[str, Dict[str, Any]]:
301
+ """
302
+ Load predefined window types from reference data.
303
+
304
+ Returns:
305
+ Dictionary of window types with properties
306
+ """
307
+ return {
308
+ "single_glazed": {
309
+ "name": "Single glazed window",
310
+ "description": "Single glazed window with aluminum frame",
311
+ "u_value": 5.8, # W/(m²·K)
312
+ "shgc": 0.86, # Solar Heat Gain Coefficient
313
+ "vt": 0.9, # Visible Transmittance
314
+ "glazing_layers": 1,
315
+ "gas_fill": "Air",
316
+ "frame_type": "Aluminum",
317
+ "low_e_coating": False
318
+ },
319
+ "double_glazed_air": {
320
+ "name": "Double glazed window with air",
321
+ "description": "Double glazed window with air gap and aluminum frame",
322
+ "u_value": 2.8, # W/(m²·K)
323
+ "shgc": 0.76, # Solar Heat Gain Coefficient
324
+ "vt": 0.81, # Visible Transmittance
325
+ "glazing_layers": 2,
326
+ "gas_fill": "Air",
327
+ "frame_type": "Aluminum",
328
+ "low_e_coating": False
329
+ },
330
+ "double_glazed_argon_low_e": {
331
+ "name": "Double glazed window with argon and low-e coating",
332
+ "description": "Double glazed window with argon fill, low-e coating, and vinyl frame",
333
+ "u_value": 1.4, # W/(m²·K)
334
+ "shgc": 0.4, # Solar Heat Gain Coefficient
335
+ "vt": 0.7, # Visible Transmittance
336
+ "glazing_layers": 2,
337
+ "gas_fill": "Argon",
338
+ "frame_type": "Vinyl",
339
+ "low_e_coating": True
340
+ },
341
+ "triple_glazed_argon_low_e": {
342
+ "name": "Triple glazed window with argon and low-e coating",
343
+ "description": "Triple glazed window with argon fill, low-e coating, and vinyl frame",
344
+ "u_value": 0.8, # W/(m²·K)
345
+ "shgc": 0.3, # Solar Heat Gain Coefficient
346
+ "vt": 0.6, # Visible Transmittance
347
+ "glazing_layers": 3,
348
+ "gas_fill": "Argon",
349
+ "frame_type": "Vinyl",
350
+ "low_e_coating": True
351
+ }
352
+ }
353
+
354
+ def _load_door_types(self) -> Dict[str, Dict[str, Any]]:
355
+ """
356
+ Load predefined door types from reference data.
357
+
358
+ Returns:
359
+ Dictionary of door types with properties
360
+ """
361
+ return {
362
+ "solid_wood_door": {
363
+ "name": "Solid wood door",
364
+ "description": "Solid wood door with no glazing",
365
+ "u_value": 2.2, # W/(m²·K)
366
+ "glazing_percentage": 0,
367
+ "door_type": "Solid"
368
+ },
369
+ "insulated_steel_door": {
370
+ "name": "Insulated steel door",
371
+ "description": "Insulated steel door with no glazing",
372
+ "u_value": 1.2, # W/(m²·K)
373
+ "glazing_percentage": 0,
374
+ "door_type": "Solid"
375
+ },
376
+ "partially_glazed_door": {
377
+ "name": "Partially glazed door",
378
+ "description": "Wood door with 25% glazing",
379
+ "u_value": 2.8, # W/(m²·K)
380
+ "glazing_percentage": 25,
381
+ "door_type": "Partially glazed",
382
+ "shgc": 0.76, # Solar Heat Gain Coefficient for the glazed portion
383
+ "vt": 0.81 # Visible Transmittance for the glazed portion
384
+ },
385
+ "glass_door": {
386
+ "name": "Glass door",
387
+ "description": "Full glass door with aluminum frame",
388
+ "u_value": 3.8, # W/(m²·K)
389
+ "glazing_percentage": 90,
390
+ "door_type": "Glass",
391
+ "shgc": 0.76, # Solar Heat Gain Coefficient
392
+ "vt": 0.81 # Visible Transmittance
393
+ }
394
+ }
395
+
396
+ def _load_internal_loads(self) -> Dict[str, Dict[str, Any]]:
397
+ """
398
+ Load internal load data from reference data.
399
+
400
+ Returns:
401
+ Dictionary of internal load types with properties
402
+ """
403
+ return {
404
+ "occupancy": {
405
+ "seated_very_light_work": {
406
+ "name": "Seated, very light work",
407
+ "sensible_heat": 70, # W per person
408
+ "latent_heat": 45 # W per person
409
+ },
410
+ "office_work_standing": {
411
+ "name": "Office work, standing",
412
+ "sensible_heat": 75, # W per person
413
+ "latent_heat": 55 # W per person
414
+ },
415
+ "light_physical_work": {
416
+ "name": "Light physical work",
417
+ "sensible_heat": 80, # W per person
418
+ "latent_heat": 140 # W per person
419
+ },
420
+ "medium_physical_work": {
421
+ "name": "Medium physical work",
422
+ "sensible_heat": 90, # W per person
423
+ "latent_heat": 185 # W per person
424
+ },
425
+ "heavy_physical_work": {
426
+ "name": "Heavy physical work",
427
+ "sensible_heat": 170, # W per person
428
+ "latent_heat": 255 # W per person
429
+ }
430
+ },
431
+ "lighting": {
432
+ "led": {
433
+ "name": "LED",
434
+ "power_density_range": [5, 10], # W/m²
435
+ "heat_to_space": 0.9 # Fraction of power that becomes heat
436
+ },
437
+ "fluorescent": {
438
+ "name": "Fluorescent",
439
+ "power_density_range": [10, 15], # W/m²
440
+ "heat_to_space": 0.95 # Fraction of power that becomes heat
441
+ },
442
+ "incandescent": {
443
+ "name": "Incandescent",
444
+ "power_density_range": [15, 25], # W/m²
445
+ "heat_to_space": 0.98 # Fraction of power that becomes heat
446
+ },
447
+ "halogen": {
448
+ "name": "Halogen",
449
+ "power_density_range": [12, 20], # W/m²
450
+ "heat_to_space": 0.97 # Fraction of power that becomes heat
451
+ }
452
+ },
453
+ "equipment": {
454
+ "office_equipment": {
455
+ "name": "Office Equipment",
456
+ "power_density_range": [10, 20], # W/m²
457
+ "sensible_fraction": 0.9,
458
+ "latent_fraction": 0.1
459
+ },
460
+ "kitchen_equipment": {
461
+ "name": "Kitchen Equipment",
462
+ "power_density_range": [30, 200], # W/m²
463
+ "sensible_fraction": 0.6,
464
+ "latent_fraction": 0.4
465
+ },
466
+ "manufacturing_equipment": {
467
+ "name": "Manufacturing Equipment",
468
+ "power_density_range": [20, 100], # W/m²
469
+ "sensible_fraction": 0.8,
470
+ "latent_fraction": 0.2
471
+ }
472
+ }
473
+ }
474
+
475
+ def get_material(self, material_id: str) -> Optional[Dict[str, Any]]:
476
+ """
477
+ Get material properties by ID.
478
+
479
+ Args:
480
+ material_id: Material identifier
481
+
482
+ Returns:
483
+ Dictionary of material properties or None if not found
484
+ """
485
+ return self.materials.get(material_id)
486
+
487
+ def get_wall_type(self, wall_type_id: str) -> Optional[Dict[str, Any]]:
488
+ """
489
+ Get wall type properties by ID.
490
+
491
+ Args:
492
+ wall_type_id: Wall type identifier
493
+
494
+ Returns:
495
+ Dictionary of wall type properties or None if not found
496
+ """
497
+ return self.wall_types.get(wall_type_id)
498
+
499
+ def get_roof_type(self, roof_type_id: str) -> Optional[Dict[str, Any]]:
500
+ """
501
+ Get roof type properties by ID.
502
+
503
+ Args:
504
+ roof_type_id: Roof type identifier
505
+
506
+ Returns:
507
+ Dictionary of roof type properties or None if not found
508
+ """
509
+ return self.roof_types.get(roof_type_id)
510
+
511
+ def get_floor_type(self, floor_type_id: str) -> Optional[Dict[str, Any]]:
512
+ """
513
+ Get floor type properties by ID.
514
+
515
+ Args:
516
+ floor_type_id: Floor type identifier
517
+
518
+ Returns:
519
+ Dictionary of floor type properties or None if not found
520
+ """
521
+ return self.floor_types.get(floor_type_id)
522
+
523
+ def get_window_type(self, window_type_id: str) -> Optional[Dict[str, Any]]:
524
+ """
525
+ Get window type properties by ID.
526
+
527
+ Args:
528
+ window_type_id: Window type identifier
529
+
530
+ Returns:
531
+ Dictionary of window type properties or None if not found
532
+ """
533
+ return self.window_types.get(window_type_id)
534
+
535
+ def get_door_type(self, door_type_id: str) -> Optional[Dict[str, Any]]:
536
+ """
537
+ Get door type properties by ID.
538
+
539
+ Args:
540
+ door_type_id: Door type identifier
541
+
542
+ Returns:
543
+ Dictionary of door type properties or None if not found
544
+ """
545
+ return self.door_types.get(door_type_id)
546
+
547
+ def get_internal_load(self, load_type: str, load_id: str) -> Optional[Dict[str, Any]]:
548
+ """
549
+ Get internal load properties by type and ID.
550
+
551
+ Args:
552
+ load_type: Type of internal load (occupancy, lighting, equipment)
553
+ load_id: Internal load identifier
554
+
555
+ Returns:
556
+ Dictionary of internal load properties or None if not found
557
+ """
558
+ if load_type in self.internal_loads:
559
+ return self.internal_loads[load_type].get(load_id)
560
+ return None
561
+
562
+ def export_to_json(self, file_path: str) -> None:
563
+ """
564
+ Export all reference data to a JSON file.
565
+
566
+ Args:
567
+ file_path: Path to the output JSON file
568
+ """
569
+ data = {
570
+ "materials": self.materials,
571
+ "wall_types": self.wall_types,
572
+ "roof_types": self.roof_types,
573
+ "floor_types": self.floor_types,
574
+ "window_types": self.window_types,
575
+ "door_types": self.door_types,
576
+ "internal_loads": self.internal_loads
577
+ }
578
+
579
+ with open(file_path, 'w') as f:
580
+ json.dump(data, f, indent=4)
581
+
582
+ @classmethod
583
+ def from_json(cls, file_path: str) -> 'ReferenceData':
584
+ """
585
+ Create a ReferenceData instance from a JSON file.
586
+
587
+ Args:
588
+ file_path: Path to the input JSON file
589
+
590
+ Returns:
591
+ A new ReferenceData instance
592
+ """
593
+ with open(file_path, 'r') as f:
594
+ data = json.load(f)
595
+
596
+ ref_data = cls()
597
+ ref_data.materials = data.get("materials", ref_data.materials)
598
+ ref_data.wall_types = data.get("wall_types", ref_data.wall_types)
599
+ ref_data.roof_types = data.get("roof_types", ref_data.roof_types)
600
+ ref_data.floor_types = data.get("floor_types", ref_data.floor_types)
601
+ ref_data.window_types = data.get("window_types", ref_data.window_types)
602
+ ref_data.door_types = data.get("door_types", ref_data.door_types)
603
+ ref_data.internal_loads = data.get("internal_loads", ref_data.internal_loads)
604
+
605
+ return ref_data
606
+
607
+
608
+ # Create a singleton instance
609
+ reference_data = ReferenceData()
610
+
611
+ # Export reference data to JSON if needed
612
+ if __name__ == "__main__":
613
+ reference_data.export_to_json(os.path.join(DATA_DIR, "reference_data.json"))
utils/area_calculation_system.py ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Area calculation system module for HVAC Load Calculator.
3
+ This module implements net wall area calculation and area validation functions.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ from dataclasses import dataclass, field
12
+
13
+ # Import data models
14
+ from data.building_components import Wall, Window, Door, Orientation, ComponentType
15
+
16
+ # Define paths
17
+ DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+
19
+
20
+ class AreaCalculationSystem:
21
+ """Class for managing area calculations and validations."""
22
+
23
+ def __init__(self):
24
+ """Initialize area calculation system."""
25
+ self.walls = {}
26
+ self.windows = {}
27
+ self.doors = {}
28
+
29
+ def add_wall(self, wall: Wall) -> None:
30
+ """
31
+ Add a wall to the area calculation system.
32
+
33
+ Args:
34
+ wall: Wall object
35
+ """
36
+ self.walls[wall.id] = wall
37
+
38
+ def add_window(self, window: Window) -> None:
39
+ """
40
+ Add a window to the area calculation system.
41
+
42
+ Args:
43
+ window: Window object
44
+ """
45
+ self.windows[window.id] = window
46
+
47
+ def add_door(self, door: Door) -> None:
48
+ """
49
+ Add a door to the area calculation system.
50
+
51
+ Args:
52
+ door: Door object
53
+ """
54
+ self.doors[door.id] = door
55
+
56
+ def remove_wall(self, wall_id: str) -> bool:
57
+ """
58
+ Remove a wall from the area calculation system.
59
+
60
+ Args:
61
+ wall_id: Wall identifier
62
+
63
+ Returns:
64
+ True if the wall was removed, False otherwise
65
+ """
66
+ if wall_id not in self.walls:
67
+ return False
68
+
69
+ # Remove all windows and doors associated with this wall
70
+ for window_id, window in list(self.windows.items()):
71
+ if window.wall_id == wall_id:
72
+ del self.windows[window_id]
73
+
74
+ for door_id, door in list(self.doors.items()):
75
+ if door.wall_id == wall_id:
76
+ del self.doors[door_id]
77
+
78
+ del self.walls[wall_id]
79
+ return True
80
+
81
+ def remove_window(self, window_id: str) -> bool:
82
+ """
83
+ Remove a window from the area calculation system.
84
+
85
+ Args:
86
+ window_id: Window identifier
87
+
88
+ Returns:
89
+ True if the window was removed, False otherwise
90
+ """
91
+ if window_id not in self.windows:
92
+ return False
93
+
94
+ # Remove window from associated wall
95
+ window = self.windows[window_id]
96
+ if window.wall_id and window.wall_id in self.walls:
97
+ wall = self.walls[window.wall_id]
98
+ if window_id in wall.windows:
99
+ wall.windows.remove(window_id)
100
+ self._update_wall_net_area(wall.id)
101
+
102
+ del self.windows[window_id]
103
+ return True
104
+
105
+ def remove_door(self, door_id: str) -> bool:
106
+ """
107
+ Remove a door from the area calculation system.
108
+
109
+ Args:
110
+ door_id: Door identifier
111
+
112
+ Returns:
113
+ True if the door was removed, False otherwise
114
+ """
115
+ if door_id not in self.doors:
116
+ return False
117
+
118
+ # Remove door from associated wall
119
+ door = self.doors[door_id]
120
+ if door.wall_id and door.wall_id in self.walls:
121
+ wall = self.walls[door.wall_id]
122
+ if door_id in wall.doors:
123
+ wall.doors.remove(door_id)
124
+ self._update_wall_net_area(wall.id)
125
+
126
+ del self.doors[door_id]
127
+ return True
128
+
129
+ def assign_window_to_wall(self, window_id: str, wall_id: str) -> bool:
130
+ """
131
+ Assign a window to a wall.
132
+
133
+ Args:
134
+ window_id: Window identifier
135
+ wall_id: Wall identifier
136
+
137
+ Returns:
138
+ True if the window was assigned, False otherwise
139
+ """
140
+ if window_id not in self.windows or wall_id not in self.walls:
141
+ return False
142
+
143
+ window = self.windows[window_id]
144
+ wall = self.walls[wall_id]
145
+
146
+ # Remove window from previous wall if assigned
147
+ if window.wall_id and window.wall_id in self.walls and window.wall_id != wall_id:
148
+ prev_wall = self.walls[window.wall_id]
149
+ if window_id in prev_wall.windows:
150
+ prev_wall.windows.remove(window_id)
151
+ self._update_wall_net_area(prev_wall.id)
152
+
153
+ # Assign window to new wall
154
+ window.wall_id = wall_id
155
+ window.orientation = wall.orientation
156
+
157
+ # Add window to wall's window list if not already there
158
+ if window_id not in wall.windows:
159
+ wall.windows.append(window_id)
160
+
161
+ # Update wall net area
162
+ self._update_wall_net_area(wall_id)
163
+
164
+ return True
165
+
166
+ def assign_door_to_wall(self, door_id: str, wall_id: str) -> bool:
167
+ """
168
+ Assign a door to a wall.
169
+
170
+ Args:
171
+ door_id: Door identifier
172
+ wall_id: Wall identifier
173
+
174
+ Returns:
175
+ True if the door was assigned, False otherwise
176
+ """
177
+ if door_id not in self.doors or wall_id not in self.walls:
178
+ return False
179
+
180
+ door = self.doors[door_id]
181
+ wall = self.walls[wall_id]
182
+
183
+ # Remove door from previous wall if assigned
184
+ if door.wall_id and door.wall_id in self.walls and door.wall_id != wall_id:
185
+ prev_wall = self.walls[door.wall_id]
186
+ if door_id in prev_wall.doors:
187
+ prev_wall.doors.remove(door_id)
188
+ self._update_wall_net_area(prev_wall.id)
189
+
190
+ # Assign door to new wall
191
+ door.wall_id = wall_id
192
+ door.orientation = wall.orientation
193
+
194
+ # Add door to wall's door list if not already there
195
+ if door_id not in wall.doors:
196
+ wall.doors.append(door_id)
197
+
198
+ # Update wall net area
199
+ self._update_wall_net_area(wall_id)
200
+
201
+ return True
202
+
203
+ def _update_wall_net_area(self, wall_id: str) -> None:
204
+ """
205
+ Update the net area of a wall by subtracting windows and doors.
206
+
207
+ Args:
208
+ wall_id: Wall identifier
209
+ """
210
+ if wall_id not in self.walls:
211
+ return
212
+
213
+ wall = self.walls[wall_id]
214
+
215
+ # Calculate total window area
216
+ total_window_area = sum(self.windows[window_id].area
217
+ for window_id in wall.windows
218
+ if window_id in self.windows)
219
+
220
+ # Calculate total door area
221
+ total_door_area = sum(self.doors[door_id].area
222
+ for door_id in wall.doors
223
+ if door_id in self.doors)
224
+
225
+ # Update wall net area
226
+ if wall.gross_area is None:
227
+ wall.gross_area = wall.area
228
+
229
+ wall.net_area = wall.gross_area - total_window_area - total_door_area
230
+ wall.area = wall.net_area # Update the main area property
231
+
232
+ def update_all_net_areas(self) -> None:
233
+ """Update the net areas of all walls."""
234
+ for wall_id in self.walls:
235
+ self._update_wall_net_area(wall_id)
236
+
237
+ def validate_areas(self) -> List[Dict[str, Any]]:
238
+ """
239
+ Validate all areas and return a list of validation issues.
240
+
241
+ Returns:
242
+ List of validation issues
243
+ """
244
+ issues = []
245
+
246
+ # Check for negative or zero net wall areas
247
+ for wall_id, wall in self.walls.items():
248
+ if wall.net_area <= 0:
249
+ issues.append({
250
+ "type": "error",
251
+ "component_id": wall_id,
252
+ "component_type": "Wall",
253
+ "message": f"Wall '{wall.name}' has a negative or zero net area. "
254
+ f"Gross area: {wall.gross_area} m², "
255
+ f"Window area: {sum(self.windows[window_id].area for window_id in wall.windows if window_id in self.windows)} m², "
256
+ f"Door area: {sum(self.doors[door_id].area for door_id in wall.doors if door_id in self.doors)} m², "
257
+ f"Net area: {wall.net_area} m²."
258
+ })
259
+ elif wall.net_area < 0.5:
260
+ issues.append({
261
+ "type": "warning",
262
+ "component_id": wall_id,
263
+ "component_type": "Wall",
264
+ "message": f"Wall '{wall.name}' has a very small net area ({wall.net_area} m²). "
265
+ f"Consider adjusting window and door sizes."
266
+ })
267
+
268
+ # Check for windows without walls
269
+ for window_id, window in self.windows.items():
270
+ if not window.wall_id:
271
+ issues.append({
272
+ "type": "warning",
273
+ "component_id": window_id,
274
+ "component_type": "Window",
275
+ "message": f"Window '{window.name}' is not assigned to any wall."
276
+ })
277
+ elif window.wall_id not in self.walls:
278
+ issues.append({
279
+ "type": "error",
280
+ "component_id": window_id,
281
+ "component_type": "Window",
282
+ "message": f"Window '{window.name}' is assigned to a non-existent wall (ID: {window.wall_id})."
283
+ })
284
+
285
+ # Check for doors without walls
286
+ for door_id, door in self.doors.items():
287
+ if not door.wall_id:
288
+ issues.append({
289
+ "type": "warning",
290
+ "component_id": door_id,
291
+ "component_type": "Door",
292
+ "message": f"Door '{door.name}' is not assigned to any wall."
293
+ })
294
+ elif door.wall_id not in self.walls:
295
+ issues.append({
296
+ "type": "error",
297
+ "component_id": door_id,
298
+ "component_type": "Door",
299
+ "message": f"Door '{door.name}' is assigned to a non-existent wall (ID: {door.wall_id})."
300
+ })
301
+
302
+ # Check for windows and doors with zero area
303
+ for window_id, window in self.windows.items():
304
+ if window.area <= 0:
305
+ issues.append({
306
+ "type": "error",
307
+ "component_id": window_id,
308
+ "component_type": "Window",
309
+ "message": f"Window '{window.name}' has a zero or negative area ({window.area} m²)."
310
+ })
311
+
312
+ for door_id, door in self.doors.items():
313
+ if door.area <= 0:
314
+ issues.append({
315
+ "type": "error",
316
+ "component_id": door_id,
317
+ "component_type": "Door",
318
+ "message": f"Door '{door.name}' has a zero or negative area ({door.area} m²)."
319
+ })
320
+
321
+ return issues
322
+
323
+ def get_wall_components(self, wall_id: str) -> Dict[str, List[str]]:
324
+ """
325
+ Get all components (windows and doors) associated with a wall.
326
+
327
+ Args:
328
+ wall_id: Wall identifier
329
+
330
+ Returns:
331
+ Dictionary with lists of window and door IDs
332
+ """
333
+ if wall_id not in self.walls:
334
+ return {"windows": [], "doors": []}
335
+
336
+ wall = self.walls[wall_id]
337
+ return {
338
+ "windows": [window_id for window_id in wall.windows if window_id in self.windows],
339
+ "doors": [door_id for door_id in wall.doors if door_id in self.doors]
340
+ }
341
+
342
+ def get_wall_area_breakdown(self, wall_id: str) -> Dict[str, float]:
343
+ """
344
+ Get a breakdown of wall areas (gross, net, windows, doors).
345
+
346
+ Args:
347
+ wall_id: Wall identifier
348
+
349
+ Returns:
350
+ Dictionary with area breakdown
351
+ """
352
+ if wall_id not in self.walls:
353
+ return {}
354
+
355
+ wall = self.walls[wall_id]
356
+
357
+ # Calculate total window area
358
+ window_area = sum(self.windows[window_id].area
359
+ for window_id in wall.windows
360
+ if window_id in self.windows)
361
+
362
+ # Calculate total door area
363
+ door_area = sum(self.doors[door_id].area
364
+ for door_id in wall.doors
365
+ if door_id in self.doors)
366
+
367
+ return {
368
+ "gross_area": wall.gross_area,
369
+ "net_area": wall.net_area,
370
+ "window_area": window_area,
371
+ "door_area": door_area
372
+ }
373
+
374
+ def get_total_areas(self) -> Dict[str, float]:
375
+ """
376
+ Get total areas for all component types.
377
+
378
+ Returns:
379
+ Dictionary with total areas
380
+ """
381
+ # Calculate total wall areas
382
+ total_wall_gross_area = sum(wall.gross_area for wall in self.walls.values() if wall.gross_area is not None)
383
+ total_wall_net_area = sum(wall.net_area for wall in self.walls.values() if wall.net_area is not None)
384
+
385
+ # Calculate total window area
386
+ total_window_area = sum(window.area for window in self.windows.values())
387
+
388
+ # Calculate total door area
389
+ total_door_area = sum(door.area for door in self.doors.values())
390
+
391
+ return {
392
+ "total_wall_gross_area": total_wall_gross_area,
393
+ "total_wall_net_area": total_wall_net_area,
394
+ "total_window_area": total_window_area,
395
+ "total_door_area": total_door_area
396
+ }
397
+
398
+ def get_areas_by_orientation(self) -> Dict[str, Dict[str, float]]:
399
+ """
400
+ Get areas for all component types grouped by orientation.
401
+
402
+ Returns:
403
+ Dictionary with areas by orientation
404
+ """
405
+ # Initialize result dictionary
406
+ result = {}
407
+
408
+ # Process walls
409
+ for wall in self.walls.values():
410
+ orientation = wall.orientation.value
411
+ if orientation not in result:
412
+ result[orientation] = {
413
+ "wall_gross_area": 0,
414
+ "wall_net_area": 0,
415
+ "window_area": 0,
416
+ "door_area": 0
417
+ }
418
+
419
+ result[orientation]["wall_gross_area"] += wall.gross_area if wall.gross_area is not None else 0
420
+ result[orientation]["wall_net_area"] += wall.net_area if wall.net_area is not None else 0
421
+
422
+ # Process windows
423
+ for window in self.windows.values():
424
+ orientation = window.orientation.value
425
+ if orientation not in result:
426
+ result[orientation] = {
427
+ "wall_gross_area": 0,
428
+ "wall_net_area": 0,
429
+ "window_area": 0,
430
+ "door_area": 0
431
+ }
432
+
433
+ result[orientation]["window_area"] += window.area
434
+
435
+ # Process doors
436
+ for door in self.doors.values():
437
+ orientation = door.orientation.value
438
+ if orientation not in result:
439
+ result[orientation] = {
440
+ "wall_gross_area": 0,
441
+ "wall_net_area": 0,
442
+ "window_area": 0,
443
+ "door_area": 0
444
+ }
445
+
446
+ result[orientation]["door_area"] += door.area
447
+
448
+ return result
449
+
450
+ def export_to_json(self, file_path: str) -> None:
451
+ """
452
+ Export all components to a JSON file.
453
+
454
+ Args:
455
+ file_path: Path to the output JSON file
456
+ """
457
+ data = {
458
+ "walls": {wall_id: wall.to_dict() for wall_id, wall in self.walls.items()},
459
+ "windows": {window_id: window.to_dict() for window_id, window in self.windows.items()},
460
+ "doors": {door_id: door.to_dict() for door_id, door in self.doors.items()}
461
+ }
462
+
463
+ with open(file_path, 'w') as f:
464
+ json.dump(data, f, indent=4)
465
+
466
+ def import_from_json(self, file_path: str) -> Tuple[int, int, int]:
467
+ """
468
+ Import components from a JSON file.
469
+
470
+ Args:
471
+ file_path: Path to the input JSON file
472
+
473
+ Returns:
474
+ Tuple with counts of walls, windows, and doors imported
475
+ """
476
+ from data.building_components import BuildingComponentFactory
477
+
478
+ with open(file_path, 'r') as f:
479
+ data = json.load(f)
480
+
481
+ wall_count = 0
482
+ window_count = 0
483
+ door_count = 0
484
+
485
+ # Import walls
486
+ for wall_id, wall_data in data.get("walls", {}).items():
487
+ try:
488
+ wall = BuildingComponentFactory.create_component(wall_data)
489
+ self.walls[wall_id] = wall
490
+ wall_count += 1
491
+ except Exception as e:
492
+ print(f"Error importing wall {wall_id}: {e}")
493
+
494
+ # Import windows
495
+ for window_id, window_data in data.get("windows", {}).items():
496
+ try:
497
+ window = BuildingComponentFactory.create_component(window_data)
498
+ self.windows[window_id] = window
499
+ window_count += 1
500
+ except Exception as e:
501
+ print(f"Error importing window {window_id}: {e}")
502
+
503
+ # Import doors
504
+ for door_id, door_data in data.get("doors", {}).items():
505
+ try:
506
+ door = BuildingComponentFactory.create_component(door_data)
507
+ self.doors[door_id] = door
508
+ door_count += 1
509
+ except Exception as e:
510
+ print(f"Error importing door {door_id}: {e}")
511
+
512
+ # Update all net areas
513
+ self.update_all_net_areas()
514
+
515
+ return (wall_count, window_count, door_count)
516
+
517
+
518
+ # Create a singleton instance
519
+ area_calculation_system = AreaCalculationSystem()
520
+
521
+ # Export area calculation system to JSON if needed
522
+ if __name__ == "__main__":
523
+ area_calculation_system.export_to_json(os.path.join(DATA_DIR, "data", "area_calculation_system.json"))
utils/component_library.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Component library module for HVAC Load Calculator.
3
+ This module implements the preset component database and component selection interface.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ import uuid
12
+ from dataclasses import asdict
13
+
14
+ # Import data models
15
+ from data.building_components import (
16
+ BuildingComponent, Wall, Roof, Floor, Window, Door, Skylight,
17
+ MaterialLayer, Orientation, ComponentType, BuildingComponentFactory
18
+ )
19
+ from data.reference_data import reference_data
20
+
21
+ # Define paths
22
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
23
+
24
+
25
+ class ComponentLibrary:
26
+ """Class for managing building component library."""
27
+
28
+ def __init__(self):
29
+ """Initialize component library."""
30
+ self.components = {}
31
+ self.load_preset_components()
32
+
33
+ def load_preset_components(self):
34
+ """Load preset components from reference data."""
35
+ # Load preset walls
36
+ for wall_id, wall_data in reference_data.wall_types.items():
37
+ # Create material layers
38
+ material_layers = []
39
+ for layer_data in wall_data.get("layers", []):
40
+ material_id = layer_data.get("material")
41
+ thickness = layer_data.get("thickness")
42
+
43
+ material = reference_data.get_material(material_id)
44
+ if material:
45
+ layer = MaterialLayer(
46
+ name=material["name"],
47
+ thickness=thickness,
48
+ conductivity=material["conductivity"],
49
+ density=material.get("density"),
50
+ specific_heat=material.get("specific_heat")
51
+ )
52
+ material_layers.append(layer)
53
+
54
+ # Create wall component
55
+ component_id = f"preset_wall_{wall_id}"
56
+ wall = Wall(
57
+ id=component_id,
58
+ name=wall_data["name"],
59
+ component_type=ComponentType.WALL,
60
+ u_value=wall_data["u_value"],
61
+ area=0.0, # Area will be set when component is used
62
+ orientation=Orientation.NOT_APPLICABLE, # Orientation will be set when component is used
63
+ wall_type=wall_data["name"],
64
+ wall_group=wall_data["wall_group"],
65
+ material_layers=material_layers
66
+ )
67
+
68
+ self.components[component_id] = wall
69
+
70
+ # Load preset roofs
71
+ for roof_id, roof_data in reference_data.roof_types.items():
72
+ # Create material layers
73
+ material_layers = []
74
+ for layer_data in roof_data.get("layers", []):
75
+ material_id = layer_data.get("material")
76
+ thickness = layer_data.get("thickness")
77
+
78
+ material = reference_data.get_material(material_id)
79
+ if material:
80
+ layer = MaterialLayer(
81
+ name=material["name"],
82
+ thickness=thickness,
83
+ conductivity=material["conductivity"],
84
+ density=material.get("density"),
85
+ specific_heat=material.get("specific_heat")
86
+ )
87
+ material_layers.append(layer)
88
+
89
+ # Create roof component
90
+ component_id = f"preset_roof_{roof_id}"
91
+ roof = Roof(
92
+ id=component_id,
93
+ name=roof_data["name"],
94
+ component_type=ComponentType.ROOF,
95
+ u_value=roof_data["u_value"],
96
+ area=0.0, # Area will be set when component is used
97
+ orientation=Orientation.HORIZONTAL,
98
+ roof_type=roof_data["name"],
99
+ roof_group=roof_data["roof_group"],
100
+ material_layers=material_layers
101
+ )
102
+
103
+ self.components[component_id] = roof
104
+
105
+ # Load preset floors
106
+ for floor_id, floor_data in reference_data.floor_types.items():
107
+ # Create material layers
108
+ material_layers = []
109
+ for layer_data in floor_data.get("layers", []):
110
+ material_id = layer_data.get("material")
111
+ thickness = layer_data.get("thickness")
112
+
113
+ material = reference_data.get_material(material_id)
114
+ if material:
115
+ layer = MaterialLayer(
116
+ name=material["name"],
117
+ thickness=thickness,
118
+ conductivity=material["conductivity"],
119
+ density=material.get("density"),
120
+ specific_heat=material.get("specific_heat")
121
+ )
122
+ material_layers.append(layer)
123
+
124
+ # Create floor component
125
+ component_id = f"preset_floor_{floor_id}"
126
+ floor = Floor(
127
+ id=component_id,
128
+ name=floor_data["name"],
129
+ component_type=ComponentType.FLOOR,
130
+ u_value=floor_data["u_value"],
131
+ area=0.0, # Area will be set when component is used
132
+ orientation=Orientation.HORIZONTAL,
133
+ floor_type=floor_data["name"],
134
+ is_ground_contact=floor_data["is_ground_contact"],
135
+ material_layers=material_layers
136
+ )
137
+
138
+ self.components[component_id] = floor
139
+
140
+ # Load preset windows
141
+ for window_id, window_data in reference_data.window_types.items():
142
+ # Create window component
143
+ component_id = f"preset_window_{window_id}"
144
+ window = Window(
145
+ id=component_id,
146
+ name=window_data["name"],
147
+ component_type=ComponentType.WINDOW,
148
+ u_value=window_data["u_value"],
149
+ area=0.0, # Area will be set when component is used
150
+ orientation=Orientation.NOT_APPLICABLE, # Orientation will be set when component is used
151
+ shgc=window_data["shgc"],
152
+ vt=window_data["vt"],
153
+ window_type=window_data["name"],
154
+ glazing_layers=window_data["glazing_layers"],
155
+ gas_fill=window_data["gas_fill"],
156
+ low_e_coating=window_data["low_e_coating"]
157
+ )
158
+
159
+ self.components[component_id] = window
160
+
161
+ # Load preset doors
162
+ for door_id, door_data in reference_data.door_types.items():
163
+ # Create door component
164
+ component_id = f"preset_door_{door_id}"
165
+ door = Door(
166
+ id=component_id,
167
+ name=door_data["name"],
168
+ component_type=ComponentType.DOOR,
169
+ u_value=door_data["u_value"],
170
+ area=0.0, # Area will be set when component is used
171
+ orientation=Orientation.NOT_APPLICABLE, # Orientation will be set when component is used
172
+ door_type=door_data["door_type"],
173
+ glazing_percentage=door_data["glazing_percentage"],
174
+ shgc=door_data.get("shgc", 0.0),
175
+ vt=door_data.get("vt", 0.0)
176
+ )
177
+
178
+ self.components[component_id] = door
179
+
180
+ def get_component(self, component_id: str) -> Optional[BuildingComponent]:
181
+ """
182
+ Get a component by ID.
183
+
184
+ Args:
185
+ component_id: Component identifier
186
+
187
+ Returns:
188
+ BuildingComponent object or None if not found
189
+ """
190
+ return self.components.get(component_id)
191
+
192
+ def get_components_by_type(self, component_type: ComponentType) -> List[BuildingComponent]:
193
+ """
194
+ Get all components of a specific type.
195
+
196
+ Args:
197
+ component_type: Component type
198
+
199
+ Returns:
200
+ List of BuildingComponent objects
201
+ """
202
+ return [comp for comp in self.components.values() if comp.component_type == component_type]
203
+
204
+ def get_preset_components_by_type(self, component_type: ComponentType) -> List[BuildingComponent]:
205
+ """
206
+ Get all preset components of a specific type.
207
+
208
+ Args:
209
+ component_type: Component type
210
+
211
+ Returns:
212
+ List of BuildingComponent objects
213
+ """
214
+ return [comp for comp in self.components.values()
215
+ if comp.component_type == component_type and comp.id.startswith("preset_")]
216
+
217
+ def get_custom_components_by_type(self, component_type: ComponentType) -> List[BuildingComponent]:
218
+ """
219
+ Get all custom components of a specific type.
220
+
221
+ Args:
222
+ component_type: Component type
223
+
224
+ Returns:
225
+ List of BuildingComponent objects
226
+ """
227
+ return [comp for comp in self.components.values()
228
+ if comp.component_type == component_type and comp.id.startswith("custom_")]
229
+
230
+ def add_component(self, component: BuildingComponent) -> str:
231
+ """
232
+ Add a component to the library.
233
+
234
+ Args:
235
+ component: BuildingComponent object
236
+
237
+ Returns:
238
+ Component ID
239
+ """
240
+ if component.id in self.components:
241
+ # Generate a new ID if the component ID already exists
242
+ component.id = f"custom_{component.component_type.value.lower()}_{str(uuid.uuid4())[:8]}"
243
+
244
+ self.components[component.id] = component
245
+ return component.id
246
+
247
+ def update_component(self, component_id: str, component: BuildingComponent) -> bool:
248
+ """
249
+ Update a component in the library.
250
+
251
+ Args:
252
+ component_id: ID of the component to update
253
+ component: Updated BuildingComponent object
254
+
255
+ Returns:
256
+ True if the component was updated, False otherwise
257
+ """
258
+ if component_id not in self.components:
259
+ return False
260
+
261
+ # Preserve the original ID
262
+ component.id = component_id
263
+ self.components[component_id] = component
264
+ return True
265
+
266
+ def remove_component(self, component_id: str) -> bool:
267
+ """
268
+ Remove a component from the library.
269
+
270
+ Args:
271
+ component_id: ID of the component to remove
272
+
273
+ Returns:
274
+ True if the component was removed, False otherwise
275
+ """
276
+ if component_id not in self.components:
277
+ return False
278
+
279
+ # Don't allow removing preset components
280
+ if component_id.startswith("preset_"):
281
+ return False
282
+
283
+ del self.components[component_id]
284
+ return True
285
+
286
+ def clone_component(self, component_id: str, new_name: str = None) -> Optional[str]:
287
+ """
288
+ Clone a component in the library.
289
+
290
+ Args:
291
+ component_id: ID of the component to clone
292
+ new_name: Name for the cloned component (optional)
293
+
294
+ Returns:
295
+ ID of the cloned component or None if the original component was not found
296
+ """
297
+ if component_id not in self.components:
298
+ return None
299
+
300
+ # Get the original component
301
+ original = self.components[component_id]
302
+
303
+ # Create a copy of the component
304
+ component_dict = asdict(original)
305
+
306
+ # Generate a new ID
307
+ component_dict["id"] = f"custom_{original.component_type.value.lower()}_{str(uuid.uuid4())[:8]}"
308
+
309
+ # Set new name if provided
310
+ if new_name:
311
+ component_dict["name"] = new_name
312
+ else:
313
+ component_dict["name"] = f"Copy of {original.name}"
314
+
315
+ # Create a new component
316
+ new_component = BuildingComponentFactory.create_component(component_dict)
317
+
318
+ # Add the new component to the library
319
+ self.components[new_component.id] = new_component
320
+
321
+ return new_component.id
322
+
323
+ def export_to_json(self, file_path: str) -> None:
324
+ """
325
+ Export all components to a JSON file.
326
+
327
+ Args:
328
+ file_path: Path to the output JSON file
329
+ """
330
+ data = {comp_id: comp.to_dict() for comp_id, comp in self.components.items()}
331
+
332
+ with open(file_path, 'w') as f:
333
+ json.dump(data, f, indent=4)
334
+
335
+ def import_from_json(self, file_path: str) -> int:
336
+ """
337
+ Import components from a JSON file.
338
+
339
+ Args:
340
+ file_path: Path to the input JSON file
341
+
342
+ Returns:
343
+ Number of components imported
344
+ """
345
+ with open(file_path, 'r') as f:
346
+ data = json.load(f)
347
+
348
+ count = 0
349
+ for comp_id, comp_data in data.items():
350
+ try:
351
+ component = BuildingComponentFactory.create_component(comp_data)
352
+ self.components[comp_id] = component
353
+ count += 1
354
+ except Exception as e:
355
+ print(f"Error importing component {comp_id}: {e}")
356
+
357
+ return count
358
+
359
+
360
+ # Create a singleton instance
361
+ component_library = ComponentLibrary()
362
+
363
+ # Export component library to JSON if needed
364
+ if __name__ == "__main__":
365
+ component_library.export_to_json(os.path.join(DATA_DIR, "component_library.json"))
utils/component_visualization.py ADDED
@@ -0,0 +1,721 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hierarchical component visualization module for HVAC Load Calculator.
3
+ This module provides visualization tools for building components.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ import plotly.graph_objects as go
10
+ import plotly.express as px
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ import math
13
+
14
+ # Import data models
15
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
16
+
17
+
18
+ class ComponentVisualization:
19
+ """Class for hierarchical component visualization."""
20
+
21
+ @staticmethod
22
+ def create_component_summary_table(components: Dict[str, List[Any]]) -> pd.DataFrame:
23
+ """
24
+ Create a summary table of building components.
25
+
26
+ Args:
27
+ components: Dictionary with lists of building components
28
+
29
+ Returns:
30
+ DataFrame with component summary
31
+ """
32
+ # Initialize data
33
+ data = []
34
+
35
+ # Process walls
36
+ for wall in components.get("walls", []):
37
+ data.append({
38
+ "Component Type": "Wall",
39
+ "Name": wall.name,
40
+ "Orientation": wall.orientation.name,
41
+ "Area (m²)": wall.area,
42
+ "U-Value (W/m²·K)": wall.u_value,
43
+ "Heat Transfer (W/K)": wall.area * wall.u_value
44
+ })
45
+
46
+ # Process roofs
47
+ for roof in components.get("roofs", []):
48
+ data.append({
49
+ "Component Type": "Roof",
50
+ "Name": roof.name,
51
+ "Orientation": roof.orientation.name,
52
+ "Area (m²)": roof.area,
53
+ "U-Value (W/m²·K)": roof.u_value,
54
+ "Heat Transfer (W/K)": roof.area * roof.u_value
55
+ })
56
+
57
+ # Process floors
58
+ for floor in components.get("floors", []):
59
+ data.append({
60
+ "Component Type": "Floor",
61
+ "Name": floor.name,
62
+ "Orientation": "Horizontal",
63
+ "Area (m²)": floor.area,
64
+ "U-Value (W/m²·K)": floor.u_value,
65
+ "Heat Transfer (W/K)": floor.area * floor.u_value
66
+ })
67
+
68
+ # Process windows
69
+ for window in components.get("windows", []):
70
+ data.append({
71
+ "Component Type": "Window",
72
+ "Name": window.name,
73
+ "Orientation": window.orientation.name,
74
+ "Area (m²)": window.area,
75
+ "U-Value (W/m²·K)": window.u_value,
76
+ "Heat Transfer (W/K)": window.area * window.u_value,
77
+ "SHGC": window.shgc if hasattr(window, "shgc") else None
78
+ })
79
+
80
+ # Process doors
81
+ for door in components.get("doors", []):
82
+ data.append({
83
+ "Component Type": "Door",
84
+ "Name": door.name,
85
+ "Orientation": door.orientation.name,
86
+ "Area (m²)": door.area,
87
+ "U-Value (W/m²·K)": door.u_value,
88
+ "Heat Transfer (W/K)": door.area * door.u_value
89
+ })
90
+
91
+ # Create DataFrame
92
+ df = pd.DataFrame(data)
93
+
94
+ return df
95
+
96
+ @staticmethod
97
+ def create_component_area_chart(components: Dict[str, List[Any]]) -> go.Figure:
98
+ """
99
+ Create a pie chart of component areas.
100
+
101
+ Args:
102
+ components: Dictionary with lists of building components
103
+
104
+ Returns:
105
+ Plotly figure with component area breakdown
106
+ """
107
+ # Calculate total areas by component type
108
+ areas = {
109
+ "Walls": sum(wall.area for wall in components.get("walls", [])),
110
+ "Roofs": sum(roof.area for roof in components.get("roofs", [])),
111
+ "Floors": sum(floor.area for floor in components.get("floors", [])),
112
+ "Windows": sum(window.area for window in components.get("windows", [])),
113
+ "Doors": sum(door.area for door in components.get("doors", []))
114
+ }
115
+
116
+ # Create labels and values
117
+ labels = list(areas.keys())
118
+ values = list(areas.values())
119
+
120
+ # Create pie chart
121
+ fig = go.Figure(data=[go.Pie(
122
+ labels=labels,
123
+ values=values,
124
+ hole=0.3,
125
+ textinfo="label+percent",
126
+ insidetextorientation="radial"
127
+ )])
128
+
129
+ # Update layout
130
+ fig.update_layout(
131
+ title="Building Component Areas",
132
+ height=500,
133
+ legend=dict(
134
+ orientation="h",
135
+ yanchor="bottom",
136
+ y=1.02,
137
+ xanchor="right",
138
+ x=1
139
+ )
140
+ )
141
+
142
+ return fig
143
+
144
+ @staticmethod
145
+ def create_orientation_area_chart(components: Dict[str, List[Any]]) -> go.Figure:
146
+ """
147
+ Create a bar chart of areas by orientation.
148
+
149
+ Args:
150
+ components: Dictionary with lists of building components
151
+
152
+ Returns:
153
+ Plotly figure with area breakdown by orientation
154
+ """
155
+ # Initialize areas by orientation
156
+ orientation_areas = {
157
+ "NORTH": 0,
158
+ "NORTHEAST": 0,
159
+ "EAST": 0,
160
+ "SOUTHEAST": 0,
161
+ "SOUTH": 0,
162
+ "SOUTHWEST": 0,
163
+ "WEST": 0,
164
+ "NORTHWEST": 0,
165
+ "HORIZONTAL": 0
166
+ }
167
+
168
+ # Calculate areas by orientation for walls
169
+ for wall in components.get("walls", []):
170
+ orientation_areas[wall.orientation.name] += wall.area
171
+
172
+ # Calculate areas by orientation for windows
173
+ for window in components.get("windows", []):
174
+ orientation_areas[window.orientation.name] += window.area
175
+
176
+ # Calculate areas by orientation for doors
177
+ for door in components.get("doors", []):
178
+ orientation_areas[door.orientation.name] += door.area
179
+
180
+ # Add roofs and floors to horizontal
181
+ for roof in components.get("roofs", []):
182
+ if roof.orientation.name == "HORIZONTAL":
183
+ orientation_areas["HORIZONTAL"] += roof.area
184
+ else:
185
+ orientation_areas[roof.orientation.name] += roof.area
186
+
187
+ for floor in components.get("floors", []):
188
+ orientation_areas["HORIZONTAL"] += floor.area
189
+
190
+ # Create labels and values
191
+ orientations = []
192
+ areas = []
193
+
194
+ for orientation, area in orientation_areas.items():
195
+ if area > 0:
196
+ orientations.append(orientation)
197
+ areas.append(area)
198
+
199
+ # Create bar chart
200
+ fig = go.Figure(data=[go.Bar(
201
+ x=orientations,
202
+ y=areas,
203
+ text=areas,
204
+ texttemplate="%{y:.1f} m²",
205
+ textposition="auto"
206
+ )])
207
+
208
+ # Update layout
209
+ fig.update_layout(
210
+ title="Building Component Areas by Orientation",
211
+ xaxis_title="Orientation",
212
+ yaxis_title="Area (m²)",
213
+ height=500
214
+ )
215
+
216
+ return fig
217
+
218
+ @staticmethod
219
+ def create_heat_transfer_chart(components: Dict[str, List[Any]]) -> go.Figure:
220
+ """
221
+ Create a bar chart of heat transfer coefficients by component type.
222
+
223
+ Args:
224
+ components: Dictionary with lists of building components
225
+
226
+ Returns:
227
+ Plotly figure with heat transfer breakdown
228
+ """
229
+ # Calculate heat transfer by component type
230
+ heat_transfer = {
231
+ "Walls": sum(wall.area * wall.u_value for wall in components.get("walls", [])),
232
+ "Roofs": sum(roof.area * roof.u_value for roof in components.get("roofs", [])),
233
+ "Floors": sum(floor.area * floor.u_value for floor in components.get("floors", [])),
234
+ "Windows": sum(window.area * window.u_value for window in components.get("windows", [])),
235
+ "Doors": sum(door.area * door.u_value for door in components.get("doors", []))
236
+ }
237
+
238
+ # Create labels and values
239
+ labels = list(heat_transfer.keys())
240
+ values = list(heat_transfer.values())
241
+
242
+ # Create bar chart
243
+ fig = go.Figure(data=[go.Bar(
244
+ x=labels,
245
+ y=values,
246
+ text=values,
247
+ texttemplate="%{y:.1f} W/K",
248
+ textposition="auto"
249
+ )])
250
+
251
+ # Update layout
252
+ fig.update_layout(
253
+ title="Heat Transfer Coefficients by Component Type",
254
+ xaxis_title="Component Type",
255
+ yaxis_title="Heat Transfer Coefficient (W/K)",
256
+ height=500
257
+ )
258
+
259
+ return fig
260
+
261
+ @staticmethod
262
+ def create_3d_building_model(components: Dict[str, List[Any]]) -> go.Figure:
263
+ """
264
+ Create a 3D visualization of the building components.
265
+
266
+ Args:
267
+ components: Dictionary with lists of building components
268
+
269
+ Returns:
270
+ Plotly figure with 3D building model
271
+ """
272
+ # Initialize figure
273
+ fig = go.Figure()
274
+
275
+ # Define colors
276
+ colors = {
277
+ "Wall": "lightblue",
278
+ "Roof": "red",
279
+ "Floor": "brown",
280
+ "Window": "skyblue",
281
+ "Door": "orange"
282
+ }
283
+
284
+ # Define orientation vectors
285
+ orientation_vectors = {
286
+ "NORTH": (0, 1, 0),
287
+ "NORTHEAST": (0.7071, 0.7071, 0),
288
+ "EAST": (1, 0, 0),
289
+ "SOUTHEAST": (0.7071, -0.7071, 0),
290
+ "SOUTH": (0, -1, 0),
291
+ "SOUTHWEST": (-0.7071, -0.7071, 0),
292
+ "WEST": (-1, 0, 0),
293
+ "NORTHWEST": (-0.7071, 0.7071, 0),
294
+ "HORIZONTAL": (0, 0, 1)
295
+ }
296
+
297
+ # Define building dimensions (simplified model)
298
+ building_width = 10
299
+ building_depth = 10
300
+ building_height = 3
301
+
302
+ # Create walls
303
+ for i, wall in enumerate(components.get("walls", [])):
304
+ orientation = wall.orientation.name
305
+ vector = orientation_vectors[orientation]
306
+
307
+ # Determine wall position and dimensions
308
+ if orientation in ["NORTH", "SOUTH"]:
309
+ width = building_width
310
+ height = building_height
311
+ depth = 0.3
312
+
313
+ if orientation == "NORTH":
314
+ x = 0
315
+ y = building_depth / 2
316
+ else: # SOUTH
317
+ x = 0
318
+ y = -building_depth / 2
319
+
320
+ z = building_height / 2
321
+
322
+ elif orientation in ["EAST", "WEST"]:
323
+ width = 0.3
324
+ height = building_height
325
+ depth = building_depth
326
+
327
+ if orientation == "EAST":
328
+ x = building_width / 2
329
+ y = 0
330
+ else: # WEST
331
+ x = -building_width / 2
332
+ y = 0
333
+
334
+ z = building_height / 2
335
+
336
+ else: # Diagonal orientations
337
+ width = building_width / 2
338
+ height = building_height
339
+ depth = 0.3
340
+
341
+ if orientation == "NORTHEAST":
342
+ x = building_width / 4
343
+ y = building_depth / 4
344
+ elif orientation == "SOUTHEAST":
345
+ x = building_width / 4
346
+ y = -building_depth / 4
347
+ elif orientation == "SOUTHWEST":
348
+ x = -building_width / 4
349
+ y = -building_depth / 4
350
+ else: # NORTHWEST
351
+ x = -building_width / 4
352
+ y = building_depth / 4
353
+
354
+ z = building_height / 2
355
+
356
+ # Add wall to figure
357
+ fig.add_trace(go.Mesh3d(
358
+ x=[x - width/2, x + width/2, x + width/2, x - width/2, x - width/2, x + width/2, x + width/2, x - width/2],
359
+ y=[y - depth/2, y - depth/2, y + depth/2, y + depth/2, y - depth/2, y - depth/2, y + depth/2, y + depth/2],
360
+ z=[z - height/2, z - height/2, z - height/2, z - height/2, z + height/2, z + height/2, z + height/2, z + height/2],
361
+ i=[0, 0, 0, 1, 4, 4],
362
+ j=[1, 2, 4, 2, 5, 6],
363
+ k=[2, 3, 7, 3, 6, 7],
364
+ color=colors["Wall"],
365
+ opacity=0.7,
366
+ name=f"Wall: {wall.name}"
367
+ ))
368
+
369
+ # Create roof
370
+ for i, roof in enumerate(components.get("roofs", [])):
371
+ # Add roof to figure
372
+ fig.add_trace(go.Mesh3d(
373
+ x=[-building_width/2, building_width/2, building_width/2, -building_width/2],
374
+ y=[-building_depth/2, -building_depth/2, building_depth/2, building_depth/2],
375
+ z=[building_height, building_height, building_height, building_height],
376
+ i=[0],
377
+ j=[1],
378
+ k=[2],
379
+ color=colors["Roof"],
380
+ opacity=0.7,
381
+ name=f"Roof: {roof.name}"
382
+ ))
383
+
384
+ fig.add_trace(go.Mesh3d(
385
+ x=[-building_width/2, -building_width/2, building_width/2],
386
+ y=[building_depth/2, -building_depth/2, -building_depth/2],
387
+ z=[building_height, building_height, building_height],
388
+ i=[0],
389
+ j=[1],
390
+ k=[2],
391
+ color=colors["Roof"],
392
+ opacity=0.7,
393
+ name=f"Roof: {roof.name}"
394
+ ))
395
+
396
+ # Create floor
397
+ for i, floor in enumerate(components.get("floors", [])):
398
+ # Add floor to figure
399
+ fig.add_trace(go.Mesh3d(
400
+ x=[-building_width/2, building_width/2, building_width/2, -building_width/2],
401
+ y=[-building_depth/2, -building_depth/2, building_depth/2, building_depth/2],
402
+ z=[0, 0, 0, 0],
403
+ i=[0],
404
+ j=[1],
405
+ k=[2],
406
+ color=colors["Floor"],
407
+ opacity=0.7,
408
+ name=f"Floor: {floor.name}"
409
+ ))
410
+
411
+ fig.add_trace(go.Mesh3d(
412
+ x=[-building_width/2, -building_width/2, building_width/2],
413
+ y=[building_depth/2, -building_depth/2, -building_depth/2],
414
+ z=[0, 0, 0],
415
+ i=[0],
416
+ j=[1],
417
+ k=[2],
418
+ color=colors["Floor"],
419
+ opacity=0.7,
420
+ name=f"Floor: {floor.name}"
421
+ ))
422
+
423
+ # Create windows
424
+ for i, window in enumerate(components.get("windows", [])):
425
+ orientation = window.orientation.name
426
+ vector = orientation_vectors[orientation]
427
+
428
+ # Determine window position and dimensions
429
+ window_width = 1.5
430
+ window_height = 1.2
431
+ window_depth = 0.1
432
+
433
+ if orientation == "NORTH":
434
+ x = i * 3 - building_width/4
435
+ y = building_depth / 2
436
+ z = building_height / 2
437
+ elif orientation == "SOUTH":
438
+ x = i * 3 - building_width/4
439
+ y = -building_depth / 2
440
+ z = building_height / 2
441
+ elif orientation == "EAST":
442
+ x = building_width / 2
443
+ y = i * 3 - building_depth/4
444
+ z = building_height / 2
445
+ elif orientation == "WEST":
446
+ x = -building_width / 2
447
+ y = i * 3 - building_depth/4
448
+ z = building_height / 2
449
+ else:
450
+ # Skip diagonal orientations for simplicity
451
+ continue
452
+
453
+ # Add window to figure
454
+ fig.add_trace(go.Mesh3d(
455
+ x=[x - window_width/2, x + window_width/2, x + window_width/2, x - window_width/2, x - window_width/2, x + window_width/2, x + window_width/2, x - window_width/2],
456
+ y=[y - window_depth/2, y - window_depth/2, y + window_depth/2, y + window_depth/2, y - window_depth/2, y - window_depth/2, y + window_depth/2, y + window_depth/2],
457
+ z=[z - window_height/2, z - window_height/2, z - window_height/2, z - window_height/2, z + window_height/2, z + window_height/2, z + window_height/2, z + window_height/2],
458
+ i=[0, 0, 0, 1, 4, 4],
459
+ j=[1, 2, 4, 2, 5, 6],
460
+ k=[2, 3, 7, 3, 6, 7],
461
+ color=colors["Window"],
462
+ opacity=0.5,
463
+ name=f"Window: {window.name}"
464
+ ))
465
+
466
+ # Create doors
467
+ for i, door in enumerate(components.get("doors", [])):
468
+ orientation = door.orientation.name
469
+ vector = orientation_vectors[orientation]
470
+
471
+ # Determine door position and dimensions
472
+ door_width = 1.0
473
+ door_height = 2.0
474
+ door_depth = 0.1
475
+
476
+ if orientation == "NORTH":
477
+ x = i * 3
478
+ y = building_depth / 2
479
+ z = door_height / 2
480
+ elif orientation == "SOUTH":
481
+ x = i * 3
482
+ y = -building_depth / 2
483
+ z = door_height / 2
484
+ elif orientation == "EAST":
485
+ x = building_width / 2
486
+ y = i * 3
487
+ z = door_height / 2
488
+ elif orientation == "WEST":
489
+ x = -building_width / 2
490
+ y = i * 3
491
+ z = door_height / 2
492
+ else:
493
+ # Skip diagonal orientations for simplicity
494
+ continue
495
+
496
+ # Add door to figure
497
+ fig.add_trace(go.Mesh3d(
498
+ x=[x - door_width/2, x + door_width/2, x + door_width/2, x - door_width/2, x - door_width/2, x + door_width/2, x + door_width/2, x - door_width/2],
499
+ y=[y - door_depth/2, y - door_depth/2, y + door_depth/2, y + door_depth/2, y - door_depth/2, y - door_depth/2, y + door_depth/2, y + door_depth/2],
500
+ z=[z - door_height/2, z - door_height/2, z - door_height/2, z - door_height/2, z + door_height/2, z + door_height/2, z + door_height/2, z + door_height/2],
501
+ i=[0, 0, 0, 1, 4, 4],
502
+ j=[1, 2, 4, 2, 5, 6],
503
+ k=[2, 3, 7, 3, 6, 7],
504
+ color=colors["Door"],
505
+ opacity=0.7,
506
+ name=f"Door: {door.name}"
507
+ ))
508
+
509
+ # Update layout
510
+ fig.update_layout(
511
+ title="3D Building Model",
512
+ scene=dict(
513
+ xaxis_title="X",
514
+ yaxis_title="Y",
515
+ zaxis_title="Z",
516
+ aspectmode="data"
517
+ ),
518
+ height=700,
519
+ margin=dict(l=0, r=0, b=0, t=30)
520
+ )
521
+
522
+ return fig
523
+
524
+ @staticmethod
525
+ def display_component_visualization(components: Dict[str, List[Any]]) -> None:
526
+ """
527
+ Display component visualization in Streamlit.
528
+
529
+ Args:
530
+ components: Dictionary with lists of building components
531
+ """
532
+ st.header("Building Component Visualization")
533
+
534
+ # Create tabs for different visualizations
535
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
536
+ "Component Summary",
537
+ "Area Breakdown",
538
+ "Orientation Analysis",
539
+ "Heat Transfer Analysis",
540
+ "3D Building Model"
541
+ ])
542
+
543
+ with tab1:
544
+ st.subheader("Component Summary")
545
+ df = ComponentVisualization.create_component_summary_table(components)
546
+ st.dataframe(df, use_container_width=True)
547
+
548
+ # Add download button for CSV
549
+ csv = df.to_csv(index=False).encode('utf-8')
550
+ st.download_button(
551
+ label="Download Component Summary as CSV",
552
+ data=csv,
553
+ file_name="component_summary.csv",
554
+ mime="text/csv"
555
+ )
556
+
557
+ with tab2:
558
+ st.subheader("Area Breakdown")
559
+ fig = ComponentVisualization.create_component_area_chart(components)
560
+ st.plotly_chart(fig, use_container_width=True)
561
+
562
+ with tab3:
563
+ st.subheader("Orientation Analysis")
564
+ fig = ComponentVisualization.create_orientation_area_chart(components)
565
+ st.plotly_chart(fig, use_container_width=True)
566
+
567
+ with tab4:
568
+ st.subheader("Heat Transfer Analysis")
569
+ fig = ComponentVisualization.create_heat_transfer_chart(components)
570
+ st.plotly_chart(fig, use_container_width=True)
571
+
572
+ with tab5:
573
+ st.subheader("3D Building Model")
574
+ fig = ComponentVisualization.create_3d_building_model(components)
575
+ st.plotly_chart(fig, use_container_width=True)
576
+
577
+
578
+ # Create a singleton instance
579
+ component_visualization = ComponentVisualization()
580
+
581
+ # Example usage
582
+ if __name__ == "__main__":
583
+ import streamlit as st
584
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
585
+
586
+ # Create sample building components
587
+ walls = [
588
+ Wall(
589
+ id="wall1",
590
+ name="North Wall",
591
+ component_type=ComponentType.WALL,
592
+ u_value=0.5,
593
+ area=20.0,
594
+ orientation=Orientation.NORTH,
595
+ wall_type="Brick",
596
+ wall_group="B"
597
+ ),
598
+ Wall(
599
+ id="wall2",
600
+ name="South Wall",
601
+ component_type=ComponentType.WALL,
602
+ u_value=0.5,
603
+ area=20.0,
604
+ orientation=Orientation.SOUTH,
605
+ wall_type="Brick",
606
+ wall_group="B"
607
+ ),
608
+ Wall(
609
+ id="wall3",
610
+ name="East Wall",
611
+ component_type=ComponentType.WALL,
612
+ u_value=0.5,
613
+ area=15.0,
614
+ orientation=Orientation.EAST,
615
+ wall_type="Brick",
616
+ wall_group="B"
617
+ ),
618
+ Wall(
619
+ id="wall4",
620
+ name="West Wall",
621
+ component_type=ComponentType.WALL,
622
+ u_value=0.5,
623
+ area=15.0,
624
+ orientation=Orientation.WEST,
625
+ wall_type="Brick",
626
+ wall_group="B"
627
+ )
628
+ ]
629
+
630
+ roofs = [
631
+ Roof(
632
+ id="roof1",
633
+ name="Flat Roof",
634
+ component_type=ComponentType.ROOF,
635
+ u_value=0.3,
636
+ area=100.0,
637
+ orientation=Orientation.HORIZONTAL,
638
+ roof_type="Concrete",
639
+ roof_group="C"
640
+ )
641
+ ]
642
+
643
+ floors = [
644
+ Floor(
645
+ id="floor1",
646
+ name="Ground Floor",
647
+ component_type=ComponentType.FLOOR,
648
+ u_value=0.4,
649
+ area=100.0,
650
+ floor_type="Concrete"
651
+ )
652
+ ]
653
+
654
+ windows = [
655
+ Window(
656
+ id="window1",
657
+ name="North Window 1",
658
+ component_type=ComponentType.WINDOW,
659
+ u_value=2.8,
660
+ area=4.0,
661
+ orientation=Orientation.NORTH,
662
+ shgc=0.7,
663
+ vt=0.8,
664
+ window_type="Double Glazed",
665
+ glazing_layers=2,
666
+ gas_fill="Air",
667
+ low_e_coating=False
668
+ ),
669
+ Window(
670
+ id="window2",
671
+ name="South Window 1",
672
+ component_type=ComponentType.WINDOW,
673
+ u_value=2.8,
674
+ area=6.0,
675
+ orientation=Orientation.SOUTH,
676
+ shgc=0.7,
677
+ vt=0.8,
678
+ window_type="Double Glazed",
679
+ glazing_layers=2,
680
+ gas_fill="Air",
681
+ low_e_coating=False
682
+ ),
683
+ Window(
684
+ id="window3",
685
+ name="East Window 1",
686
+ component_type=ComponentType.WINDOW,
687
+ u_value=2.8,
688
+ area=3.0,
689
+ orientation=Orientation.EAST,
690
+ shgc=0.7,
691
+ vt=0.8,
692
+ window_type="Double Glazed",
693
+ glazing_layers=2,
694
+ gas_fill="Air",
695
+ low_e_coating=False
696
+ )
697
+ ]
698
+
699
+ doors = [
700
+ Door(
701
+ id="door1",
702
+ name="Front Door",
703
+ component_type=ComponentType.DOOR,
704
+ u_value=2.0,
705
+ area=2.0,
706
+ orientation=Orientation.SOUTH,
707
+ door_type="Solid Wood"
708
+ )
709
+ ]
710
+
711
+ # Create components dictionary
712
+ components = {
713
+ "walls": walls,
714
+ "roofs": roofs,
715
+ "floors": floors,
716
+ "windows": windows,
717
+ "doors": doors
718
+ }
719
+
720
+ # Display component visualization
721
+ component_visualization.display_component_visualization(components)
utils/cooling_load.py ADDED
@@ -0,0 +1,774 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cooling load calculation module for HVAC Load Calculator.
3
+ This module implements the CLTD/CLF method for calculating cooling loads.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import math
8
+ import numpy as np
9
+ import pandas as pd
10
+ import os
11
+ from datetime import datetime, timedelta
12
+ from enum import Enum
13
+
14
+ # Import data models and utilities
15
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
16
+ from data.ashrae_tables import ashrae_tables
17
+ from utils.psychrometrics import Psychrometrics
18
+ from utils.heat_transfer import HeatTransferCalculations
19
+
20
+ # Define paths
21
+ DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
22
+
23
+
24
+ class CoolingLoadCalculator:
25
+ """Class for calculating cooling loads using the CLTD/CLF method."""
26
+
27
+ def __init__(self):
28
+ """Initialize cooling load calculator."""
29
+ self.heat_transfer = HeatTransferCalculations()
30
+ self.psychrometrics = Psychrometrics()
31
+ self.ashrae_tables = ashrae_tables
32
+
33
+ def calculate_wall_cooling_load(self, wall: Wall, outdoor_temp: float, indoor_temp: float,
34
+ month: str, hour: int, latitude: str = "40N",
35
+ color: str = "Dark") -> float:
36
+ """
37
+ Calculate cooling load through a wall using the CLTD method.
38
+
39
+ Args:
40
+ wall: Wall object
41
+ outdoor_temp: Outdoor temperature in °C
42
+ indoor_temp: Indoor temperature in °C
43
+ month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
44
+ hour: Hour of the day (0-23)
45
+ latitude: Latitude (24N, 32N, 40N, 48N, 56N)
46
+ color: Surface color (Dark, Medium, Light)
47
+
48
+ Returns:
49
+ Cooling load in W
50
+ """
51
+ # Get wall properties
52
+ u_value = wall.u_value
53
+ area = wall.area
54
+ orientation = wall.orientation.value
55
+ wall_group = wall.wall_group
56
+
57
+ # Calculate corrected CLTD
58
+ cltd = self.ashrae_tables.calculate_corrected_cltd_wall(
59
+ wall_group=wall_group,
60
+ orientation=orientation,
61
+ hour=hour,
62
+ color=color,
63
+ month=month,
64
+ latitude=latitude,
65
+ indoor_temp=indoor_temp,
66
+ outdoor_temp=outdoor_temp
67
+ )
68
+
69
+ # Calculate cooling load
70
+ cooling_load = u_value * area * cltd
71
+
72
+ return cooling_load
73
+
74
+ def calculate_roof_cooling_load(self, roof: Roof, outdoor_temp: float, indoor_temp: float,
75
+ month: str, hour: int, latitude: str = "40N",
76
+ color: str = "Dark") -> float:
77
+ """
78
+ Calculate cooling load through a roof using the CLTD method.
79
+
80
+ Args:
81
+ roof: Roof object
82
+ outdoor_temp: Outdoor temperature in °C
83
+ indoor_temp: Indoor temperature in °C
84
+ month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
85
+ hour: Hour of the day (0-23)
86
+ latitude: Latitude (24N, 32N, 40N, 48N, 56N)
87
+ color: Surface color (Dark, Medium, Light)
88
+
89
+ Returns:
90
+ Cooling load in W
91
+ """
92
+ # Get roof properties
93
+ u_value = roof.u_value
94
+ area = roof.area
95
+ roof_group = roof.roof_group
96
+
97
+ # Calculate corrected CLTD
98
+ cltd = self.ashrae_tables.calculate_corrected_cltd_roof(
99
+ roof_group=roof_group,
100
+ hour=hour,
101
+ color=color,
102
+ month=month,
103
+ latitude=latitude,
104
+ indoor_temp=indoor_temp,
105
+ outdoor_temp=outdoor_temp
106
+ )
107
+
108
+ # Calculate cooling load
109
+ cooling_load = u_value * area * cltd
110
+
111
+ return cooling_load
112
+
113
+ def calculate_window_cooling_load(self, window: Window, outdoor_temp: float, indoor_temp: float,
114
+ month: str, hour: int, latitude: str = "40N_JUL",
115
+ shading_coefficient: float = 1.0) -> Dict[str, float]:
116
+ """
117
+ Calculate cooling load through a window using the CLTD/SCL method.
118
+
119
+ Args:
120
+ window: Window object
121
+ outdoor_temp: Outdoor temperature in °C
122
+ indoor_temp: Indoor temperature in °C
123
+ month: Month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
124
+ hour: Hour of the day (0-23)
125
+ latitude: Latitude and month key (default: "40N_JUL")
126
+ shading_coefficient: Shading coefficient (0-1)
127
+
128
+ Returns:
129
+ Dictionary with conduction, solar, and total cooling loads in W
130
+ """
131
+ # Get window properties
132
+ u_value = window.u_value
133
+ area = window.area
134
+ orientation = window.orientation.value
135
+ shgc = window.shgc
136
+
137
+ # Calculate conduction cooling load
138
+ delta_t = outdoor_temp - indoor_temp
139
+ conduction_load = u_value * area * delta_t
140
+
141
+ # Calculate solar cooling load
142
+ scl = self.ashrae_tables.get_scl(orientation, hour, latitude)
143
+ solar_load = area * shgc * shading_coefficient * scl
144
+
145
+ # Calculate total cooling load
146
+ total_load = conduction_load + solar_load
147
+
148
+ return {
149
+ "conduction": conduction_load,
150
+ "solar": solar_load,
151
+ "total": total_load
152
+ }
153
+
154
+ def calculate_door_cooling_load(self, door: Door, outdoor_temp: float, indoor_temp: float) -> float:
155
+ """
156
+ Calculate cooling load through a door using simple conduction.
157
+
158
+ Args:
159
+ door: Door object
160
+ outdoor_temp: Outdoor temperature in °C
161
+ indoor_temp: Indoor temperature in °C
162
+
163
+ Returns:
164
+ Cooling load in W
165
+ """
166
+ # Get door properties
167
+ u_value = door.u_value
168
+ area = door.area
169
+
170
+ # Calculate cooling load
171
+ delta_t = outdoor_temp - indoor_temp
172
+ cooling_load = u_value * area * delta_t
173
+
174
+ return cooling_load
175
+
176
+ def calculate_floor_cooling_load(self, floor: Floor, ground_temp: float, indoor_temp: float) -> float:
177
+ """
178
+ Calculate cooling load through a floor.
179
+
180
+ Args:
181
+ floor: Floor object
182
+ ground_temp: Ground or adjacent space temperature in °C
183
+ indoor_temp: Indoor temperature in °C
184
+
185
+ Returns:
186
+ Cooling load in W
187
+ """
188
+ # Get floor properties
189
+ u_value = floor.u_value
190
+ area = floor.area
191
+
192
+ # Calculate cooling load
193
+ delta_t = ground_temp - indoor_temp
194
+ cooling_load = u_value * area * delta_t
195
+
196
+ # Return positive value for heat gain, zero for heat loss
197
+ return max(0, cooling_load)
198
+
199
+ def calculate_infiltration_cooling_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
200
+ outdoor_rh: float, indoor_rh: float) -> Dict[str, float]:
201
+ """
202
+ Calculate sensible and latent cooling loads due to infiltration.
203
+
204
+ Args:
205
+ flow_rate: Infiltration flow rate in m³/s
206
+ outdoor_temp: Outdoor temperature in °C
207
+ indoor_temp: Indoor temperature in °C
208
+ outdoor_rh: Outdoor relative humidity in %
209
+ indoor_rh: Indoor relative humidity in %
210
+
211
+ Returns:
212
+ Dictionary with sensible, latent, and total cooling loads in W
213
+ """
214
+ # Calculate sensible cooling load
215
+ sensible_load = self.heat_transfer.infiltration_heat_transfer(
216
+ flow_rate=flow_rate,
217
+ delta_t=outdoor_temp - indoor_temp
218
+ )
219
+
220
+ # Calculate humidity ratios
221
+ w_outdoor = self.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh)
222
+ w_indoor = self.psychrometrics.humidity_ratio(indoor_temp, indoor_rh)
223
+
224
+ # Calculate latent cooling load
225
+ latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
226
+ flow_rate=flow_rate,
227
+ delta_w=w_outdoor - w_indoor
228
+ )
229
+
230
+ # Calculate total cooling load
231
+ total_load = sensible_load + latent_load
232
+
233
+ return {
234
+ "sensible": sensible_load,
235
+ "latent": latent_load,
236
+ "total": total_load
237
+ }
238
+
239
+ def calculate_ventilation_cooling_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
240
+ outdoor_rh: float, indoor_rh: float) -> Dict[str, float]:
241
+ """
242
+ Calculate sensible and latent cooling loads due to ventilation.
243
+
244
+ Args:
245
+ flow_rate: Ventilation flow rate in m³/s
246
+ outdoor_temp: Outdoor temperature in °C
247
+ indoor_temp: Indoor temperature in °C
248
+ outdoor_rh: Outdoor relative humidity in %
249
+ indoor_rh: Indoor relative humidity in %
250
+
251
+ Returns:
252
+ Dictionary with sensible, latent, and total cooling loads in W
253
+ """
254
+ # Ventilation load calculation is the same as infiltration
255
+ return self.calculate_infiltration_cooling_load(
256
+ flow_rate=flow_rate,
257
+ outdoor_temp=outdoor_temp,
258
+ indoor_temp=indoor_temp,
259
+ outdoor_rh=outdoor_rh,
260
+ indoor_rh=indoor_rh
261
+ )
262
+
263
+ def calculate_people_cooling_load(self, num_people: int, activity_level: str,
264
+ hours_occupancy: str, hour: int) -> Dict[str, float]:
265
+ """
266
+ Calculate sensible and latent cooling loads due to people.
267
+
268
+ Args:
269
+ num_people: Number of people
270
+ activity_level: Activity level (Seated/Resting, Light work, Medium work, Heavy work)
271
+ hours_occupancy: Hours of occupancy (8h, 10h, 12h, 14h, 16h, 18h, 24h)
272
+ hour: Hour of the day (0-23)
273
+
274
+ Returns:
275
+ Dictionary with sensible, latent, and total cooling loads in W
276
+ """
277
+ # Define heat gains for different activity levels
278
+ activity_gains = {
279
+ "Seated/Resting": {"sensible": 70, "latent": 45},
280
+ "Light work": {"sensible": 75, "latent": 55},
281
+ "Medium work": {"sensible": 85, "latent": 80},
282
+ "Heavy work": {"sensible": 95, "latent": 145}
283
+ }
284
+
285
+ # Get heat gains for the specified activity level
286
+ if activity_level not in activity_gains:
287
+ raise ValueError(f"Invalid activity level: {activity_level}")
288
+
289
+ sensible_gain = activity_gains[activity_level]["sensible"]
290
+ latent_gain = activity_gains[activity_level]["latent"]
291
+
292
+ # Get CLF for the specified hour and occupancy
293
+ clf = self.ashrae_tables.get_clf_people(hour, hours_occupancy)
294
+
295
+ # Calculate cooling loads
296
+ sensible_load = num_people * sensible_gain * clf
297
+ latent_load = num_people * latent_gain # Latent load is not affected by CLF
298
+ total_load = sensible_load + latent_load
299
+
300
+ return {
301
+ "sensible": sensible_load,
302
+ "latent": latent_load,
303
+ "total": total_load
304
+ }
305
+
306
+ def calculate_lights_cooling_load(self, power: float, use_factor: float,
307
+ special_allowance: float, hours_operation: str,
308
+ hour: int) -> float:
309
+ """
310
+ Calculate cooling load due to lights.
311
+
312
+ Args:
313
+ power: Installed lighting power in W
314
+ use_factor: Usage factor (0-1)
315
+ special_allowance: Special allowance factor for fixtures (0-1)
316
+ hours_operation: Hours of operation (8h, 10h, 12h, 14h, 16h, 18h, 24h)
317
+ hour: Hour of the day (0-23)
318
+
319
+ Returns:
320
+ Cooling load in W
321
+ """
322
+ # Get CLF for the specified hour and operation
323
+ clf = self.ashrae_tables.get_clf_lights(hour, hours_operation)
324
+
325
+ # Calculate cooling load
326
+ cooling_load = power * use_factor * (1 + special_allowance) * clf
327
+
328
+ return cooling_load
329
+
330
+ def calculate_equipment_cooling_load(self, power: float, use_factor: float,
331
+ radiation_factor: float, hours_operation: str,
332
+ hour: int) -> Dict[str, float]:
333
+ """
334
+ Calculate sensible and latent cooling loads due to equipment.
335
+
336
+ Args:
337
+ power: Equipment power in W
338
+ use_factor: Usage factor (0-1)
339
+ radiation_factor: Radiation factor (0-1)
340
+ hours_operation: Hours of operation (8h, 10h, 12h, 14h, 16h, 18h, 24h)
341
+ hour: Hour of the day (0-23)
342
+
343
+ Returns:
344
+ Dictionary with sensible, latent, and total cooling loads in W
345
+ """
346
+ # Get CLF for the specified hour and operation
347
+ clf = self.ashrae_tables.get_clf_equipment(hour, hours_operation)
348
+
349
+ # Calculate sensible cooling load
350
+ sensible_load = power * use_factor * radiation_factor * clf
351
+
352
+ # Calculate latent cooling load (if any)
353
+ latent_load = power * use_factor * (1 - radiation_factor)
354
+
355
+ # Calculate total cooling load
356
+ total_load = sensible_load + latent_load
357
+
358
+ return {
359
+ "sensible": sensible_load,
360
+ "latent": latent_load,
361
+ "total": total_load
362
+ }
363
+
364
+ def calculate_hourly_cooling_loads(self, building_components: Dict[str, List[Any]],
365
+ outdoor_conditions: Dict[str, Any],
366
+ indoor_conditions: Dict[str, Any],
367
+ internal_loads: Dict[str, Any]) -> Dict[int, Dict[str, float]]:
368
+ """
369
+ Calculate hourly cooling loads for a building.
370
+
371
+ Args:
372
+ building_components: Dictionary with lists of building components
373
+ outdoor_conditions: Dictionary with outdoor conditions
374
+ indoor_conditions: Dictionary with indoor conditions
375
+ internal_loads: Dictionary with internal loads
376
+
377
+ Returns:
378
+ Dictionary with hourly cooling loads
379
+ """
380
+ # Extract building components
381
+ walls = building_components.get("walls", [])
382
+ roofs = building_components.get("roofs", [])
383
+ floors = building_components.get("floors", [])
384
+ windows = building_components.get("windows", [])
385
+ doors = building_components.get("doors", [])
386
+
387
+ # Extract outdoor conditions
388
+ outdoor_temp = outdoor_conditions.get("temperature", 35.0)
389
+ outdoor_rh = outdoor_conditions.get("relative_humidity", 50.0)
390
+ ground_temp = outdoor_conditions.get("ground_temperature", 20.0)
391
+ month = outdoor_conditions.get("month", "Jul")
392
+ latitude = outdoor_conditions.get("latitude", "40N")
393
+
394
+ # Extract indoor conditions
395
+ indoor_temp = indoor_conditions.get("temperature", 24.0)
396
+ indoor_rh = indoor_conditions.get("relative_humidity", 50.0)
397
+
398
+ # Extract internal loads
399
+ people = internal_loads.get("people", {})
400
+ lights = internal_loads.get("lights", {})
401
+ equipment = internal_loads.get("equipment", {})
402
+ infiltration = internal_loads.get("infiltration", {})
403
+ ventilation = internal_loads.get("ventilation", {})
404
+
405
+ # Initialize hourly cooling loads
406
+ hourly_loads = {}
407
+
408
+ # Calculate cooling loads for each hour
409
+ for hour in range(24):
410
+ # Initialize loads for this hour
411
+ loads = {
412
+ "walls": 0,
413
+ "roofs": 0,
414
+ "floors": 0,
415
+ "windows_conduction": 0,
416
+ "windows_solar": 0,
417
+ "doors": 0,
418
+ "infiltration_sensible": 0,
419
+ "infiltration_latent": 0,
420
+ "ventilation_sensible": 0,
421
+ "ventilation_latent": 0,
422
+ "people_sensible": 0,
423
+ "people_latent": 0,
424
+ "lights": 0,
425
+ "equipment_sensible": 0,
426
+ "equipment_latent": 0,
427
+ "total_sensible": 0,
428
+ "total_latent": 0,
429
+ "total": 0
430
+ }
431
+
432
+ # Calculate wall loads
433
+ for wall in walls:
434
+ wall_load = self.calculate_wall_cooling_load(
435
+ wall=wall,
436
+ outdoor_temp=outdoor_temp,
437
+ indoor_temp=indoor_temp,
438
+ month=month,
439
+ hour=hour,
440
+ latitude=latitude,
441
+ color=wall.color if hasattr(wall, "color") else "Dark"
442
+ )
443
+ loads["walls"] += wall_load
444
+
445
+ # Calculate roof loads
446
+ for roof in roofs:
447
+ roof_load = self.calculate_roof_cooling_load(
448
+ roof=roof,
449
+ outdoor_temp=outdoor_temp,
450
+ indoor_temp=indoor_temp,
451
+ month=month,
452
+ hour=hour,
453
+ latitude=latitude,
454
+ color=roof.color if hasattr(roof, "color") else "Dark"
455
+ )
456
+ loads["roofs"] += roof_load
457
+
458
+ # Calculate floor loads
459
+ for floor in floors:
460
+ floor_load = self.calculate_floor_cooling_load(
461
+ floor=floor,
462
+ ground_temp=ground_temp,
463
+ indoor_temp=indoor_temp
464
+ )
465
+ loads["floors"] += floor_load
466
+
467
+ # Calculate window loads
468
+ for window in windows:
469
+ window_loads = self.calculate_window_cooling_load(
470
+ window=window,
471
+ outdoor_temp=outdoor_temp,
472
+ indoor_temp=indoor_temp,
473
+ month=month,
474
+ hour=hour,
475
+ latitude=f"{latitude}_{month.upper()}",
476
+ shading_coefficient=window.shading_coefficient if hasattr(window, "shading_coefficient") else 1.0
477
+ )
478
+ loads["windows_conduction"] += window_loads["conduction"]
479
+ loads["windows_solar"] += window_loads["solar"]
480
+
481
+ # Calculate door loads
482
+ for door in doors:
483
+ door_load = self.calculate_door_cooling_load(
484
+ door=door,
485
+ outdoor_temp=outdoor_temp,
486
+ indoor_temp=indoor_temp
487
+ )
488
+ loads["doors"] += door_load
489
+
490
+ # Calculate infiltration loads
491
+ if infiltration:
492
+ flow_rate = infiltration.get("flow_rate", 0.0)
493
+ infiltration_loads = self.calculate_infiltration_cooling_load(
494
+ flow_rate=flow_rate,
495
+ outdoor_temp=outdoor_temp,
496
+ indoor_temp=indoor_temp,
497
+ outdoor_rh=outdoor_rh,
498
+ indoor_rh=indoor_rh
499
+ )
500
+ loads["infiltration_sensible"] = infiltration_loads["sensible"]
501
+ loads["infiltration_latent"] = infiltration_loads["latent"]
502
+
503
+ # Calculate ventilation loads
504
+ if ventilation:
505
+ flow_rate = ventilation.get("flow_rate", 0.0)
506
+ ventilation_loads = self.calculate_ventilation_cooling_load(
507
+ flow_rate=flow_rate,
508
+ outdoor_temp=outdoor_temp,
509
+ indoor_temp=indoor_temp,
510
+ outdoor_rh=outdoor_rh,
511
+ indoor_rh=indoor_rh
512
+ )
513
+ loads["ventilation_sensible"] = ventilation_loads["sensible"]
514
+ loads["ventilation_latent"] = ventilation_loads["latent"]
515
+
516
+ # Calculate people loads
517
+ if people:
518
+ num_people = people.get("number", 0)
519
+ activity_level = people.get("activity_level", "Seated/Resting")
520
+ hours_occupancy = people.get("hours_occupancy", "8h")
521
+
522
+ people_loads = self.calculate_people_cooling_load(
523
+ num_people=num_people,
524
+ activity_level=activity_level,
525
+ hours_occupancy=hours_occupancy,
526
+ hour=hour
527
+ )
528
+ loads["people_sensible"] = people_loads["sensible"]
529
+ loads["people_latent"] = people_loads["latent"]
530
+
531
+ # Calculate lights loads
532
+ if lights:
533
+ power = lights.get("power", 0.0)
534
+ use_factor = lights.get("use_factor", 1.0)
535
+ special_allowance = lights.get("special_allowance", 0.0)
536
+ hours_operation = lights.get("hours_operation", "8h")
537
+
538
+ lights_load = self.calculate_lights_cooling_load(
539
+ power=power,
540
+ use_factor=use_factor,
541
+ special_allowance=special_allowance,
542
+ hours_operation=hours_operation,
543
+ hour=hour
544
+ )
545
+ loads["lights"] = lights_load
546
+
547
+ # Calculate equipment loads
548
+ if equipment:
549
+ power = equipment.get("power", 0.0)
550
+ use_factor = equipment.get("use_factor", 1.0)
551
+ radiation_factor = equipment.get("radiation_factor", 0.7)
552
+ hours_operation = equipment.get("hours_operation", "8h")
553
+
554
+ equipment_loads = self.calculate_equipment_cooling_load(
555
+ power=power,
556
+ use_factor=use_factor,
557
+ radiation_factor=radiation_factor,
558
+ hours_operation=hours_operation,
559
+ hour=hour
560
+ )
561
+ loads["equipment_sensible"] = equipment_loads["sensible"]
562
+ loads["equipment_latent"] = equipment_loads["latent"]
563
+
564
+ # Calculate total loads
565
+ loads["total_sensible"] = (
566
+ loads["walls"] + loads["roofs"] + loads["floors"] +
567
+ loads["windows_conduction"] + loads["windows_solar"] +
568
+ loads["doors"] + loads["infiltration_sensible"] +
569
+ loads["ventilation_sensible"] + loads["people_sensible"] +
570
+ loads["lights"] + loads["equipment_sensible"]
571
+ )
572
+
573
+ loads["total_latent"] = (
574
+ loads["infiltration_latent"] + loads["ventilation_latent"] +
575
+ loads["people_latent"] + loads["equipment_latent"]
576
+ )
577
+
578
+ loads["total"] = loads["total_sensible"] + loads["total_latent"]
579
+
580
+ # Store loads for this hour
581
+ hourly_loads[hour] = loads
582
+
583
+ return hourly_loads
584
+
585
+ def calculate_design_cooling_load(self, hourly_loads: Dict[int, Dict[str, float]]) -> Dict[str, float]:
586
+ """
587
+ Calculate design cooling load based on hourly loads.
588
+
589
+ Args:
590
+ hourly_loads: Dictionary with hourly cooling loads
591
+
592
+ Returns:
593
+ Dictionary with design cooling loads
594
+ """
595
+ # Find hour with maximum total load
596
+ max_hour = max(hourly_loads.keys(), key=lambda h: hourly_loads[h]["total"])
597
+
598
+ # Get loads for the design hour
599
+ design_loads = hourly_loads[max_hour].copy()
600
+
601
+ # Add design hour information
602
+ design_loads["design_hour"] = max_hour
603
+
604
+ return design_loads
605
+
606
+ def calculate_cooling_load_summary(self, design_loads: Dict[str, float]) -> Dict[str, float]:
607
+ """
608
+ Calculate cooling load summary.
609
+
610
+ Args:
611
+ design_loads: Dictionary with design cooling loads
612
+
613
+ Returns:
614
+ Dictionary with cooling load summary
615
+ """
616
+ # Calculate envelope loads
617
+ envelope_loads = (
618
+ design_loads["walls"] + design_loads["roofs"] + design_loads["floors"] +
619
+ design_loads["windows_conduction"] + design_loads["windows_solar"] +
620
+ design_loads["doors"]
621
+ )
622
+
623
+ # Calculate ventilation and infiltration loads
624
+ ventilation_loads = design_loads["ventilation_sensible"] + design_loads["ventilation_latent"]
625
+ infiltration_loads = design_loads["infiltration_sensible"] + design_loads["infiltration_latent"]
626
+
627
+ # Calculate internal loads
628
+ internal_loads = (
629
+ design_loads["people_sensible"] + design_loads["people_latent"] +
630
+ design_loads["lights"] + design_loads["equipment_sensible"] + design_loads["equipment_latent"]
631
+ )
632
+
633
+ # Calculate sensible heat ratio
634
+ shr = design_loads["total_sensible"] / design_loads["total"] if design_loads["total"] > 0 else 1.0
635
+
636
+ # Create summary
637
+ summary = {
638
+ "envelope_loads": envelope_loads,
639
+ "ventilation_loads": ventilation_loads,
640
+ "infiltration_loads": infiltration_loads,
641
+ "internal_loads": internal_loads,
642
+ "total_sensible": design_loads["total_sensible"],
643
+ "total_latent": design_loads["total_latent"],
644
+ "total": design_loads["total"],
645
+ "sensible_heat_ratio": shr,
646
+ "design_hour": design_loads["design_hour"]
647
+ }
648
+
649
+ return summary
650
+
651
+
652
+ # Create a singleton instance
653
+ cooling_load_calculator = CoolingLoadCalculator()
654
+
655
+ # Example usage
656
+ if __name__ == "__main__":
657
+ # Create sample building components
658
+ from data.building_components import Wall, Roof, Window, Door, Orientation, ComponentType
659
+
660
+ # Create a sample wall
661
+ wall = Wall(
662
+ id="wall1",
663
+ name="Exterior Wall",
664
+ component_type=ComponentType.WALL,
665
+ u_value=0.5,
666
+ area=20.0,
667
+ orientation=Orientation.SOUTH,
668
+ wall_type="Brick",
669
+ wall_group="B"
670
+ )
671
+
672
+ # Create a sample roof
673
+ roof = Roof(
674
+ id="roof1",
675
+ name="Flat Roof",
676
+ component_type=ComponentType.ROOF,
677
+ u_value=0.3,
678
+ area=50.0,
679
+ orientation=Orientation.HORIZONTAL,
680
+ roof_type="Concrete",
681
+ roof_group="C"
682
+ )
683
+
684
+ # Create a sample window
685
+ window = Window(
686
+ id="window1",
687
+ name="South Window",
688
+ component_type=ComponentType.WINDOW,
689
+ u_value=2.8,
690
+ area=5.0,
691
+ orientation=Orientation.SOUTH,
692
+ shgc=0.7,
693
+ vt=0.8,
694
+ window_type="Double Glazed",
695
+ glazing_layers=2,
696
+ gas_fill="Air",
697
+ low_e_coating=False
698
+ )
699
+
700
+ # Define building components
701
+ building_components = {
702
+ "walls": [wall],
703
+ "roofs": [roof],
704
+ "windows": [window],
705
+ "doors": [],
706
+ "floors": []
707
+ }
708
+
709
+ # Define conditions
710
+ outdoor_conditions = {
711
+ "temperature": 35.0,
712
+ "relative_humidity": 50.0,
713
+ "ground_temperature": 20.0,
714
+ "month": "Jul",
715
+ "latitude": "40N"
716
+ }
717
+
718
+ indoor_conditions = {
719
+ "temperature": 24.0,
720
+ "relative_humidity": 50.0
721
+ }
722
+
723
+ # Define internal loads
724
+ internal_loads = {
725
+ "people": {
726
+ "number": 3,
727
+ "activity_level": "Seated/Resting",
728
+ "hours_occupancy": "8h"
729
+ },
730
+ "lights": {
731
+ "power": 500.0,
732
+ "use_factor": 0.9,
733
+ "special_allowance": 0.1,
734
+ "hours_operation": "8h"
735
+ },
736
+ "equipment": {
737
+ "power": 1000.0,
738
+ "use_factor": 0.7,
739
+ "radiation_factor": 0.7,
740
+ "hours_operation": "8h"
741
+ },
742
+ "infiltration": {
743
+ "flow_rate": 0.05
744
+ },
745
+ "ventilation": {
746
+ "flow_rate": 0.1
747
+ }
748
+ }
749
+
750
+ # Calculate hourly cooling loads
751
+ hourly_loads = cooling_load_calculator.calculate_hourly_cooling_loads(
752
+ building_components=building_components,
753
+ outdoor_conditions=outdoor_conditions,
754
+ indoor_conditions=indoor_conditions,
755
+ internal_loads=internal_loads
756
+ )
757
+
758
+ # Calculate design cooling load
759
+ design_loads = cooling_load_calculator.calculate_design_cooling_load(hourly_loads)
760
+
761
+ # Calculate cooling load summary
762
+ summary = cooling_load_calculator.calculate_cooling_load_summary(design_loads)
763
+
764
+ # Print results
765
+ print("Cooling Load Summary:")
766
+ print(f"Envelope Loads: {summary['envelope_loads']:.2f} W")
767
+ print(f"Ventilation Loads: {summary['ventilation_loads']:.2f} W")
768
+ print(f"Infiltration Loads: {summary['infiltration_loads']:.2f} W")
769
+ print(f"Internal Loads: {summary['internal_loads']:.2f} W")
770
+ print(f"Total Sensible: {summary['total_sensible']:.2f} W")
771
+ print(f"Total Latent: {summary['total_latent']:.2f} W")
772
+ print(f"Total: {summary['total']:.2f} W")
773
+ print(f"Sensible Heat Ratio: {summary['sensible_heat_ratio']:.2f}")
774
+ print(f"Design Hour: {summary['design_hour']}")
utils/heat_transfer.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared calculation functions module for HVAC Load Calculator.
3
+ This module implements common heat transfer calculations used in both cooling and heating load calculations.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import math
8
+ import numpy as np
9
+ import pandas as pd
10
+ import os
11
+
12
+ # Import data models and utilities
13
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation
14
+ from utils.psychrometrics import Psychrometrics
15
+
16
+ # Define constants
17
+ STEFAN_BOLTZMANN_CONSTANT = 5.67e-8 # W/(m²·K⁴)
18
+ SOLAR_CONSTANT = 1367 # W/m²
19
+ EARTH_TILT_ANGLE = 23.45 # degrees
20
+
21
+
22
+ class HeatTransferCalculations:
23
+ """Class for shared heat transfer calculations."""
24
+
25
+ @staticmethod
26
+ def conduction_heat_transfer(u_value: float, area: float, delta_t: float) -> float:
27
+ """
28
+ Calculate conduction heat transfer through a building component.
29
+
30
+ Args:
31
+ u_value: U-value of the component in W/(m²·K)
32
+ area: Area of the component in m²
33
+ delta_t: Temperature difference across the component in K (or °C)
34
+
35
+ Returns:
36
+ Heat transfer rate in W
37
+ """
38
+ return u_value * area * delta_t
39
+
40
+ @staticmethod
41
+ def convection_heat_transfer(h_c: float, area: float, delta_t: float) -> float:
42
+ """
43
+ Calculate convection heat transfer.
44
+
45
+ Args:
46
+ h_c: Convection heat transfer coefficient in W/(m²·K)
47
+ area: Surface area in m²
48
+ delta_t: Temperature difference between surface and fluid in K (or °C)
49
+
50
+ Returns:
51
+ Heat transfer rate in W
52
+ """
53
+ return h_c * area * delta_t
54
+
55
+ @staticmethod
56
+ def radiation_heat_transfer(emissivity: float, area: float, t_surface: float, t_surroundings: float) -> float:
57
+ """
58
+ Calculate radiation heat transfer.
59
+
60
+ Args:
61
+ emissivity: Surface emissivity (0-1)
62
+ area: Surface area in m²
63
+ t_surface: Surface temperature in K
64
+ t_surroundings: Surroundings temperature in K
65
+
66
+ Returns:
67
+ Heat transfer rate in W
68
+ """
69
+ return emissivity * STEFAN_BOLTZMANN_CONSTANT * area * (t_surface**4 - t_surroundings**4)
70
+
71
+ @staticmethod
72
+ def infiltration_heat_transfer(flow_rate: float, delta_t: float, density: float = 1.2, specific_heat: float = 1006) -> float:
73
+ """
74
+ Calculate sensible heat transfer due to infiltration or ventilation.
75
+
76
+ Args:
77
+ flow_rate: Volumetric flow rate in m³/s
78
+ delta_t: Temperature difference between indoor and outdoor air in K (or °C)
79
+ density: Air density in kg/m³ (default: 1.2 kg/m³)
80
+ specific_heat: Specific heat capacity of air in J/(kg·K) (default: 1006 J/(kg·K))
81
+
82
+ Returns:
83
+ Heat transfer rate in W
84
+ """
85
+ return flow_rate * density * specific_heat * delta_t
86
+
87
+ @staticmethod
88
+ def infiltration_latent_heat_transfer(flow_rate: float, delta_w: float, density: float = 1.2, latent_heat: float = 2501000) -> float:
89
+ """
90
+ Calculate latent heat transfer due to infiltration or ventilation.
91
+
92
+ Args:
93
+ flow_rate: Volumetric flow rate in m³/s
94
+ delta_w: Humidity ratio difference between indoor and outdoor air in kg/kg
95
+ density: Air density in kg/m³ (default: 1.2 kg/m³)
96
+ latent_heat: Latent heat of vaporization in J/kg (default: 2501000 J/kg)
97
+
98
+ Returns:
99
+ Heat transfer rate in W
100
+ """
101
+ return flow_rate * density * latent_heat * delta_w
102
+
103
+ @staticmethod
104
+ def air_exchange_rate_to_flow_rate(ach: float, volume: float) -> float:
105
+ """
106
+ Convert air changes per hour to volumetric flow rate.
107
+
108
+ Args:
109
+ ach: Air changes per hour (1/h)
110
+ volume: Room or building volume in m³
111
+
112
+ Returns:
113
+ Volumetric flow rate in m³/s
114
+ """
115
+ return ach * volume / 3600
116
+
117
+ @staticmethod
118
+ def flow_rate_to_air_exchange_rate(flow_rate: float, volume: float) -> float:
119
+ """
120
+ Convert volumetric flow rate to air changes per hour.
121
+
122
+ Args:
123
+ flow_rate: Volumetric flow rate in m³/s
124
+ volume: Room or building volume in m³
125
+
126
+ Returns:
127
+ Air changes per hour (1/h)
128
+ """
129
+ return flow_rate * 3600 / volume
130
+
131
+ @staticmethod
132
+ def crack_method_infiltration(crack_length: float, coefficient: float, pressure_difference: float, exponent: float = 0.65) -> float:
133
+ """
134
+ Calculate infiltration using the crack method.
135
+
136
+ Args:
137
+ crack_length: Length of cracks in m
138
+ coefficient: Flow coefficient in m³/(s·m·Pa^n)
139
+ pressure_difference: Pressure difference in Pa
140
+ exponent: Flow exponent (default: 0.65)
141
+
142
+ Returns:
143
+ Infiltration flow rate in m³/s
144
+ """
145
+ return coefficient * crack_length * pressure_difference**exponent
146
+
147
+ @staticmethod
148
+ def wind_pressure_difference(wind_speed: float, wind_coefficient: float, density: float = 1.2) -> float:
149
+ """
150
+ Calculate pressure difference due to wind.
151
+
152
+ Args:
153
+ wind_speed: Wind speed in m/s
154
+ wind_coefficient: Wind pressure coefficient (dimensionless)
155
+ density: Air density in kg/m³ (default: 1.2 kg/m³)
156
+
157
+ Returns:
158
+ Pressure difference in Pa
159
+ """
160
+ return 0.5 * density * wind_speed**2 * wind_coefficient
161
+
162
+ @staticmethod
163
+ def stack_pressure_difference(height: float, indoor_temp: float, outdoor_temp: float,
164
+ neutral_plane_height: float = None, gravity: float = 9.81) -> float:
165
+ """
166
+ Calculate pressure difference due to stack effect.
167
+
168
+ Args:
169
+ height: Height from reference level in m
170
+ indoor_temp: Indoor temperature in K
171
+ outdoor_temp: Outdoor temperature in K
172
+ neutral_plane_height: Height of neutral pressure plane in m (default: half of height)
173
+ gravity: Acceleration due to gravity in m/s² (default: 9.81 m/s²)
174
+
175
+ Returns:
176
+ Pressure difference in Pa
177
+ """
178
+ if neutral_plane_height is None:
179
+ neutral_plane_height = height / 2
180
+
181
+ # Calculate pressure difference
182
+ return gravity * (height - neutral_plane_height) * (outdoor_temp - indoor_temp) / outdoor_temp
183
+
184
+ @staticmethod
185
+ def combined_pressure_difference(wind_pd: float, stack_pd: float) -> float:
186
+ """
187
+ Calculate combined pressure difference from wind and stack effects.
188
+
189
+ Args:
190
+ wind_pd: Pressure difference due to wind in Pa
191
+ stack_pd: Pressure difference due to stack effect in Pa
192
+
193
+ Returns:
194
+ Combined pressure difference in Pa
195
+ """
196
+ # Simple quadrature combination
197
+ return math.sqrt(wind_pd**2 + stack_pd**2)
198
+
199
+ @staticmethod
200
+ def solar_declination(day_of_year: int) -> float:
201
+ """
202
+ Calculate solar declination angle.
203
+
204
+ Args:
205
+ day_of_year: Day of the year (1-365)
206
+
207
+ Returns:
208
+ Solar declination angle in degrees
209
+ """
210
+ return EARTH_TILT_ANGLE * math.sin(2 * math.pi * (day_of_year - 81) / 365)
211
+
212
+ @staticmethod
213
+ def solar_hour_angle(solar_time: float) -> float:
214
+ """
215
+ Calculate solar hour angle.
216
+
217
+ Args:
218
+ solar_time: Solar time in hours (0-24)
219
+
220
+ Returns:
221
+ Solar hour angle in degrees
222
+ """
223
+ return 15 * (solar_time - 12)
224
+
225
+ @staticmethod
226
+ def solar_altitude(latitude: float, declination: float, hour_angle: float) -> float:
227
+ """
228
+ Calculate solar altitude angle.
229
+
230
+ Args:
231
+ latitude: Latitude in degrees
232
+ declination: Solar declination angle in degrees
233
+ hour_angle: Solar hour angle in degrees
234
+
235
+ Returns:
236
+ Solar altitude angle in degrees
237
+ """
238
+ # Convert angles to radians
239
+ lat_rad = math.radians(latitude)
240
+ decl_rad = math.radians(declination)
241
+ hour_rad = math.radians(hour_angle)
242
+
243
+ # Calculate solar altitude
244
+ sin_altitude = (math.sin(lat_rad) * math.sin(decl_rad) +
245
+ math.cos(lat_rad) * math.cos(decl_rad) * math.cos(hour_rad))
246
+
247
+ return math.degrees(math.asin(sin_altitude))
248
+
249
+ @staticmethod
250
+ def solar_azimuth(latitude: float, declination: float, hour_angle: float, altitude: float) -> float:
251
+ """
252
+ Calculate solar azimuth angle.
253
+
254
+ Args:
255
+ latitude: Latitude in degrees
256
+ declination: Solar declination angle in degrees
257
+ hour_angle: Solar hour angle in degrees
258
+ altitude: Solar altitude angle in degrees
259
+
260
+ Returns:
261
+ Solar azimuth angle in degrees (0° = South, positive westward)
262
+ """
263
+ # Convert angles to radians
264
+ lat_rad = math.radians(latitude)
265
+ decl_rad = math.radians(declination)
266
+ hour_rad = math.radians(hour_angle)
267
+ alt_rad = math.radians(altitude)
268
+
269
+ # Calculate solar azimuth
270
+ cos_azimuth = ((math.sin(decl_rad) * math.cos(lat_rad) -
271
+ math.cos(decl_rad) * math.sin(lat_rad) * math.cos(hour_rad)) /
272
+ math.cos(alt_rad))
273
+
274
+ # Constrain to [-1, 1] to avoid domain errors
275
+ cos_azimuth = max(-1, min(1, cos_azimuth))
276
+
277
+ # Calculate azimuth angle
278
+ azimuth = math.degrees(math.acos(cos_azimuth))
279
+
280
+ # Adjust for morning hours (negative hour angle)
281
+ if hour_angle < 0:
282
+ azimuth = -azimuth
283
+
284
+ return azimuth
285
+
286
+ @staticmethod
287
+ def incident_angle(surface_tilt: float, surface_azimuth: float,
288
+ solar_altitude: float, solar_azimuth: float) -> float:
289
+ """
290
+ Calculate angle of incidence on a surface.
291
+
292
+ Args:
293
+ surface_tilt: Surface tilt angle from horizontal in degrees (0° = horizontal, 90° = vertical)
294
+ surface_azimuth: Surface azimuth angle in degrees (0° = South, positive westward)
295
+ solar_altitude: Solar altitude angle in degrees
296
+ solar_azimuth: Solar azimuth angle in degrees
297
+
298
+ Returns:
299
+ Incident angle in degrees
300
+ """
301
+ # Convert angles to radians
302
+ surf_tilt_rad = math.radians(surface_tilt)
303
+ surf_azim_rad = math.radians(surface_azimuth)
304
+ solar_alt_rad = math.radians(solar_altitude)
305
+ solar_azim_rad = math.radians(solar_azimuth)
306
+
307
+ # Calculate incident angle
308
+ cos_incident = (math.sin(solar_alt_rad) * math.cos(surf_tilt_rad) +
309
+ math.cos(solar_alt_rad) * math.sin(surf_tilt_rad) *
310
+ math.cos(solar_azim_rad - surf_azim_rad))
311
+
312
+ # Constrain to [-1, 1] to avoid domain errors
313
+ cos_incident = max(-1, min(1, cos_incident))
314
+
315
+ return math.degrees(math.acos(cos_incident))
316
+
317
+ @staticmethod
318
+ def direct_normal_irradiance(altitude: float, atmospheric_clearness: float = 1.0) -> float:
319
+ """
320
+ Calculate direct normal irradiance.
321
+
322
+ Args:
323
+ altitude: Solar altitude angle in degrees
324
+ atmospheric_clearness: Atmospheric clearness factor (0-1)
325
+
326
+ Returns:
327
+ Direct normal irradiance in W/m²
328
+ """
329
+ if altitude <= 0:
330
+ return 0
331
+
332
+ # Simple model based on air mass
333
+ air_mass = 1 / math.sin(math.radians(altitude))
334
+
335
+ # Limit air mass to reasonable values
336
+ air_mass = min(air_mass, 38)
337
+
338
+ # Calculate direct normal irradiance
339
+ dni = SOLAR_CONSTANT * atmospheric_clearness**air_mass
340
+
341
+ return dni
342
+
343
+ @staticmethod
344
+ def diffuse_horizontal_irradiance(dni: float, altitude: float, clearness: float = 0.2) -> float:
345
+ """
346
+ Calculate diffuse horizontal irradiance.
347
+
348
+ Args:
349
+ dni: Direct normal irradiance in W/m²
350
+ altitude: Solar altitude angle in degrees
351
+ clearness: Sky clearness factor (0-1)
352
+
353
+ Returns:
354
+ Diffuse horizontal irradiance in W/m²
355
+ """
356
+ if altitude <= 0:
357
+ return 0
358
+
359
+ # Simple model for diffuse irradiance
360
+ return dni * clearness * math.sin(math.radians(altitude))
361
+
362
+ @staticmethod
363
+ def global_horizontal_irradiance(dni: float, dhi: float, altitude: float) -> float:
364
+ """
365
+ Calculate global horizontal irradiance.
366
+
367
+ Args:
368
+ dni: Direct normal irradiance in W/m²
369
+ dhi: Diffuse horizontal irradiance in W/m²
370
+ altitude: Solar altitude angle in degrees
371
+
372
+ Returns:
373
+ Global horizontal irradiance in W/m²
374
+ """
375
+ if altitude <= 0:
376
+ return 0
377
+
378
+ # Calculate direct horizontal component
379
+ direct_horizontal = dni * math.sin(math.radians(altitude))
380
+
381
+ # Calculate global horizontal irradiance
382
+ return direct_horizontal + dhi
383
+
384
+ @staticmethod
385
+ def irradiance_on_surface(dni: float, dhi: float, incident_angle: float,
386
+ surface_tilt: float, ground_reflectance: float = 0.2) -> float:
387
+ """
388
+ Calculate total irradiance on a surface.
389
+
390
+ Args:
391
+ dni: Direct normal irradiance in W/m²
392
+ dhi: Diffuse horizontal irradiance in W/m²
393
+ incident_angle: Incident angle in degrees
394
+ surface_tilt: Surface tilt angle from horizontal in degrees
395
+ ground_reflectance: Ground reflectance (albedo) (0-1)
396
+
397
+ Returns:
398
+ Total irradiance on the surface in W/m²
399
+ """
400
+ # Convert angles to radians
401
+ incident_rad = math.radians(incident_angle)
402
+ tilt_rad = math.radians(surface_tilt)
403
+
404
+ # Calculate direct component
405
+ if incident_angle < 90:
406
+ direct = dni * math.cos(incident_rad)
407
+ else:
408
+ direct = 0
409
+
410
+ # Calculate diffuse component (simple isotropic model)
411
+ diffuse = dhi * (1 + math.cos(tilt_rad)) / 2
412
+
413
+ # Calculate ground-reflected component
414
+ reflected = (dni * math.sin(math.radians(incident_angle)) + dhi) * ground_reflectance * (1 - math.cos(tilt_rad)) / 2
415
+
416
+ # Calculate total irradiance
417
+ return direct + diffuse + reflected
418
+
419
+ @staticmethod
420
+ def solar_heat_gain(irradiance: float, area: float, shgc: float,
421
+ shading_coefficient: float = 1.0, frame_factor: float = 0.85) -> float:
422
+ """
423
+ Calculate solar heat gain through a window.
424
+
425
+ Args:
426
+ irradiance: Total irradiance on the window in W/m²
427
+ area: Window area in m²
428
+ shgc: Solar Heat Gain Coefficient (0-1)
429
+ shading_coefficient: External shading coefficient (0-1)
430
+ frame_factor: Ratio of glazing area to total window area (0-1)
431
+
432
+ Returns:
433
+ Solar heat gain in W
434
+ """
435
+ return irradiance * area * shgc * shading_coefficient * frame_factor
436
+
437
+ @staticmethod
438
+ def internal_gains(occupants: int, lights_power: float, equipment_power: float,
439
+ occupant_sensible_gain: float = 70, occupant_latent_gain: float = 45) -> Dict[str, float]:
440
+ """
441
+ Calculate internal heat gains.
442
+
443
+ Args:
444
+ occupants: Number of occupants
445
+ lights_power: Lighting power in W
446
+ equipment_power: Equipment power in W
447
+ occupant_sensible_gain: Sensible heat gain per occupant in W (default: 70 W)
448
+ occupant_latent_gain: Latent heat gain per occupant in W (default: 45 W)
449
+
450
+ Returns:
451
+ Dictionary with sensible, latent, and total heat gains in W
452
+ """
453
+ # Calculate occupant gains
454
+ occupant_sensible = occupants * occupant_sensible_gain
455
+ occupant_latent = occupants * occupant_latent_gain
456
+
457
+ # Calculate total sensible and latent gains
458
+ sensible_gain = occupant_sensible + lights_power + equipment_power
459
+ latent_gain = occupant_latent
460
+
461
+ return {
462
+ "sensible": sensible_gain,
463
+ "latent": latent_gain,
464
+ "total": sensible_gain + latent_gain
465
+ }
466
+
467
+ @staticmethod
468
+ def thermal_mass_effect(mass: float, specific_heat: float, delta_t: float) -> float:
469
+ """
470
+ Calculate heat storage in thermal mass.
471
+
472
+ Args:
473
+ mass: Mass of the material in kg
474
+ specific_heat: Specific heat capacity in J/(kg·K)
475
+ delta_t: Temperature change in K (or °C)
476
+
477
+ Returns:
478
+ Heat stored in J
479
+ """
480
+ return mass * specific_heat * delta_t
481
+
482
+ @staticmethod
483
+ def thermal_lag_factor(thermal_mass: float, time_constant: float, time_step: float) -> float:
484
+ """
485
+ Calculate thermal lag factor for dynamic heat transfer.
486
+
487
+ Args:
488
+ thermal_mass: Thermal mass in J/K
489
+ time_constant: Time constant in hours
490
+ time_step: Time step in hours
491
+
492
+ Returns:
493
+ Thermal lag factor (0-1)
494
+ """
495
+ return 1 - math.exp(-time_step / time_constant)
496
+
497
+ @staticmethod
498
+ def temperature_swing(heat_gain: float, thermal_mass: float) -> float:
499
+ """
500
+ Calculate temperature swing due to heat gain and thermal mass.
501
+
502
+ Args:
503
+ heat_gain: Heat gain in J
504
+ thermal_mass: Thermal mass in J/K
505
+
506
+ Returns:
507
+ Temperature swing in K (or °C)
508
+ """
509
+ return heat_gain / thermal_mass
510
+
511
+ @staticmethod
512
+ def sol_air_temperature(outdoor_temp: float, solar_irradiance: float,
513
+ surface_absorptivity: float, surface_resistance: float) -> float:
514
+ """
515
+ Calculate sol-air temperature.
516
+
517
+ Args:
518
+ outdoor_temp: Outdoor air temperature in °C
519
+ solar_irradiance: Solar irradiance on the surface in W/m²
520
+ surface_absorptivity: Surface solar absorptivity (0-1)
521
+ surface_resistance: Surface heat transfer resistance in m²·K/W
522
+
523
+ Returns:
524
+ Sol-air temperature in °C
525
+ """
526
+ return outdoor_temp + solar_irradiance * surface_absorptivity * surface_resistance
527
+
528
+
529
+ # Create a singleton instance
530
+ heat_transfer = HeatTransferCalculations()
531
+
532
+ # Example usage
533
+ if __name__ == "__main__":
534
+ # Calculate conduction heat transfer
535
+ q_cond = heat_transfer.conduction_heat_transfer(u_value=0.5, area=10, delta_t=20)
536
+ print(f"Conduction heat transfer: {q_cond:.2f} W")
537
+
538
+ # Calculate infiltration heat transfer
539
+ q_inf = heat_transfer.infiltration_heat_transfer(flow_rate=0.1, delta_t=20)
540
+ print(f"Infiltration heat transfer: {q_inf:.2f} W")
541
+
542
+ # Calculate solar heat gain
543
+ q_solar = heat_transfer.solar_heat_gain(irradiance=500, area=5, shgc=0.7)
544
+ print(f"Solar heat gain: {q_solar:.2f} W")
545
+
546
+ # Calculate internal gains
547
+ gains = heat_transfer.internal_gains(occupants=3, lights_power=200, equipment_power=500)
548
+ print(f"Internal gains - Sensible: {gains['sensible']:.2f} W, Latent: {gains['latent']:.2f} W, Total: {gains['total']:.2f} W")
utils/heating_load.py ADDED
@@ -0,0 +1,683 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Heating load calculation module for HVAC Load Calculator.
3
+ This module implements steady-state methods for calculating heating loads.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import math
8
+ import numpy as np
9
+ import pandas as pd
10
+ import os
11
+ from datetime import datetime, timedelta
12
+ from enum import Enum
13
+
14
+ # Import data models and utilities
15
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
16
+ from utils.psychrometrics import Psychrometrics
17
+ from utils.heat_transfer import HeatTransferCalculations
18
+
19
+ # Define paths
20
+ DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21
+
22
+
23
+ class HeatingLoadCalculator:
24
+ """Class for calculating heating loads using steady-state methods."""
25
+
26
+ def __init__(self):
27
+ """Initialize heating load calculator."""
28
+ self.heat_transfer = HeatTransferCalculations()
29
+ self.psychrometrics = Psychrometrics()
30
+
31
+ def calculate_wall_heating_load(self, wall: Wall, outdoor_temp: float, indoor_temp: float) -> float:
32
+ """
33
+ Calculate heating load through a wall using steady-state conduction.
34
+
35
+ Args:
36
+ wall: Wall object
37
+ outdoor_temp: Outdoor temperature in °C
38
+ indoor_temp: Indoor temperature in °C
39
+
40
+ Returns:
41
+ Heating load in W
42
+ """
43
+ # Get wall properties
44
+ u_value = wall.u_value
45
+ area = wall.area
46
+
47
+ # Calculate heating load
48
+ delta_t = indoor_temp - outdoor_temp
49
+ heating_load = u_value * area * delta_t
50
+
51
+ return heating_load
52
+
53
+ def calculate_roof_heating_load(self, roof: Roof, outdoor_temp: float, indoor_temp: float) -> float:
54
+ """
55
+ Calculate heating load through a roof using steady-state conduction.
56
+
57
+ Args:
58
+ roof: Roof object
59
+ outdoor_temp: Outdoor temperature in °C
60
+ indoor_temp: Indoor temperature in °C
61
+
62
+ Returns:
63
+ Heating load in W
64
+ """
65
+ # Get roof properties
66
+ u_value = roof.u_value
67
+ area = roof.area
68
+
69
+ # Calculate heating load
70
+ delta_t = indoor_temp - outdoor_temp
71
+ heating_load = u_value * area * delta_t
72
+
73
+ return heating_load
74
+
75
+ def calculate_floor_heating_load(self, floor: Floor, ground_temp: float, indoor_temp: float) -> float:
76
+ """
77
+ Calculate heating load through a floor.
78
+
79
+ Args:
80
+ floor: Floor object
81
+ ground_temp: Ground or adjacent space temperature in °C
82
+ indoor_temp: Indoor temperature in °C
83
+
84
+ Returns:
85
+ Heating load in W
86
+ """
87
+ # Get floor properties
88
+ u_value = floor.u_value
89
+ area = floor.area
90
+
91
+ # Calculate heating load
92
+ delta_t = indoor_temp - ground_temp
93
+ heating_load = u_value * area * delta_t
94
+
95
+ return heating_load
96
+
97
+ def calculate_window_heating_load(self, window: Window, outdoor_temp: float, indoor_temp: float) -> float:
98
+ """
99
+ Calculate heating load through a window using steady-state conduction.
100
+
101
+ Args:
102
+ window: Window object
103
+ outdoor_temp: Outdoor temperature in °C
104
+ indoor_temp: Indoor temperature in °C
105
+
106
+ Returns:
107
+ Heating load in W
108
+ """
109
+ # Get window properties
110
+ u_value = window.u_value
111
+ area = window.area
112
+
113
+ # Calculate heating load
114
+ delta_t = indoor_temp - outdoor_temp
115
+ heating_load = u_value * area * delta_t
116
+
117
+ return heating_load
118
+
119
+ def calculate_door_heating_load(self, door: Door, outdoor_temp: float, indoor_temp: float) -> float:
120
+ """
121
+ Calculate heating load through a door using steady-state conduction.
122
+
123
+ Args:
124
+ door: Door object
125
+ outdoor_temp: Outdoor temperature in °C
126
+ indoor_temp: Indoor temperature in °C
127
+
128
+ Returns:
129
+ Heating load in W
130
+ """
131
+ # Get door properties
132
+ u_value = door.u_value
133
+ area = door.area
134
+
135
+ # Calculate heating load
136
+ delta_t = indoor_temp - outdoor_temp
137
+ heating_load = u_value * area * delta_t
138
+
139
+ return heating_load
140
+
141
+ def calculate_infiltration_heating_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
142
+ outdoor_rh: float, indoor_rh: float) -> Dict[str, float]:
143
+ """
144
+ Calculate sensible and latent heating loads due to infiltration.
145
+
146
+ Args:
147
+ flow_rate: Infiltration flow rate in m³/s
148
+ outdoor_temp: Outdoor temperature in °C
149
+ indoor_temp: Indoor temperature in °C
150
+ outdoor_rh: Outdoor relative humidity in %
151
+ indoor_rh: Indoor relative humidity in %
152
+
153
+ Returns:
154
+ Dictionary with sensible, latent, and total heating loads in W
155
+ """
156
+ # Calculate sensible heating load
157
+ sensible_load = self.heat_transfer.infiltration_heat_transfer(
158
+ flow_rate=flow_rate,
159
+ delta_t=indoor_temp - outdoor_temp
160
+ )
161
+
162
+ # Calculate humidity ratios
163
+ w_outdoor = self.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh)
164
+ w_indoor = self.psychrometrics.humidity_ratio(indoor_temp, indoor_rh)
165
+
166
+ # Calculate latent heating load (only if indoor humidity is higher than outdoor)
167
+ delta_w = w_indoor - w_outdoor
168
+ if delta_w > 0:
169
+ latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
170
+ flow_rate=flow_rate,
171
+ delta_w=delta_w
172
+ )
173
+ else:
174
+ latent_load = 0
175
+
176
+ # Calculate total heating load
177
+ total_load = sensible_load + latent_load
178
+
179
+ return {
180
+ "sensible": sensible_load,
181
+ "latent": latent_load,
182
+ "total": total_load
183
+ }
184
+
185
+ def calculate_ventilation_heating_load(self, flow_rate: float, outdoor_temp: float, indoor_temp: float,
186
+ outdoor_rh: float, indoor_rh: float) -> Dict[str, float]:
187
+ """
188
+ Calculate sensible and latent heating loads due to ventilation.
189
+
190
+ Args:
191
+ flow_rate: Ventilation flow rate in m³/s
192
+ outdoor_temp: Outdoor temperature in °C
193
+ indoor_temp: Indoor temperature in °C
194
+ outdoor_rh: Outdoor relative humidity in %
195
+ indoor_rh: Indoor relative humidity in %
196
+
197
+ Returns:
198
+ Dictionary with sensible, latent, and total heating loads in W
199
+ """
200
+ # Ventilation load calculation is the same as infiltration
201
+ return self.calculate_infiltration_heating_load(
202
+ flow_rate=flow_rate,
203
+ outdoor_temp=outdoor_temp,
204
+ indoor_temp=indoor_temp,
205
+ outdoor_rh=outdoor_rh,
206
+ indoor_rh=indoor_rh
207
+ )
208
+
209
+ def calculate_internal_gains_offset(self, people_load: float, lights_load: float,
210
+ equipment_load: float, usage_factor: float = 0.7) -> float:
211
+ """
212
+ Calculate internal gains offset for heating load.
213
+
214
+ Args:
215
+ people_load: Heat gain from people in W
216
+ lights_load: Heat gain from lights in W
217
+ equipment_load: Heat gain from equipment in W
218
+ usage_factor: Usage factor for internal gains (0-1)
219
+
220
+ Returns:
221
+ Internal gains offset in W
222
+ """
223
+ # Calculate total internal gains
224
+ total_gains = people_load + lights_load + equipment_load
225
+
226
+ # Apply usage factor
227
+ offset = total_gains * usage_factor
228
+
229
+ return offset
230
+
231
+ def calculate_design_heating_load(self, building_components: Dict[str, List[Any]],
232
+ outdoor_conditions: Dict[str, Any],
233
+ indoor_conditions: Dict[str, Any],
234
+ internal_loads: Dict[str, Any],
235
+ safety_factor: float = 1.15) -> Dict[str, float]:
236
+ """
237
+ Calculate design heating load for a building.
238
+
239
+ Args:
240
+ building_components: Dictionary with lists of building components
241
+ outdoor_conditions: Dictionary with outdoor conditions
242
+ indoor_conditions: Dictionary with indoor conditions
243
+ internal_loads: Dictionary with internal loads
244
+ safety_factor: Safety factor for heating load (default: 1.15)
245
+
246
+ Returns:
247
+ Dictionary with design heating loads
248
+ """
249
+ # Extract building components
250
+ walls = building_components.get("walls", [])
251
+ roofs = building_components.get("roofs", [])
252
+ floors = building_components.get("floors", [])
253
+ windows = building_components.get("windows", [])
254
+ doors = building_components.get("doors", [])
255
+
256
+ # Extract outdoor conditions
257
+ outdoor_temp = outdoor_conditions.get("design_temperature", -10.0)
258
+ outdoor_rh = outdoor_conditions.get("design_relative_humidity", 80.0)
259
+ ground_temp = outdoor_conditions.get("ground_temperature", 10.0)
260
+
261
+ # Extract indoor conditions
262
+ indoor_temp = indoor_conditions.get("temperature", 21.0)
263
+ indoor_rh = indoor_conditions.get("relative_humidity", 40.0)
264
+
265
+ # Extract internal loads
266
+ people = internal_loads.get("people", {})
267
+ lights = internal_loads.get("lights", {})
268
+ equipment = internal_loads.get("equipment", {})
269
+ infiltration = internal_loads.get("infiltration", {})
270
+ ventilation = internal_loads.get("ventilation", {})
271
+
272
+ # Initialize loads
273
+ loads = {
274
+ "walls": 0,
275
+ "roofs": 0,
276
+ "floors": 0,
277
+ "windows": 0,
278
+ "doors": 0,
279
+ "infiltration_sensible": 0,
280
+ "infiltration_latent": 0,
281
+ "ventilation_sensible": 0,
282
+ "ventilation_latent": 0,
283
+ "internal_gains_offset": 0,
284
+ "subtotal": 0,
285
+ "safety_factor": safety_factor,
286
+ "total": 0
287
+ }
288
+
289
+ # Calculate wall loads
290
+ for wall in walls:
291
+ wall_load = self.calculate_wall_heating_load(
292
+ wall=wall,
293
+ outdoor_temp=outdoor_temp,
294
+ indoor_temp=indoor_temp
295
+ )
296
+ loads["walls"] += wall_load
297
+
298
+ # Calculate roof loads
299
+ for roof in roofs:
300
+ roof_load = self.calculate_roof_heating_load(
301
+ roof=roof,
302
+ outdoor_temp=outdoor_temp,
303
+ indoor_temp=indoor_temp
304
+ )
305
+ loads["roofs"] += roof_load
306
+
307
+ # Calculate floor loads
308
+ for floor in floors:
309
+ floor_load = self.calculate_floor_heating_load(
310
+ floor=floor,
311
+ ground_temp=ground_temp,
312
+ indoor_temp=indoor_temp
313
+ )
314
+ loads["floors"] += floor_load
315
+
316
+ # Calculate window loads
317
+ for window in windows:
318
+ window_load = self.calculate_window_heating_load(
319
+ window=window,
320
+ outdoor_temp=outdoor_temp,
321
+ indoor_temp=indoor_temp
322
+ )
323
+ loads["windows"] += window_load
324
+
325
+ # Calculate door loads
326
+ for door in doors:
327
+ door_load = self.calculate_door_heating_load(
328
+ door=door,
329
+ outdoor_temp=outdoor_temp,
330
+ indoor_temp=indoor_temp
331
+ )
332
+ loads["doors"] += door_load
333
+
334
+ # Calculate infiltration loads
335
+ if infiltration:
336
+ flow_rate = infiltration.get("flow_rate", 0.0)
337
+ infiltration_loads = self.calculate_infiltration_heating_load(
338
+ flow_rate=flow_rate,
339
+ outdoor_temp=outdoor_temp,
340
+ indoor_temp=indoor_temp,
341
+ outdoor_rh=outdoor_rh,
342
+ indoor_rh=indoor_rh
343
+ )
344
+ loads["infiltration_sensible"] = infiltration_loads["sensible"]
345
+ loads["infiltration_latent"] = infiltration_loads["latent"]
346
+
347
+ # Calculate ventilation loads
348
+ if ventilation:
349
+ flow_rate = ventilation.get("flow_rate", 0.0)
350
+ ventilation_loads = self.calculate_ventilation_heating_load(
351
+ flow_rate=flow_rate,
352
+ outdoor_temp=outdoor_temp,
353
+ indoor_temp=indoor_temp,
354
+ outdoor_rh=outdoor_rh,
355
+ indoor_rh=indoor_rh
356
+ )
357
+ loads["ventilation_sensible"] = ventilation_loads["sensible"]
358
+ loads["ventilation_latent"] = ventilation_loads["latent"]
359
+
360
+ # Calculate internal gains offset
361
+ people_load = people.get("number", 0) * people.get("sensible_gain", 70)
362
+ lights_load = lights.get("power", 0) * lights.get("use_factor", 1.0)
363
+ equipment_load = equipment.get("power", 0) * equipment.get("use_factor", 1.0)
364
+
365
+ loads["internal_gains_offset"] = self.calculate_internal_gains_offset(
366
+ people_load=people_load,
367
+ lights_load=lights_load,
368
+ equipment_load=equipment_load,
369
+ usage_factor=internal_loads.get("usage_factor", 0.7)
370
+ )
371
+
372
+ # Calculate subtotal
373
+ loads["subtotal"] = (
374
+ loads["walls"] + loads["roofs"] + loads["floors"] +
375
+ loads["windows"] + loads["doors"] +
376
+ loads["infiltration_sensible"] + loads["infiltration_latent"] +
377
+ loads["ventilation_sensible"] + loads["ventilation_latent"] -
378
+ loads["internal_gains_offset"]
379
+ )
380
+
381
+ # Apply safety factor
382
+ loads["total"] = loads["subtotal"] * safety_factor
383
+
384
+ return loads
385
+
386
+ def calculate_heating_load_summary(self, design_loads: Dict[str, float]) -> Dict[str, float]:
387
+ """
388
+ Calculate heating load summary.
389
+
390
+ Args:
391
+ design_loads: Dictionary with design heating loads
392
+
393
+ Returns:
394
+ Dictionary with heating load summary
395
+ """
396
+ # Calculate envelope loads
397
+ envelope_loads = (
398
+ design_loads["walls"] + design_loads["roofs"] + design_loads["floors"] +
399
+ design_loads["windows"] + design_loads["doors"]
400
+ )
401
+
402
+ # Calculate ventilation and infiltration loads
403
+ ventilation_loads = design_loads["ventilation_sensible"] + design_loads["ventilation_latent"]
404
+ infiltration_loads = design_loads["infiltration_sensible"] + design_loads["infiltration_latent"]
405
+
406
+ # Create summary
407
+ summary = {
408
+ "envelope_loads": envelope_loads,
409
+ "ventilation_loads": ventilation_loads,
410
+ "infiltration_loads": infiltration_loads,
411
+ "internal_gains_offset": design_loads["internal_gains_offset"],
412
+ "subtotal": design_loads["subtotal"],
413
+ "safety_factor": design_loads["safety_factor"],
414
+ "total": design_loads["total"]
415
+ }
416
+
417
+ return summary
418
+
419
+ def calculate_monthly_heating_loads(self, design_loads: Dict[str, float],
420
+ monthly_temps: Dict[str, float],
421
+ design_temp: float, indoor_temp: float) -> Dict[str, float]:
422
+ """
423
+ Calculate monthly heating loads based on design load and monthly temperatures.
424
+
425
+ Args:
426
+ design_loads: Dictionary with design heating loads
427
+ monthly_temps: Dictionary with monthly average temperatures
428
+ design_temp: Design outdoor temperature in °C
429
+ indoor_temp: Indoor temperature in °C
430
+
431
+ Returns:
432
+ Dictionary with monthly heating loads
433
+ """
434
+ # Calculate design temperature difference
435
+ design_delta_t = indoor_temp - design_temp
436
+
437
+ # Calculate monthly loads
438
+ monthly_loads = {}
439
+
440
+ for month, temp in monthly_temps.items():
441
+ # Calculate temperature difference for this month
442
+ delta_t = indoor_temp - temp
443
+
444
+ # Skip months where outdoor temperature is higher than indoor
445
+ if delta_t <= 0:
446
+ monthly_loads[month] = 0
447
+ continue
448
+
449
+ # Calculate load ratio based on temperature difference
450
+ load_ratio = delta_t / design_delta_t
451
+
452
+ # Calculate monthly load
453
+ monthly_loads[month] = design_loads["total"] * load_ratio
454
+
455
+ return monthly_loads
456
+
457
+ def calculate_heating_degree_days(self, monthly_temps: Dict[str, float],
458
+ base_temp: float = 18.0) -> Dict[str, float]:
459
+ """
460
+ Calculate heating degree days for each month.
461
+
462
+ Args:
463
+ monthly_temps: Dictionary with monthly average temperatures
464
+ base_temp: Base temperature for degree days in °C (default: 18°C)
465
+
466
+ Returns:
467
+ Dictionary with monthly heating degree days
468
+ """
469
+ # Calculate monthly heating degree days
470
+ monthly_hdds = {}
471
+
472
+ for month, temp in monthly_temps.items():
473
+ # Calculate degree days
474
+ days_in_month = 30 # Approximate
475
+ if month in ["Apr", "Jun", "Sep", "Nov"]:
476
+ days_in_month = 30
477
+ elif month == "Feb":
478
+ days_in_month = 28 # Ignore leap years for simplicity
479
+ else:
480
+ days_in_month = 31
481
+
482
+ # Calculate daily degree days
483
+ daily_hdd = max(0, base_temp - temp)
484
+
485
+ # Calculate monthly degree days
486
+ monthly_hdds[month] = daily_hdd * days_in_month
487
+
488
+ return monthly_hdds
489
+
490
+ def calculate_annual_heating_energy(self, monthly_loads: Dict[str, float],
491
+ heating_system_efficiency: float = 0.8) -> Dict[str, float]:
492
+ """
493
+ Calculate annual heating energy consumption.
494
+
495
+ Args:
496
+ monthly_loads: Dictionary with monthly heating loads in W
497
+ heating_system_efficiency: Heating system efficiency (0-1)
498
+
499
+ Returns:
500
+ Dictionary with monthly and annual heating energy in kWh
501
+ """
502
+ # Calculate monthly energy consumption
503
+ monthly_energy = {}
504
+ annual_energy = 0
505
+
506
+ for month, load in monthly_loads.items():
507
+ # Calculate hours in month
508
+ hours_in_month = 24 * 30 # Approximate
509
+ if month in ["Apr", "Jun", "Sep", "Nov"]:
510
+ hours_in_month = 24 * 30
511
+ elif month == "Feb":
512
+ hours_in_month = 24 * 28 # Ignore leap years for simplicity
513
+ else:
514
+ hours_in_month = 24 * 31
515
+
516
+ # Calculate energy in kWh
517
+ energy = load * hours_in_month / 1000 / heating_system_efficiency
518
+
519
+ # Store monthly energy
520
+ monthly_energy[month] = energy
521
+
522
+ # Add to annual total
523
+ annual_energy += energy
524
+
525
+ # Add annual total to results
526
+ monthly_energy["annual"] = annual_energy
527
+
528
+ return monthly_energy
529
+
530
+
531
+ # Create a singleton instance
532
+ heating_load_calculator = HeatingLoadCalculator()
533
+
534
+ # Example usage
535
+ if __name__ == "__main__":
536
+ # Create sample building components
537
+ from data.building_components import Wall, Roof, Window, Door, Orientation, ComponentType
538
+
539
+ # Create a sample wall
540
+ wall = Wall(
541
+ id="wall1",
542
+ name="Exterior Wall",
543
+ component_type=ComponentType.WALL,
544
+ u_value=0.5,
545
+ area=20.0,
546
+ orientation=Orientation.NORTH,
547
+ wall_type="Brick",
548
+ wall_group="B"
549
+ )
550
+
551
+ # Create a sample roof
552
+ roof = Roof(
553
+ id="roof1",
554
+ name="Flat Roof",
555
+ component_type=ComponentType.ROOF,
556
+ u_value=0.3,
557
+ area=50.0,
558
+ orientation=Orientation.HORIZONTAL,
559
+ roof_type="Concrete",
560
+ roof_group="C"
561
+ )
562
+
563
+ # Create a sample window
564
+ window = Window(
565
+ id="window1",
566
+ name="North Window",
567
+ component_type=ComponentType.WINDOW,
568
+ u_value=2.8,
569
+ area=5.0,
570
+ orientation=Orientation.NORTH,
571
+ shgc=0.7,
572
+ vt=0.8,
573
+ window_type="Double Glazed",
574
+ glazing_layers=2,
575
+ gas_fill="Air",
576
+ low_e_coating=False
577
+ )
578
+
579
+ # Define building components
580
+ building_components = {
581
+ "walls": [wall],
582
+ "roofs": [roof],
583
+ "windows": [window],
584
+ "doors": [],
585
+ "floors": []
586
+ }
587
+
588
+ # Define conditions
589
+ outdoor_conditions = {
590
+ "design_temperature": -10.0,
591
+ "design_relative_humidity": 80.0,
592
+ "ground_temperature": 10.0
593
+ }
594
+
595
+ indoor_conditions = {
596
+ "temperature": 21.0,
597
+ "relative_humidity": 40.0
598
+ }
599
+
600
+ # Define internal loads
601
+ internal_loads = {
602
+ "people": {
603
+ "number": 3,
604
+ "sensible_gain": 70
605
+ },
606
+ "lights": {
607
+ "power": 500.0,
608
+ "use_factor": 0.9
609
+ },
610
+ "equipment": {
611
+ "power": 1000.0,
612
+ "use_factor": 0.7
613
+ },
614
+ "infiltration": {
615
+ "flow_rate": 0.05
616
+ },
617
+ "ventilation": {
618
+ "flow_rate": 0.1
619
+ },
620
+ "usage_factor": 0.7
621
+ }
622
+
623
+ # Calculate design heating load
624
+ design_loads = heating_load_calculator.calculate_design_heating_load(
625
+ building_components=building_components,
626
+ outdoor_conditions=outdoor_conditions,
627
+ indoor_conditions=indoor_conditions,
628
+ internal_loads=internal_loads
629
+ )
630
+
631
+ # Calculate heating load summary
632
+ summary = heating_load_calculator.calculate_heating_load_summary(design_loads)
633
+
634
+ # Define monthly temperatures
635
+ monthly_temps = {
636
+ "Jan": -5.0,
637
+ "Feb": -3.0,
638
+ "Mar": 2.0,
639
+ "Apr": 8.0,
640
+ "May": 14.0,
641
+ "Jun": 18.0,
642
+ "Jul": 21.0,
643
+ "Aug": 20.0,
644
+ "Sep": 16.0,
645
+ "Oct": 10.0,
646
+ "Nov": 4.0,
647
+ "Dec": -2.0
648
+ }
649
+
650
+ # Calculate monthly heating loads
651
+ monthly_loads = heating_load_calculator.calculate_monthly_heating_loads(
652
+ design_loads=design_loads,
653
+ monthly_temps=monthly_temps,
654
+ design_temp=outdoor_conditions["design_temperature"],
655
+ indoor_temp=indoor_conditions["temperature"]
656
+ )
657
+
658
+ # Calculate heating degree days
659
+ hdds = heating_load_calculator.calculate_heating_degree_days(monthly_temps)
660
+
661
+ # Calculate annual heating energy
662
+ energy = heating_load_calculator.calculate_annual_heating_energy(monthly_loads)
663
+
664
+ # Print results
665
+ print("Heating Load Summary:")
666
+ print(f"Envelope Loads: {summary['envelope_loads']:.2f} W")
667
+ print(f"Ventilation Loads: {summary['ventilation_loads']:.2f} W")
668
+ print(f"Infiltration Loads: {summary['infiltration_loads']:.2f} W")
669
+ print(f"Internal Gains Offset: {summary['internal_gains_offset']:.2f} W")
670
+ print(f"Subtotal: {summary['subtotal']:.2f} W")
671
+ print(f"Safety Factor: {summary['safety_factor']:.2f}")
672
+ print(f"Total: {summary['total']:.2f} W")
673
+
674
+ print("\nMonthly Heating Loads:")
675
+ for month, load in monthly_loads.items():
676
+ print(f"{month}: {load:.2f} W")
677
+
678
+ print("\nHeating Degree Days:")
679
+ for month, hdd in hdds.items():
680
+ print(f"{month}: {hdd:.2f} HDD")
681
+
682
+ print("\nAnnual Heating Energy:")
683
+ print(f"Total: {energy['annual']:.2f} kWh")
utils/psychrometric_visualization.py ADDED
@@ -0,0 +1,635 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Psychrometric visualization module for HVAC Load Calculator.
3
+ This module provides visualization tools for psychrometric processes.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ import plotly.graph_objects as go
10
+ import plotly.express as px
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ import math
13
+
14
+ # Import psychrometrics module
15
+ from utils.psychrometrics import Psychrometrics
16
+
17
+
18
+ class PsychrometricVisualization:
19
+ """Class for psychrometric visualization."""
20
+
21
+ def __init__(self):
22
+ """Initialize psychrometric visualization."""
23
+ self.psychrometrics = Psychrometrics()
24
+
25
+ # Define temperature and humidity ratio ranges for chart
26
+ self.temp_min = -10
27
+ self.temp_max = 50
28
+ self.w_min = 0
29
+ self.w_max = 0.030
30
+
31
+ # Define standard atmospheric pressure
32
+ self.pressure = 101325 # Pa
33
+
34
+ def create_psychrometric_chart(self, points: Optional[List[Dict[str, Any]]] = None,
35
+ processes: Optional[List[Dict[str, Any]]] = None,
36
+ comfort_zone: Optional[Dict[str, Any]] = None) -> go.Figure:
37
+ """
38
+ Create an interactive psychrometric chart.
39
+
40
+ Args:
41
+ points: List of points to plot on the chart
42
+ processes: List of processes to plot on the chart
43
+ comfort_zone: Dictionary with comfort zone parameters
44
+
45
+ Returns:
46
+ Plotly figure with psychrometric chart
47
+ """
48
+ # Create figure
49
+ fig = go.Figure()
50
+
51
+ # Generate temperature and humidity ratio grids
52
+ temp_range = np.linspace(self.temp_min, self.temp_max, 100)
53
+ w_range = np.linspace(self.w_min, self.w_max, 100)
54
+
55
+ # Generate saturation curve
56
+ sat_temps = np.linspace(self.temp_min, self.temp_max, 100)
57
+ sat_w = [self.psychrometrics.humidity_ratio(t, 100, self.pressure) for t in sat_temps]
58
+
59
+ # Plot saturation curve
60
+ fig.add_trace(go.Scatter(
61
+ x=sat_temps,
62
+ y=sat_w,
63
+ mode="lines",
64
+ line=dict(color="blue", width=2),
65
+ name="Saturation Curve"
66
+ ))
67
+
68
+ # Generate constant RH curves
69
+ rh_values = [10, 20, 30, 40, 50, 60, 70, 80, 90]
70
+
71
+ for rh in rh_values:
72
+ rh_temps = np.linspace(self.temp_min, self.temp_max, 50)
73
+ rh_w = [self.psychrometrics.humidity_ratio(t, rh, self.pressure) for t in rh_temps]
74
+
75
+ # Filter out values above saturation
76
+ valid_points = [(t, w) for t, w in zip(rh_temps, rh_w) if w <= self.psychrometrics.humidity_ratio(t, 100, self.pressure)]
77
+
78
+ if valid_points:
79
+ valid_temps, valid_w = zip(*valid_points)
80
+
81
+ fig.add_trace(go.Scatter(
82
+ x=valid_temps,
83
+ y=valid_w,
84
+ mode="lines",
85
+ line=dict(color="rgba(0, 0, 255, 0.3)", width=1, dash="dot"),
86
+ name=f"{rh}% RH",
87
+ hoverinfo="name"
88
+ ))
89
+
90
+ # Generate constant wet-bulb temperature lines
91
+ wb_values = np.arange(0, 35, 5)
92
+
93
+ for wb in wb_values:
94
+ wb_temps = np.linspace(wb, self.temp_max, 50)
95
+ wb_points = []
96
+
97
+ for t in wb_temps:
98
+ # Binary search to find humidity ratio for this wet-bulb temperature
99
+ w_low = 0
100
+ w_high = self.psychrometrics.humidity_ratio(t, 100, self.pressure)
101
+
102
+ for _ in range(10): # 10 iterations should be enough for good precision
103
+ w_mid = (w_low + w_high) / 2
104
+ rh = self.psychrometrics.relative_humidity(t, w_mid, self.pressure)
105
+ t_wb_calc = self.psychrometrics.wet_bulb_temperature(t, rh, self.pressure)
106
+
107
+ if abs(t_wb_calc - wb) < 0.1:
108
+ wb_points.append((t, w_mid))
109
+ break
110
+ elif t_wb_calc < wb:
111
+ w_low = w_mid
112
+ else:
113
+ w_high = w_mid
114
+
115
+ if wb_points:
116
+ wb_temps, wb_w = zip(*wb_points)
117
+
118
+ fig.add_trace(go.Scatter(
119
+ x=wb_temps,
120
+ y=wb_w,
121
+ mode="lines",
122
+ line=dict(color="rgba(0, 128, 0, 0.3)", width=1, dash="dash"),
123
+ name=f"{wb}°C WB",
124
+ hoverinfo="name"
125
+ ))
126
+
127
+ # Generate constant enthalpy lines
128
+ h_values = np.arange(0, 100, 10) * 1000 # kJ/kg to J/kg
129
+
130
+ for h in h_values:
131
+ h_temps = np.linspace(self.temp_min, self.temp_max, 50)
132
+ h_points = []
133
+
134
+ for t in h_temps:
135
+ # Calculate humidity ratio for this enthalpy
136
+ w = self.psychrometrics.find_humidity_ratio_for_enthalpy(t, h)
137
+
138
+ if 0 <= w <= self.psychrometrics.humidity_ratio(t, 100, self.pressure):
139
+ h_points.append((t, w))
140
+
141
+ if h_points:
142
+ h_temps, h_w = zip(*h_points)
143
+
144
+ fig.add_trace(go.Scatter(
145
+ x=h_temps,
146
+ y=h_w,
147
+ mode="lines",
148
+ line=dict(color="rgba(255, 0, 0, 0.3)", width=1, dash="dashdot"),
149
+ name=f"{h/1000:.0f} kJ/kg",
150
+ hoverinfo="name"
151
+ ))
152
+
153
+ # Generate constant specific volume lines
154
+ v_values = [0.8, 0.85, 0.9, 0.95, 1.0, 1.05]
155
+
156
+ for v in v_values:
157
+ v_temps = np.linspace(self.temp_min, self.temp_max, 50)
158
+ v_points = []
159
+
160
+ for t in h_temps:
161
+ # Binary search to find humidity ratio for this specific volume
162
+ w_low = 0
163
+ w_high = self.psychrometrics.humidity_ratio(t, 100, self.pressure)
164
+
165
+ for _ in range(10): # 10 iterations should be enough for good precision
166
+ w_mid = (w_low + w_high) / 2
167
+ v_calc = self.psychrometrics.specific_volume(t, w_mid, self.pressure)
168
+
169
+ if abs(v_calc - v) < 0.01:
170
+ v_points.append((t, w_mid))
171
+ break
172
+ elif v_calc < v:
173
+ w_low = w_mid
174
+ else:
175
+ w_high = w_mid
176
+
177
+ if v_points:
178
+ v_temps, v_w = zip(*v_points)
179
+
180
+ fig.add_trace(go.Scatter(
181
+ x=v_temps,
182
+ y=v_w,
183
+ mode="lines",
184
+ line=dict(color="rgba(128, 0, 128, 0.3)", width=1, dash="longdash"),
185
+ name=f"{v:.2f} m³/kg",
186
+ hoverinfo="name"
187
+ ))
188
+
189
+ # Add comfort zone if specified
190
+ if comfort_zone:
191
+ temp_min = comfort_zone.get("temp_min", 20)
192
+ temp_max = comfort_zone.get("temp_max", 26)
193
+ rh_min = comfort_zone.get("rh_min", 30)
194
+ rh_max = comfort_zone.get("rh_max", 60)
195
+
196
+ # Calculate humidity ratios at corners
197
+ w_bottom_left = self.psychrometrics.humidity_ratio(temp_min, rh_min, self.pressure)
198
+ w_bottom_right = self.psychrometrics.humidity_ratio(temp_max, rh_min, self.pressure)
199
+ w_top_right = self.psychrometrics.humidity_ratio(temp_max, rh_max, self.pressure)
200
+ w_top_left = self.psychrometrics.humidity_ratio(temp_min, rh_max, self.pressure)
201
+
202
+ # Add comfort zone as a filled polygon
203
+ fig.add_trace(go.Scatter(
204
+ x=[temp_min, temp_max, temp_max, temp_min, temp_min],
205
+ y=[w_bottom_left, w_bottom_right, w_top_right, w_top_left, w_bottom_left],
206
+ fill="toself",
207
+ fillcolor="rgba(0, 255, 0, 0.2)",
208
+ line=dict(color="green", width=2),
209
+ name="Comfort Zone"
210
+ ))
211
+
212
+ # Add points if specified
213
+ if points:
214
+ for i, point in enumerate(points):
215
+ temp = point.get("temp", 0)
216
+ rh = point.get("rh", 0)
217
+ w = point.get("w", self.psychrometrics.humidity_ratio(temp, rh, self.pressure))
218
+ name = point.get("name", f"Point {i+1}")
219
+ color = point.get("color", "blue")
220
+
221
+ fig.add_trace(go.Scatter(
222
+ x=[temp],
223
+ y=[w],
224
+ mode="markers+text",
225
+ marker=dict(size=10, color=color),
226
+ text=[name],
227
+ textposition="top center",
228
+ name=name,
229
+ hovertemplate=(
230
+ f"<b>{name}</b><br>" +
231
+ "Temperature: %{x:.1f}°C<br>" +
232
+ "Humidity Ratio: %{y:.5f} kg/kg<br>" +
233
+ f"Relative Humidity: {rh:.1f}%<br>"
234
+ )
235
+ ))
236
+
237
+ # Add processes if specified
238
+ if processes:
239
+ for i, process in enumerate(processes):
240
+ start_point = process.get("start", {})
241
+ end_point = process.get("end", {})
242
+
243
+ start_temp = start_point.get("temp", 0)
244
+ start_rh = start_point.get("rh", 0)
245
+ start_w = start_point.get("w", self.psychrometrics.humidity_ratio(start_temp, start_rh, self.pressure))
246
+
247
+ end_temp = end_point.get("temp", 0)
248
+ end_rh = end_point.get("rh", 0)
249
+ end_w = end_point.get("w", self.psychrometrics.humidity_ratio(end_temp, end_rh, self.pressure))
250
+
251
+ name = process.get("name", f"Process {i+1}")
252
+ color = process.get("color", "red")
253
+
254
+ fig.add_trace(go.Scatter(
255
+ x=[start_temp, end_temp],
256
+ y=[start_w, end_w],
257
+ mode="lines+markers",
258
+ line=dict(color=color, width=2, dash="solid"),
259
+ marker=dict(size=8, color=color),
260
+ name=name
261
+ ))
262
+
263
+ # Add arrow to indicate direction
264
+ fig.add_annotation(
265
+ x=end_temp,
266
+ y=end_w,
267
+ ax=start_temp,
268
+ ay=start_w,
269
+ xref="x",
270
+ yref="y",
271
+ axref="x",
272
+ ayref="y",
273
+ showarrow=True,
274
+ arrowhead=2,
275
+ arrowsize=1,
276
+ arrowwidth=2,
277
+ arrowcolor=color
278
+ )
279
+
280
+ # Update layout
281
+ fig.update_layout(
282
+ title="Psychrometric Chart",
283
+ xaxis_title="Dry-Bulb Temperature (°C)",
284
+ yaxis_title="Humidity Ratio (kg/kg)",
285
+ xaxis=dict(
286
+ range=[self.temp_min, self.temp_max],
287
+ gridcolor="rgba(0, 0, 0, 0.1)",
288
+ showgrid=True
289
+ ),
290
+ yaxis=dict(
291
+ range=[self.w_min, self.w_max],
292
+ gridcolor="rgba(0, 0, 0, 0.1)",
293
+ showgrid=True
294
+ ),
295
+ height=700,
296
+ margin=dict(l=50, r=50, b=50, t=50),
297
+ legend=dict(
298
+ orientation="h",
299
+ yanchor="bottom",
300
+ y=1.02,
301
+ xanchor="right",
302
+ x=1
303
+ ),
304
+ hovermode="closest"
305
+ )
306
+
307
+ return fig
308
+
309
+ def create_process_visualization(self, process: Dict[str, Any]) -> go.Figure:
310
+ """
311
+ Create a visualization of a psychrometric process.
312
+
313
+ Args:
314
+ process: Dictionary with process parameters
315
+
316
+ Returns:
317
+ Plotly figure with process visualization
318
+ """
319
+ # Extract process parameters
320
+ start_point = process.get("start", {})
321
+ end_point = process.get("end", {})
322
+
323
+ start_temp = start_point.get("temp", 0)
324
+ start_rh = start_point.get("rh", 0)
325
+
326
+ end_temp = end_point.get("temp", 0)
327
+ end_rh = end_point.get("rh", 0)
328
+
329
+ # Calculate psychrometric properties
330
+ start_props = self.psychrometrics.moist_air_properties(start_temp, start_rh, self.pressure)
331
+ end_props = self.psychrometrics.moist_air_properties(end_temp, end_rh, self.pressure)
332
+
333
+ # Calculate process changes
334
+ delta_t = end_temp - start_temp
335
+ delta_w = end_props["humidity_ratio"] - start_props["humidity_ratio"]
336
+ delta_h = end_props["enthalpy"] - start_props["enthalpy"]
337
+
338
+ # Determine process type
339
+ process_type = "Unknown"
340
+ if abs(delta_w) < 0.0001: # Sensible heating/cooling
341
+ if delta_t > 0:
342
+ process_type = "Sensible Heating"
343
+ else:
344
+ process_type = "Sensible Cooling"
345
+ elif abs(delta_t) < 0.1: # Humidification/Dehumidification
346
+ if delta_w > 0:
347
+ process_type = "Humidification"
348
+ else:
349
+ process_type = "Dehumidification"
350
+ elif delta_t > 0 and delta_w > 0:
351
+ process_type = "Heating and Humidification"
352
+ elif delta_t < 0 and delta_w < 0:
353
+ process_type = "Cooling and Dehumidification"
354
+ elif delta_t > 0 and delta_w < 0:
355
+ process_type = "Heating and Dehumidification"
356
+ elif delta_t < 0 and delta_w > 0:
357
+ process_type = "Cooling and Humidification"
358
+
359
+ # Create figure
360
+ fig = go.Figure()
361
+
362
+ # Add process to psychrometric chart
363
+ chart_fig = self.create_psychrometric_chart(
364
+ points=[
365
+ {"temp": start_temp, "rh": start_rh, "name": "Start", "color": "blue"},
366
+ {"temp": end_temp, "rh": end_rh, "name": "End", "color": "red"}
367
+ ],
368
+ processes=[
369
+ {"start": {"temp": start_temp, "rh": start_rh},
370
+ "end": {"temp": end_temp, "rh": end_rh},
371
+ "name": process_type,
372
+ "color": "green"}
373
+ ]
374
+ )
375
+
376
+ # Create process diagram
377
+ # Create data for process parameters
378
+ params = [
379
+ "Dry-Bulb Temperature (°C)",
380
+ "Relative Humidity (%)",
381
+ "Humidity Ratio (g/kg)",
382
+ "Enthalpy (kJ/kg)",
383
+ "Wet-Bulb Temperature (°C)",
384
+ "Dew Point Temperature (°C)",
385
+ "Specific Volume (m³/kg)"
386
+ ]
387
+
388
+ start_values = [
389
+ start_props["dry_bulb_temperature"],
390
+ start_props["relative_humidity"],
391
+ start_props["humidity_ratio"] * 1000, # Convert to g/kg
392
+ start_props["enthalpy"] / 1000, # Convert to kJ/kg
393
+ start_props["wet_bulb_temperature"],
394
+ start_props["dew_point_temperature"],
395
+ start_props["specific_volume"]
396
+ ]
397
+
398
+ end_values = [
399
+ end_props["dry_bulb_temperature"],
400
+ end_props["relative_humidity"],
401
+ end_props["humidity_ratio"] * 1000, # Convert to g/kg
402
+ end_props["enthalpy"] / 1000, # Convert to kJ/kg
403
+ end_props["wet_bulb_temperature"],
404
+ end_props["dew_point_temperature"],
405
+ end_props["specific_volume"]
406
+ ]
407
+
408
+ delta_values = [end - start for start, end in zip(start_values, end_values)]
409
+
410
+ # Create table
411
+ table_fig = go.Figure(data=[go.Table(
412
+ header=dict(
413
+ values=["Parameter", "Start", "End", "Change"],
414
+ fill_color="paleturquoise",
415
+ align="left",
416
+ font=dict(size=12)
417
+ ),
418
+ cells=dict(
419
+ values=[
420
+ params,
421
+ [f"{val:.2f}" for val in start_values],
422
+ [f"{val:.2f}" for val in end_values],
423
+ [f"{val:.2f}" for val in delta_values]
424
+ ],
425
+ fill_color="lavender",
426
+ align="left",
427
+ font=dict(size=11)
428
+ )
429
+ )])
430
+
431
+ table_fig.update_layout(
432
+ title=f"Process Parameters: {process_type}",
433
+ height=300,
434
+ margin=dict(l=0, r=0, b=0, t=30)
435
+ )
436
+
437
+ return chart_fig, table_fig
438
+
439
+ def display_psychrometric_visualization(self) -> None:
440
+ """
441
+ Display psychrometric visualization in Streamlit.
442
+ """
443
+ st.header("Psychrometric Visualization")
444
+
445
+ # Create tabs for different visualizations
446
+ tab1, tab2, tab3 = st.tabs([
447
+ "Interactive Psychrometric Chart",
448
+ "Process Visualization",
449
+ "Comfort Zone Analysis"
450
+ ])
451
+
452
+ with tab1:
453
+ st.subheader("Interactive Psychrometric Chart")
454
+
455
+ # Add controls for points
456
+ st.write("Add points to the chart:")
457
+
458
+ col1, col2, col3 = st.columns(3)
459
+
460
+ with col1:
461
+ point1_temp = st.number_input("Point 1 Temperature (°C)", -10.0, 50.0, 20.0, key="point1_temp")
462
+ point1_rh = st.number_input("Point 1 RH (%)", 0.0, 100.0, 50.0, key="point1_rh")
463
+
464
+ with col2:
465
+ point2_temp = st.number_input("Point 2 Temperature (°C)", -10.0, 50.0, 30.0, key="point2_temp")
466
+ point2_rh = st.number_input("Point 2 RH (%)", 0.0, 100.0, 40.0, key="point2_rh")
467
+
468
+ with col3:
469
+ show_process = st.checkbox("Show Process Line", True, key="show_process")
470
+ process_name = st.text_input("Process Name", "Cooling Process", key="process_name")
471
+
472
+ # Create points
473
+ points = [
474
+ {"temp": point1_temp, "rh": point1_rh, "name": "Point 1", "color": "blue"},
475
+ {"temp": point2_temp, "rh": point2_rh, "name": "Point 2", "color": "red"}
476
+ ]
477
+
478
+ # Create process if enabled
479
+ processes = []
480
+ if show_process:
481
+ processes.append({
482
+ "start": {"temp": point1_temp, "rh": point1_rh},
483
+ "end": {"temp": point2_temp, "rh": point2_rh},
484
+ "name": process_name,
485
+ "color": "green"
486
+ })
487
+
488
+ # Create and display chart
489
+ fig = self.create_psychrometric_chart(points=points, processes=processes)
490
+ st.plotly_chart(fig, use_container_width=True)
491
+
492
+ # Display point properties
493
+ col1, col2 = st.columns(2)
494
+
495
+ with col1:
496
+ st.subheader("Point 1 Properties")
497
+ props1 = self.psychrometrics.moist_air_properties(point1_temp, point1_rh, self.pressure)
498
+ st.write(f"Dry-Bulb Temperature: {props1['dry_bulb_temperature']:.2f} °C")
499
+ st.write(f"Relative Humidity: {props1['relative_humidity']:.2f} %")
500
+ st.write(f"Humidity Ratio: {props1['humidity_ratio']*1000:.2f} g/kg")
501
+ st.write(f"Enthalpy: {props1['enthalpy']/1000:.2f} kJ/kg")
502
+ st.write(f"Wet-Bulb Temperature: {props1['wet_bulb_temperature']:.2f} °C")
503
+ st.write(f"Dew Point Temperature: {props1['dew_point_temperature']:.2f} °C")
504
+
505
+ with col2:
506
+ st.subheader("Point 2 Properties")
507
+ props2 = self.psychrometrics.moist_air_properties(point2_temp, point2_rh, self.pressure)
508
+ st.write(f"Dry-Bulb Temperature: {props2['dry_bulb_temperature']:.2f} °C")
509
+ st.write(f"Relative Humidity: {props2['relative_humidity']:.2f} %")
510
+ st.write(f"Humidity Ratio: {props2['humidity_ratio']*1000:.2f} g/kg")
511
+ st.write(f"Enthalpy: {props2['enthalpy']/1000:.2f} kJ/kg")
512
+ st.write(f"Wet-Bulb Temperature: {props2['wet_bulb_temperature']:.2f} °C")
513
+ st.write(f"Dew Point Temperature: {props2['dew_point_temperature']:.2f} °C")
514
+
515
+ with tab2:
516
+ st.subheader("Process Visualization")
517
+
518
+ # Add controls for process
519
+ st.write("Define a psychrometric process:")
520
+
521
+ col1, col2 = st.columns(2)
522
+
523
+ with col1:
524
+ st.write("Starting Point")
525
+ start_temp = st.number_input("Temperature (°C)", -10.0, 50.0, 24.0, key="start_temp")
526
+ start_rh = st.number_input("RH (%)", 0.0, 100.0, 50.0, key="start_rh")
527
+
528
+ with col2:
529
+ st.write("Ending Point")
530
+ end_temp = st.number_input("Temperature (°C)", -10.0, 50.0, 14.0, key="end_temp")
531
+ end_rh = st.number_input("RH (%)", 0.0, 100.0, 90.0, key="end_rh")
532
+
533
+ # Create process
534
+ process = {
535
+ "start": {"temp": start_temp, "rh": start_rh},
536
+ "end": {"temp": end_temp, "rh": end_rh}
537
+ }
538
+
539
+ # Create and display process visualization
540
+ chart_fig, table_fig = self.create_process_visualization(process)
541
+
542
+ st.plotly_chart(chart_fig, use_container_width=True)
543
+ st.plotly_chart(table_fig, use_container_width=True)
544
+
545
+ # Calculate process energy requirements
546
+ start_props = self.psychrometrics.moist_air_properties(start_temp, start_rh, self.pressure)
547
+ end_props = self.psychrometrics.moist_air_properties(end_temp, end_rh, self.pressure)
548
+
549
+ delta_h = end_props["enthalpy"] - start_props["enthalpy"] # J/kg
550
+
551
+ st.subheader("Energy Calculations")
552
+
553
+ air_flow = st.number_input("Air Flow Rate (m³/s)", 0.1, 100.0, 1.0, key="air_flow")
554
+
555
+ # Calculate mass flow rate
556
+ density = start_props["density"] # kg/m³
557
+ mass_flow = air_flow * density # kg/s
558
+
559
+ # Calculate energy rate
560
+ energy_rate = mass_flow * delta_h # W
561
+
562
+ st.write(f"Air Density: {density:.2f} kg/m³")
563
+ st.write(f"Mass Flow Rate: {mass_flow:.2f} kg/s")
564
+ st.write(f"Enthalpy Change: {delta_h/1000:.2f} kJ/kg")
565
+ st.write(f"Energy Rate: {energy_rate/1000:.2f} kW")
566
+
567
+ with tab3:
568
+ st.subheader("Comfort Zone Analysis")
569
+
570
+ # Add controls for comfort zone
571
+ st.write("Define comfort zone parameters:")
572
+
573
+ col1, col2 = st.columns(2)
574
+
575
+ with col1:
576
+ temp_min = st.number_input("Minimum Temperature (°C)", 10.0, 30.0, 20.0, key="temp_min")
577
+ temp_max = st.number_input("Maximum Temperature (°C)", 10.0, 30.0, 26.0, key="temp_max")
578
+
579
+ with col2:
580
+ rh_min = st.number_input("Minimum RH (%)", 0.0, 100.0, 30.0, key="rh_min")
581
+ rh_max = st.number_input("Maximum RH (%)", 0.0, 100.0, 60.0, key="rh_max")
582
+
583
+ # Create comfort zone
584
+ comfort_zone = {
585
+ "temp_min": temp_min,
586
+ "temp_max": temp_max,
587
+ "rh_min": rh_min,
588
+ "rh_max": rh_max
589
+ }
590
+
591
+ # Add point to check if it's in comfort zone
592
+ st.write("Check if a point is within the comfort zone:")
593
+
594
+ col1, col2 = st.columns(2)
595
+
596
+ with col1:
597
+ check_temp = st.number_input("Temperature (°C)", -10.0, 50.0, 22.0, key="check_temp")
598
+ check_rh = st.number_input("RH (%)", 0.0, 100.0, 45.0, key="check_rh")
599
+
600
+ # Check if point is in comfort zone
601
+ in_comfort_zone = (
602
+ temp_min <= check_temp <= temp_max and
603
+ rh_min <= check_rh <= rh_max
604
+ )
605
+
606
+ with col2:
607
+ if in_comfort_zone:
608
+ st.success("✅ Point is within the comfort zone")
609
+ else:
610
+ st.error("❌ Point is outside the comfort zone")
611
+
612
+ # Calculate properties
613
+ check_props = self.psychrometrics.moist_air_properties(check_temp, check_rh, self.pressure)
614
+ st.write(f"Humidity Ratio: {check_props['humidity_ratio']*1000:.2f} g/kg")
615
+ st.write(f"Enthalpy: {check_props['enthalpy']/1000:.2f} kJ/kg")
616
+ st.write(f"Wet-Bulb Temperature: {check_props['wet_bulb_temperature']:.2f} °C")
617
+
618
+ # Create and display chart with comfort zone
619
+ fig = self.create_psychrometric_chart(
620
+ points=[{"temp": check_temp, "rh": check_rh, "name": "Test Point", "color": "purple"}],
621
+ comfort_zone=comfort_zone
622
+ )
623
+
624
+ st.plotly_chart(fig, use_container_width=True)
625
+
626
+
627
+ # Create a singleton instance
628
+ psychrometric_visualization = PsychrometricVisualization()
629
+
630
+ # Example usage
631
+ if __name__ == "__main__":
632
+ import streamlit as st
633
+
634
+ # Display psychrometric visualization
635
+ psychrometric_visualization.display_psychrometric_visualization()
utils/psychrometrics.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Psychrometric module for HVAC Load Calculator.
3
+ This module implements psychrometric calculations for air properties.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import math
8
+ import numpy as np
9
+
10
+ # Constants
11
+ ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure in Pa
12
+ WATER_MOLECULAR_WEIGHT = 18.01534 # kg/kmol
13
+ DRY_AIR_MOLECULAR_WEIGHT = 28.9645 # kg/kmol
14
+ UNIVERSAL_GAS_CONSTANT = 8314.462618 # J/(kmol·K)
15
+ GAS_CONSTANT_DRY_AIR = UNIVERSAL_GAS_CONSTANT / DRY_AIR_MOLECULAR_WEIGHT # J/(kg·K)
16
+ GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K)
17
+
18
+
19
+ class Psychrometrics:
20
+ """Class for psychrometric calculations."""
21
+
22
+ @staticmethod
23
+ def saturation_pressure(t_db: float) -> float:
24
+ """
25
+ Calculate saturation pressure of water vapor.
26
+
27
+ Args:
28
+ t_db: Dry-bulb temperature in °C
29
+
30
+ Returns:
31
+ Saturation pressure in Pa
32
+ """
33
+ # Convert temperature to Kelvin
34
+ t_k = t_db + 273.15
35
+
36
+ # ASHRAE Fundamentals 2017 Chapter 1, Equation 5 & 6
37
+ if t_db >= 0:
38
+ # Equation 5 for temperatures above freezing
39
+ c1 = -5.8002206e3
40
+ c2 = 1.3914993
41
+ c3 = -4.8640239e-2
42
+ c4 = 4.1764768e-5
43
+ c5 = -1.4452093e-8
44
+ c6 = 6.5459673
45
+ else:
46
+ # Equation 6 for temperatures below freezing
47
+ c1 = -5.6745359e3
48
+ c2 = 6.3925247
49
+ c3 = -9.6778430e-3
50
+ c4 = 6.2215701e-7
51
+ c5 = 2.0747825e-9
52
+ c6 = -9.4840240e-13
53
+ c7 = 4.1635019
54
+
55
+ # Calculate natural log of saturation pressure in Pa
56
+ if t_db >= 0:
57
+ ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * math.log(t_k)
58
+ else:
59
+ ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * t_k**4 + c7 * math.log(t_k)
60
+
61
+ # Convert from natural log to actual pressure in Pa
62
+ p_ws = math.exp(ln_p_ws)
63
+
64
+ return p_ws
65
+
66
+ @staticmethod
67
+ def humidity_ratio(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
68
+ """
69
+ Calculate humidity ratio (mass of water vapor per unit mass of dry air).
70
+
71
+ Args:
72
+ t_db: Dry-bulb temperature in °C
73
+ rh: Relative humidity (0-100)
74
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
75
+
76
+ Returns:
77
+ Humidity ratio in kg water vapor / kg dry air
78
+ """
79
+ # Convert relative humidity to decimal
80
+ rh_decimal = rh / 100.0
81
+
82
+ # Calculate saturation pressure
83
+ p_ws = Psychrometrics.saturation_pressure(t_db)
84
+
85
+ # Calculate partial pressure of water vapor
86
+ p_w = rh_decimal * p_ws
87
+
88
+ # Calculate humidity ratio
89
+ # ASHRAE Fundamentals 2017 Chapter 1, Equation 20
90
+ w = 0.621945 * p_w / (p_atm - p_w)
91
+
92
+ return w
93
+
94
+ @staticmethod
95
+ def relative_humidity(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
96
+ """
97
+ Calculate relative humidity from humidity ratio.
98
+
99
+ Args:
100
+ t_db: Dry-bulb temperature in °C
101
+ w: Humidity ratio in kg water vapor / kg dry air
102
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
103
+
104
+ Returns:
105
+ Relative humidity (0-100)
106
+ """
107
+ # Calculate saturation pressure
108
+ p_ws = Psychrometrics.saturation_pressure(t_db)
109
+
110
+ # Calculate partial pressure of water vapor
111
+ # Rearranged from ASHRAE Fundamentals 2017 Chapter 1, Equation 20
112
+ p_w = p_atm * w / (0.621945 + w)
113
+
114
+ # Calculate relative humidity
115
+ rh = 100.0 * p_w / p_ws
116
+
117
+ return rh
118
+
119
+ @staticmethod
120
+ def wet_bulb_temperature(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
121
+ """
122
+ Calculate wet-bulb temperature using iterative method.
123
+
124
+ Args:
125
+ t_db: Dry-bulb temperature in °C
126
+ rh: Relative humidity (0-100)
127
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
128
+
129
+ Returns:
130
+ Wet-bulb temperature in °C
131
+ """
132
+ # Calculate humidity ratio at given conditions
133
+ w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
134
+
135
+ # Initial guess for wet-bulb temperature
136
+ t_wb = t_db
137
+
138
+ # Iterative solution
139
+ max_iterations = 100
140
+ tolerance = 0.001 # °C
141
+
142
+ for i in range(max_iterations):
143
+ # Calculate saturation pressure at wet-bulb temperature
144
+ p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
145
+
146
+ # Calculate saturation humidity ratio at wet-bulb temperature
147
+ w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
148
+
149
+ # Calculate humidity ratio from wet-bulb temperature
150
+ # ASHRAE Fundamentals 2017 Chapter 1, Equation 35
151
+ h_fg = 2501000 + 1840 * t_wb # Latent heat of vaporization at t_wb in J/kg
152
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
153
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
154
+
155
+ w_calc = ((h_fg - c_pw * (t_db - t_wb)) * w_s_wb - c_pa * (t_db - t_wb)) / (h_fg + c_pw * t_db - c_pw * t_wb)
156
+
157
+ # Check convergence
158
+ if abs(w - w_calc) < tolerance:
159
+ break
160
+
161
+ # Adjust wet-bulb temperature
162
+ if w_calc > w:
163
+ t_wb -= 0.1
164
+ else:
165
+ t_wb += 0.1
166
+
167
+ return t_wb
168
+
169
+ @staticmethod
170
+ def dew_point_temperature(t_db: float, rh: float) -> float:
171
+ """
172
+ Calculate dew point temperature.
173
+
174
+ Args:
175
+ t_db: Dry-bulb temperature in °C
176
+ rh: Relative humidity (0-100)
177
+
178
+ Returns:
179
+ Dew point temperature in °C
180
+ """
181
+ # Convert relative humidity to decimal
182
+ rh_decimal = rh / 100.0
183
+
184
+ # Calculate saturation pressure
185
+ p_ws = Psychrometrics.saturation_pressure(t_db)
186
+
187
+ # Calculate partial pressure of water vapor
188
+ p_w = rh_decimal * p_ws
189
+
190
+ # Calculate dew point temperature
191
+ # ASHRAE Fundamentals 2017 Chapter 1, Equation 39 and 40
192
+ alpha = math.log(p_w / 1000.0) # Convert to kPa for the formula
193
+
194
+ if t_db >= 0:
195
+ # For temperatures above freezing
196
+ c14 = 6.54
197
+ c15 = 14.526
198
+ c16 = 0.7389
199
+ c17 = 0.09486
200
+ c18 = 0.4569
201
+
202
+ t_dp = c14 + c15 * alpha + c16 * alpha**2 + c17 * alpha**3 + c18 * p_w**(0.1984)
203
+ else:
204
+ # For temperatures below freezing
205
+ c14 = 6.09
206
+ c15 = 12.608
207
+ c16 = 0.4959
208
+
209
+ t_dp = c14 + c15 * alpha + c16 * alpha**2
210
+
211
+ return t_dp
212
+
213
+ @staticmethod
214
+ def enthalpy(t_db: float, w: float) -> float:
215
+ """
216
+ Calculate specific enthalpy of moist air.
217
+
218
+ Args:
219
+ t_db: Dry-bulb temperature in °C
220
+ w: Humidity ratio in kg water vapor / kg dry air
221
+
222
+ Returns:
223
+ Specific enthalpy in J/kg dry air
224
+ """
225
+ # ASHRAE Fundamentals 2017 Chapter 1, Equation 30
226
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
227
+ h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
228
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
229
+
230
+ h = c_pa * t_db + w * (h_fg + c_pw * t_db)
231
+
232
+ return h
233
+
234
+ @staticmethod
235
+ def specific_volume(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
236
+ """
237
+ Calculate specific volume of moist air.
238
+
239
+ Args:
240
+ t_db: Dry-bulb temperature in °C
241
+ w: Humidity ratio in kg water vapor / kg dry air
242
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
243
+
244
+ Returns:
245
+ Specific volume in m³/kg dry air
246
+ """
247
+ # Convert temperature to Kelvin
248
+ t_k = t_db + 273.15
249
+
250
+ # ASHRAE Fundamentals 2017 Chapter 1, Equation 28
251
+ r_da = GAS_CONSTANT_DRY_AIR # Gas constant for dry air in J/(kg·K)
252
+
253
+ v = r_da * t_k * (1 + 1.607858 * w) / p_atm
254
+
255
+ return v
256
+
257
+ @staticmethod
258
+ def density(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
259
+ """
260
+ Calculate density of moist air.
261
+
262
+ Args:
263
+ t_db: Dry-bulb temperature in °C
264
+ w: Humidity ratio in kg water vapor / kg dry air
265
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
266
+
267
+ Returns:
268
+ Density in kg/m³
269
+ """
270
+ # Calculate specific volume
271
+ v = Psychrometrics.specific_volume(t_db, w, p_atm)
272
+
273
+ # Density is the reciprocal of specific volume
274
+ rho = (1 + w) / v
275
+
276
+ return rho
277
+
278
+ @staticmethod
279
+ def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
280
+ """
281
+ Calculate all psychrometric properties of moist air.
282
+
283
+ Args:
284
+ t_db: Dry-bulb temperature in °C
285
+ rh: Relative humidity (0-100)
286
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
287
+
288
+ Returns:
289
+ Dictionary with all psychrometric properties
290
+ """
291
+ # Calculate humidity ratio
292
+ w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
293
+
294
+ # Calculate wet-bulb temperature
295
+ t_wb = Psychrometrics.wet_bulb_temperature(t_db, rh, p_atm)
296
+
297
+ # Calculate dew point temperature
298
+ t_dp = Psychrometrics.dew_point_temperature(t_db, rh)
299
+
300
+ # Calculate enthalpy
301
+ h = Psychrometrics.enthalpy(t_db, w)
302
+
303
+ # Calculate specific volume
304
+ v = Psychrometrics.specific_volume(t_db, w, p_atm)
305
+
306
+ # Calculate density
307
+ rho = Psychrometrics.density(t_db, w, p_atm)
308
+
309
+ # Calculate saturation pressure
310
+ p_ws = Psychrometrics.saturation_pressure(t_db)
311
+
312
+ # Calculate partial pressure of water vapor
313
+ p_w = rh / 100.0 * p_ws
314
+
315
+ # Return all properties
316
+ return {
317
+ "dry_bulb_temperature": t_db,
318
+ "wet_bulb_temperature": t_wb,
319
+ "dew_point_temperature": t_dp,
320
+ "relative_humidity": rh,
321
+ "humidity_ratio": w,
322
+ "enthalpy": h,
323
+ "specific_volume": v,
324
+ "density": rho,
325
+ "saturation_pressure": p_ws,
326
+ "partial_pressure": p_w,
327
+ "atmospheric_pressure": p_atm
328
+ }
329
+
330
+ @staticmethod
331
+ def find_humidity_ratio_for_enthalpy(t_db: float, h: float) -> float:
332
+ """
333
+ Find humidity ratio for a given dry-bulb temperature and enthalpy.
334
+
335
+ Args:
336
+ t_db: Dry-bulb temperature in °C
337
+ h: Specific enthalpy in J/kg dry air
338
+
339
+ Returns:
340
+ Humidity ratio in kg water vapor / kg dry air
341
+ """
342
+ # Rearrange ASHRAE Fundamentals 2017 Chapter 1, Equation 30
343
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
344
+ h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
345
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
346
+
347
+ w = (h - c_pa * t_db) / (h_fg + c_pw * t_db)
348
+
349
+ return max(0, w) # Ensure non-negative value
350
+
351
+ @staticmethod
352
+ def find_temperature_for_enthalpy(w: float, h: float) -> float:
353
+ """
354
+ Find dry-bulb temperature for a given humidity ratio and enthalpy.
355
+
356
+ Args:
357
+ w: Humidity ratio in kg water vapor / kg dry air
358
+ h: Specific enthalpy in J/kg dry air
359
+
360
+ Returns:
361
+ Dry-bulb temperature in °C
362
+ """
363
+ # Rearrange ASHRAE Fundamentals 2017 Chapter 1, Equation 30
364
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
365
+ h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
366
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
367
+
368
+ t_db = (h - w * h_fg) / (c_pa + w * c_pw)
369
+
370
+ return t_db
371
+
372
+ @staticmethod
373
+ def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
374
+ """
375
+ Calculate sensible heat ratio.
376
+
377
+ Args:
378
+ q_sensible: Sensible heat load in W
379
+ q_total: Total heat load in W
380
+
381
+ Returns:
382
+ Sensible heat ratio (0-1)
383
+ """
384
+ if q_total == 0:
385
+ return 1.0
386
+
387
+ return q_sensible / q_total
388
+
389
+ @staticmethod
390
+ def air_flow_rate_for_load(q_sensible: float, t_supply: float, t_return: float,
391
+ rh_return: float = 50.0, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
392
+ """
393
+ Calculate required air flow rate for a given sensible load.
394
+
395
+ Args:
396
+ q_sensible: Sensible heat load in W
397
+ t_supply: Supply air temperature in °C
398
+ t_return: Return air temperature in °C
399
+ rh_return: Return air relative humidity in % (default: 50%)
400
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
401
+
402
+ Returns:
403
+ Dictionary with air flow rate in different units
404
+ """
405
+ # Calculate return air properties
406
+ w_return = Psychrometrics.humidity_ratio(t_return, rh_return, p_atm)
407
+ rho_return = Psychrometrics.density(t_return, w_return, p_atm)
408
+
409
+ # Calculate specific heat of moist air
410
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
411
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
412
+ c_p_moist = c_pa + w_return * c_pw
413
+
414
+ # Calculate mass flow rate
415
+ delta_t = t_return - t_supply
416
+ if delta_t == 0:
417
+ raise ValueError("Supply and return temperatures cannot be equal")
418
+
419
+ m_dot = q_sensible / (c_p_moist * delta_t)
420
+
421
+ # Calculate volumetric flow rate
422
+ v_dot = m_dot / rho_return
423
+
424
+ # Convert to different units
425
+ v_dot_m3_s = v_dot
426
+ v_dot_m3_h = v_dot * 3600
427
+ v_dot_cfm = v_dot * 2118.88
428
+ v_dot_l_s = v_dot * 1000
429
+
430
+ return {
431
+ "mass_flow_rate_kg_s": m_dot,
432
+ "volumetric_flow_rate_m3_s": v_dot_m3_s,
433
+ "volumetric_flow_rate_m3_h": v_dot_m3_h,
434
+ "volumetric_flow_rate_cfm": v_dot_cfm,
435
+ "volumetric_flow_rate_l_s": v_dot_l_s
436
+ }
437
+
438
+ @staticmethod
439
+ def mixing_air_properties(m1: float, t_db1: float, rh1: float,
440
+ m2: float, t_db2: float, rh2: float,
441
+ p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
442
+ """
443
+ Calculate properties of mixed airstreams.
444
+
445
+ Args:
446
+ m1: Mass flow rate of airstream 1 in kg/s
447
+ t_db1: Dry-bulb temperature of airstream 1 in °C
448
+ rh1: Relative humidity of airstream 1 in %
449
+ m2: Mass flow rate of airstream 2 in kg/s
450
+ t_db2: Dry-bulb temperature of airstream 2 in °C
451
+ rh2: Relative humidity of airstream 2 in %
452
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
453
+
454
+ Returns:
455
+ Dictionary with mixed air properties
456
+ """
457
+ # Calculate humidity ratios
458
+ w1 = Psychrometrics.humidity_ratio(t_db1, rh1, p_atm)
459
+ w2 = Psychrometrics.humidity_ratio(t_db2, rh2, p_atm)
460
+
461
+ # Calculate enthalpies
462
+ h1 = Psychrometrics.enthalpy(t_db1, w1)
463
+ h2 = Psychrometrics.enthalpy(t_db2, w2)
464
+
465
+ # Calculate mixed air properties
466
+ m_total = m1 + m2
467
+
468
+ if m_total == 0:
469
+ raise ValueError("Total mass flow rate cannot be zero")
470
+
471
+ w_mix = (m1 * w1 + m2 * w2) / m_total
472
+ h_mix = (m1 * h1 + m2 * h2) / m_total
473
+
474
+ # Find dry-bulb temperature for the mixed air
475
+ t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
476
+
477
+ # Calculate relative humidity for the mixed air
478
+ rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
479
+
480
+ # Return mixed air properties
481
+ return Psychrometrics.moist_air_properties(t_db_mix, rh_mix, p_atm)
482
+
483
+
484
+ # Create a singleton instance
485
+ psychrometrics = Psychrometrics()
486
+
487
+ # Example usage
488
+ if __name__ == "__main__":
489
+ # Calculate properties of air at 25°C and 50% RH
490
+ properties = psychrometrics.moist_air_properties(25, 50)
491
+
492
+ print("Air Properties at 25°C and 50% RH:")
493
+ print(f"Dry-bulb temperature: {properties['dry_bulb_temperature']:.2f} °C")
494
+ print(f"Wet-bulb temperature: {properties['wet_bulb_temperature']:.2f} °C")
495
+ print(f"Dew point temperature: {properties['dew_point_temperature']:.2f} °C")
496
+ print(f"Relative humidity: {properties['relative_humidity']:.2f} %")
497
+ print(f"Humidity ratio: {properties['humidity_ratio']:.6f} kg/kg")
498
+ print(f"Enthalpy: {properties['enthalpy']/1000:.2f} kJ/kg")
499
+ print(f"Specific volume: {properties['specific_volume']:.4f} m³/kg")
500
+ print(f"Density: {properties['density']:.4f} kg/m³")
501
+ print(f"Saturation pressure: {properties['saturation_pressure']/1000:.2f} kPa")
502
+ print(f"Partial pressure: {properties['partial_pressure']/1000:.2f} kPa")
utils/scenario_comparison.py ADDED
@@ -0,0 +1,675 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scenario comparison visualization module for HVAC Load Calculator.
3
+ This module provides visualization tools for comparing different scenarios.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ import plotly.graph_objects as go
10
+ import plotly.express as px
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ import math
13
+
14
+ # Import calculation modules
15
+ from utils.cooling_load import CoolingLoadCalculator
16
+ from utils.heating_load import HeatingLoadCalculator
17
+
18
+
19
+ class ScenarioComparisonVisualization:
20
+ """Class for scenario comparison visualization."""
21
+
22
+ @staticmethod
23
+ def create_scenario_summary_table(scenarios: Dict[str, Dict[str, Any]]) -> pd.DataFrame:
24
+ """
25
+ Create a summary table of different scenarios.
26
+
27
+ Args:
28
+ scenarios: Dictionary with scenario data
29
+
30
+ Returns:
31
+ DataFrame with scenario summary
32
+ """
33
+ # Initialize data
34
+ data = []
35
+
36
+ # Process scenarios
37
+ for scenario_name, scenario_data in scenarios.items():
38
+ # Extract cooling and heating loads
39
+ cooling_loads = scenario_data.get("cooling_loads", {})
40
+ heating_loads = scenario_data.get("heating_loads", {})
41
+
42
+ # Create summary row
43
+ row = {
44
+ "Scenario": scenario_name,
45
+ "Cooling Load (W)": cooling_loads.get("total", 0),
46
+ "Sensible Heat Ratio": cooling_loads.get("sensible_heat_ratio", 0),
47
+ "Heating Load (W)": heating_loads.get("total", 0)
48
+ }
49
+
50
+ # Add to data
51
+ data.append(row)
52
+
53
+ # Create DataFrame
54
+ df = pd.DataFrame(data)
55
+
56
+ return df
57
+
58
+ @staticmethod
59
+ def create_load_comparison_chart(scenarios: Dict[str, Dict[str, Any]], load_type: str = "cooling") -> go.Figure:
60
+ """
61
+ Create a bar chart comparing loads across scenarios.
62
+
63
+ Args:
64
+ scenarios: Dictionary with scenario data
65
+ load_type: Type of load to compare ("cooling" or "heating")
66
+
67
+ Returns:
68
+ Plotly figure with load comparison
69
+ """
70
+ # Initialize data
71
+ scenario_names = []
72
+ total_loads = []
73
+ component_loads = {}
74
+
75
+ # Process scenarios
76
+ for scenario_name, scenario_data in scenarios.items():
77
+ # Extract loads based on load type
78
+ if load_type == "cooling":
79
+ loads = scenario_data.get("cooling_loads", {})
80
+ components = ["walls", "roofs", "floors", "windows_conduction", "windows_solar",
81
+ "doors", "infiltration_sensible", "infiltration_latent",
82
+ "people_sensible", "people_latent", "lights", "equipment_sensible", "equipment_latent"]
83
+ else: # heating
84
+ loads = scenario_data.get("heating_loads", {})
85
+ components = ["walls", "roofs", "floors", "windows", "doors",
86
+ "infiltration_sensible", "infiltration_latent",
87
+ "ventilation_sensible", "ventilation_latent"]
88
+
89
+ # Add scenario name
90
+ scenario_names.append(scenario_name)
91
+
92
+ # Add total load
93
+ total_loads.append(loads.get("total", 0))
94
+
95
+ # Add component loads
96
+ for component in components:
97
+ if component not in component_loads:
98
+ component_loads[component] = []
99
+
100
+ component_loads[component].append(loads.get(component, 0))
101
+
102
+ # Create figure
103
+ fig = go.Figure()
104
+
105
+ # Add total load bars
106
+ fig.add_trace(go.Bar(
107
+ x=scenario_names,
108
+ y=total_loads,
109
+ name="Total Load",
110
+ marker_color="rgba(55, 83, 109, 0.7)",
111
+ opacity=0.7
112
+ ))
113
+
114
+ # Add component load bars
115
+ for component, loads in component_loads.items():
116
+ # Skip components with zero loads
117
+ if sum(loads) == 0:
118
+ continue
119
+
120
+ # Format component name for display
121
+ display_name = component.replace("_", " ").title()
122
+
123
+ fig.add_trace(go.Bar(
124
+ x=scenario_names,
125
+ y=loads,
126
+ name=display_name,
127
+ visible="legendonly"
128
+ ))
129
+
130
+ # Update layout
131
+ title = f"{load_type.title()} Load Comparison"
132
+ y_title = f"{load_type.title()} Load (W)"
133
+
134
+ fig.update_layout(
135
+ title=title,
136
+ xaxis_title="Scenario",
137
+ yaxis_title=y_title,
138
+ barmode="group",
139
+ height=500,
140
+ legend=dict(
141
+ orientation="h",
142
+ yanchor="bottom",
143
+ y=1.02,
144
+ xanchor="right",
145
+ x=1
146
+ )
147
+ )
148
+
149
+ return fig
150
+
151
+ @staticmethod
152
+ def create_percentage_difference_chart(scenarios: Dict[str, Dict[str, Any]],
153
+ baseline_scenario: str,
154
+ load_type: str = "cooling") -> go.Figure:
155
+ """
156
+ Create a bar chart showing percentage differences from a baseline scenario.
157
+
158
+ Args:
159
+ scenarios: Dictionary with scenario data
160
+ baseline_scenario: Name of the baseline scenario
161
+ load_type: Type of load to compare ("cooling" or "heating")
162
+
163
+ Returns:
164
+ Plotly figure with percentage difference chart
165
+ """
166
+ # Check if baseline scenario exists
167
+ if baseline_scenario not in scenarios:
168
+ raise ValueError(f"Baseline scenario '{baseline_scenario}' not found in scenarios")
169
+
170
+ # Get baseline loads
171
+ if load_type == "cooling":
172
+ baseline_loads = scenarios[baseline_scenario].get("cooling_loads", {})
173
+ components = ["walls", "roofs", "floors", "windows_conduction", "windows_solar",
174
+ "doors", "infiltration_sensible", "infiltration_latent",
175
+ "people_sensible", "people_latent", "lights", "equipment_sensible", "equipment_latent"]
176
+ else: # heating
177
+ baseline_loads = scenarios[baseline_scenario].get("heating_loads", {})
178
+ components = ["walls", "roofs", "floors", "windows", "doors",
179
+ "infiltration_sensible", "infiltration_latent",
180
+ "ventilation_sensible", "ventilation_latent"]
181
+
182
+ baseline_total = baseline_loads.get("total", 0)
183
+
184
+ # Initialize data
185
+ scenario_names = []
186
+ percentage_diffs = []
187
+ component_diffs = {}
188
+
189
+ # Process scenarios (excluding baseline)
190
+ for scenario_name, scenario_data in scenarios.items():
191
+ if scenario_name == baseline_scenario:
192
+ continue
193
+
194
+ # Extract loads based on load type
195
+ if load_type == "cooling":
196
+ loads = scenario_data.get("cooling_loads", {})
197
+ else: # heating
198
+ loads = scenario_data.get("heating_loads", {})
199
+
200
+ # Add scenario name
201
+ scenario_names.append(scenario_name)
202
+
203
+ # Calculate percentage difference for total load
204
+ scenario_total = loads.get("total", 0)
205
+ if baseline_total != 0:
206
+ percentage_diff = (scenario_total - baseline_total) / baseline_total * 100
207
+ else:
208
+ percentage_diff = 0
209
+
210
+ percentage_diffs.append(percentage_diff)
211
+
212
+ # Calculate percentage differences for components
213
+ for component in components:
214
+ if component not in component_diffs:
215
+ component_diffs[component] = []
216
+
217
+ baseline_component = baseline_loads.get(component, 0)
218
+ scenario_component = loads.get(component, 0)
219
+
220
+ if baseline_component != 0:
221
+ component_diff = (scenario_component - baseline_component) / baseline_component * 100
222
+ else:
223
+ component_diff = 0
224
+
225
+ component_diffs[component].append(component_diff)
226
+
227
+ # Create figure
228
+ fig = go.Figure()
229
+
230
+ # Add total percentage difference bars
231
+ fig.add_trace(go.Bar(
232
+ x=scenario_names,
233
+ y=percentage_diffs,
234
+ name="Total Load",
235
+ marker_color="rgba(55, 83, 109, 0.7)",
236
+ opacity=0.7
237
+ ))
238
+
239
+ # Add component percentage difference bars
240
+ for component, diffs in component_diffs.items():
241
+ # Skip components with zero differences
242
+ if sum([abs(diff) for diff in diffs]) == 0:
243
+ continue
244
+
245
+ # Format component name for display
246
+ display_name = component.replace("_", " ").title()
247
+
248
+ fig.add_trace(go.Bar(
249
+ x=scenario_names,
250
+ y=diffs,
251
+ name=display_name,
252
+ visible="legendonly"
253
+ ))
254
+
255
+ # Update layout
256
+ title = f"{load_type.title()} Load Percentage Difference from {baseline_scenario}"
257
+ y_title = "Percentage Difference (%)"
258
+
259
+ fig.update_layout(
260
+ title=title,
261
+ xaxis_title="Scenario",
262
+ yaxis_title=y_title,
263
+ barmode="group",
264
+ height=500,
265
+ legend=dict(
266
+ orientation="h",
267
+ yanchor="bottom",
268
+ y=1.02,
269
+ xanchor="right",
270
+ x=1
271
+ )
272
+ )
273
+
274
+ # Add zero line
275
+ fig.add_shape(
276
+ type="line",
277
+ x0=-0.5,
278
+ x1=len(scenario_names) - 0.5,
279
+ y0=0,
280
+ y1=0,
281
+ line=dict(
282
+ color="black",
283
+ width=1,
284
+ dash="dash"
285
+ )
286
+ )
287
+
288
+ return fig
289
+
290
+ @staticmethod
291
+ def create_radar_chart(scenarios: Dict[str, Dict[str, Any]], load_type: str = "cooling") -> go.Figure:
292
+ """
293
+ Create a radar chart comparing key metrics across scenarios.
294
+
295
+ Args:
296
+ scenarios: Dictionary with scenario data
297
+ load_type: Type of load to compare ("cooling" or "heating")
298
+
299
+ Returns:
300
+ Plotly figure with radar chart
301
+ """
302
+ # Define metrics based on load type
303
+ if load_type == "cooling":
304
+ metrics = [
305
+ "total",
306
+ "total_sensible",
307
+ "total_latent",
308
+ "walls",
309
+ "roofs",
310
+ "windows_conduction",
311
+ "windows_solar",
312
+ "infiltration_sensible",
313
+ "people_sensible",
314
+ "lights",
315
+ "equipment_sensible"
316
+ ]
317
+ metric_names = [
318
+ "Total Load",
319
+ "Sensible Load",
320
+ "Latent Load",
321
+ "Walls",
322
+ "Roofs",
323
+ "Windows (Conduction)",
324
+ "Windows (Solar)",
325
+ "Infiltration",
326
+ "People",
327
+ "Lights",
328
+ "Equipment"
329
+ ]
330
+ else: # heating
331
+ metrics = [
332
+ "total",
333
+ "walls",
334
+ "roofs",
335
+ "floors",
336
+ "windows",
337
+ "doors",
338
+ "infiltration_sensible",
339
+ "ventilation_sensible"
340
+ ]
341
+ metric_names = [
342
+ "Total Load",
343
+ "Walls",
344
+ "Roofs",
345
+ "Floors",
346
+ "Windows",
347
+ "Doors",
348
+ "Infiltration",
349
+ "Ventilation"
350
+ ]
351
+
352
+ # Initialize figure
353
+ fig = go.Figure()
354
+
355
+ # Process scenarios
356
+ for scenario_name, scenario_data in scenarios.items():
357
+ # Extract loads based on load type
358
+ if load_type == "cooling":
359
+ loads = scenario_data.get("cooling_loads", {})
360
+ else: # heating
361
+ loads = scenario_data.get("heating_loads", {})
362
+
363
+ # Extract metric values
364
+ values = [loads.get(metric, 0) for metric in metrics]
365
+
366
+ # Add trace
367
+ fig.add_trace(go.Scatterpolar(
368
+ r=values,
369
+ theta=metric_names,
370
+ fill="toself",
371
+ name=scenario_name
372
+ ))
373
+
374
+ # Update layout
375
+ title = f"{load_type.title()} Load Comparison (Radar Chart)"
376
+
377
+ fig.update_layout(
378
+ title=title,
379
+ polar=dict(
380
+ radialaxis=dict(
381
+ visible=True,
382
+ range=[0, max([max([scenarios[s].get(f"{load_type}_loads", {}).get(m, 0) for m in metrics]) for s in scenarios]) * 1.1]
383
+ )
384
+ ),
385
+ height=600,
386
+ showlegend=True
387
+ )
388
+
389
+ return fig
390
+
391
+ @staticmethod
392
+ def create_parallel_coordinates_chart(scenarios: Dict[str, Dict[str, Any]]) -> go.Figure:
393
+ """
394
+ Create a parallel coordinates chart comparing scenarios.
395
+
396
+ Args:
397
+ scenarios: Dictionary with scenario data
398
+
399
+ Returns:
400
+ Plotly figure with parallel coordinates chart
401
+ """
402
+ # Initialize data
403
+ data = []
404
+
405
+ # Process scenarios
406
+ for scenario_name, scenario_data in scenarios.items():
407
+ # Extract cooling and heating loads
408
+ cooling_loads = scenario_data.get("cooling_loads", {})
409
+ heating_loads = scenario_data.get("heating_loads", {})
410
+
411
+ # Create data point
412
+ point = {
413
+ "Scenario": scenario_name,
414
+ "Cooling Load (W)": cooling_loads.get("total", 0),
415
+ "Heating Load (W)": heating_loads.get("total", 0),
416
+ "Sensible Heat Ratio": cooling_loads.get("sensible_heat_ratio", 0),
417
+ "Walls (Cooling)": cooling_loads.get("walls", 0),
418
+ "Windows (Cooling)": cooling_loads.get("windows_conduction", 0) + cooling_loads.get("windows_solar", 0),
419
+ "Internal Gains (Cooling)": cooling_loads.get("people_sensible", 0) + cooling_loads.get("lights", 0) + cooling_loads.get("equipment_sensible", 0),
420
+ "Walls (Heating)": heating_loads.get("walls", 0),
421
+ "Windows (Heating)": heating_loads.get("windows", 0),
422
+ "Infiltration (Heating)": heating_loads.get("infiltration_sensible", 0)
423
+ }
424
+
425
+ # Add to data
426
+ data.append(point)
427
+
428
+ # Create DataFrame
429
+ df = pd.DataFrame(data)
430
+
431
+ # Create figure
432
+ fig = px.parallel_coordinates(
433
+ df,
434
+ color="Cooling Load (W)",
435
+ labels={
436
+ "Scenario": "Scenario",
437
+ "Cooling Load (W)": "Cooling Load (W)",
438
+ "Heating Load (W)": "Heating Load (W)",
439
+ "Sensible Heat Ratio": "Sensible Heat Ratio",
440
+ "Walls (Cooling)": "Walls (Cooling)",
441
+ "Windows (Cooling)": "Windows (Cooling)",
442
+ "Internal Gains (Cooling)": "Internal Gains (Cooling)",
443
+ "Walls (Heating)": "Walls (Heating)",
444
+ "Windows (Heating)": "Windows (Heating)",
445
+ "Infiltration (Heating)": "Infiltration (Heating)"
446
+ },
447
+ color_continuous_scale=px.colors.sequential.Viridis
448
+ )
449
+
450
+ # Update layout
451
+ fig.update_layout(
452
+ title="Scenario Comparison (Parallel Coordinates)",
453
+ height=600
454
+ )
455
+
456
+ return fig
457
+
458
+ @staticmethod
459
+ def display_scenario_comparison(scenarios: Dict[str, Dict[str, Any]]) -> None:
460
+ """
461
+ Display scenario comparison visualization in Streamlit.
462
+
463
+ Args:
464
+ scenarios: Dictionary with scenario data
465
+ """
466
+ st.header("Scenario Comparison Visualization")
467
+
468
+ # Check if scenarios exist
469
+ if not scenarios:
470
+ st.warning("No scenarios available for comparison.")
471
+ return
472
+
473
+ # Create tabs for different visualizations
474
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
475
+ "Scenario Summary",
476
+ "Load Comparison",
477
+ "Percentage Difference",
478
+ "Radar Chart",
479
+ "Parallel Coordinates"
480
+ ])
481
+
482
+ with tab1:
483
+ st.subheader("Scenario Summary")
484
+ df = ScenarioComparisonVisualization.create_scenario_summary_table(scenarios)
485
+ st.dataframe(df, use_container_width=True)
486
+
487
+ # Add download button for CSV
488
+ csv = df.to_csv(index=False).encode('utf-8')
489
+ st.download_button(
490
+ label="Download Scenario Summary as CSV",
491
+ data=csv,
492
+ file_name="scenario_summary.csv",
493
+ mime="text/csv"
494
+ )
495
+
496
+ with tab2:
497
+ st.subheader("Load Comparison")
498
+
499
+ # Add load type selector
500
+ load_type = st.radio(
501
+ "Select Load Type",
502
+ ["cooling", "heating"],
503
+ horizontal=True,
504
+ key="load_comparison_type"
505
+ )
506
+
507
+ # Create and display chart
508
+ fig = ScenarioComparisonVisualization.create_load_comparison_chart(scenarios, load_type)
509
+ st.plotly_chart(fig, use_container_width=True)
510
+
511
+ with tab3:
512
+ st.subheader("Percentage Difference")
513
+
514
+ # Add baseline scenario selector
515
+ baseline_scenario = st.selectbox(
516
+ "Select Baseline Scenario",
517
+ list(scenarios.keys()),
518
+ key="baseline_scenario"
519
+ )
520
+
521
+ # Add load type selector
522
+ load_type = st.radio(
523
+ "Select Load Type",
524
+ ["cooling", "heating"],
525
+ horizontal=True,
526
+ key="percentage_diff_type"
527
+ )
528
+
529
+ # Create and display chart
530
+ try:
531
+ fig = ScenarioComparisonVisualization.create_percentage_difference_chart(
532
+ scenarios, baseline_scenario, load_type
533
+ )
534
+ st.plotly_chart(fig, use_container_width=True)
535
+ except ValueError as e:
536
+ st.error(str(e))
537
+
538
+ with tab4:
539
+ st.subheader("Radar Chart")
540
+
541
+ # Add load type selector
542
+ load_type = st.radio(
543
+ "Select Load Type",
544
+ ["cooling", "heating"],
545
+ horizontal=True,
546
+ key="radar_chart_type"
547
+ )
548
+
549
+ # Create and display chart
550
+ fig = ScenarioComparisonVisualization.create_radar_chart(scenarios, load_type)
551
+ st.plotly_chart(fig, use_container_width=True)
552
+
553
+ with tab5:
554
+ st.subheader("Parallel Coordinates")
555
+
556
+ # Create and display chart
557
+ fig = ScenarioComparisonVisualization.create_parallel_coordinates_chart(scenarios)
558
+ st.plotly_chart(fig, use_container_width=True)
559
+
560
+
561
+ # Create a singleton instance
562
+ scenario_comparison = ScenarioComparisonVisualization()
563
+
564
+ # Example usage
565
+ if __name__ == "__main__":
566
+ import streamlit as st
567
+
568
+ # Create sample scenarios
569
+ scenarios = {
570
+ "Base Case": {
571
+ "cooling_loads": {
572
+ "total": 5000,
573
+ "total_sensible": 4000,
574
+ "total_latent": 1000,
575
+ "sensible_heat_ratio": 0.8,
576
+ "walls": 1000,
577
+ "roofs": 800,
578
+ "floors": 200,
579
+ "windows_conduction": 500,
580
+ "windows_solar": 800,
581
+ "doors": 100,
582
+ "infiltration_sensible": 300,
583
+ "infiltration_latent": 200,
584
+ "people_sensible": 300,
585
+ "people_latent": 200,
586
+ "lights": 400,
587
+ "equipment_sensible": 400,
588
+ "equipment_latent": 600
589
+ },
590
+ "heating_loads": {
591
+ "total": 6000,
592
+ "walls": 1500,
593
+ "roofs": 1000,
594
+ "floors": 500,
595
+ "windows": 1200,
596
+ "doors": 200,
597
+ "infiltration_sensible": 800,
598
+ "infiltration_latent": 0,
599
+ "ventilation_sensible": 800,
600
+ "ventilation_latent": 0,
601
+ "internal_gains_offset": 1000
602
+ }
603
+ },
604
+ "Improved Insulation": {
605
+ "cooling_loads": {
606
+ "total": 4200,
607
+ "total_sensible": 3500,
608
+ "total_latent": 700,
609
+ "sensible_heat_ratio": 0.83,
610
+ "walls": 600,
611
+ "roofs": 500,
612
+ "floors": 150,
613
+ "windows_conduction": 500,
614
+ "windows_solar": 800,
615
+ "doors": 100,
616
+ "infiltration_sensible": 300,
617
+ "infiltration_latent": 200,
618
+ "people_sensible": 300,
619
+ "people_latent": 200,
620
+ "lights": 400,
621
+ "equipment_sensible": 400,
622
+ "equipment_latent": 300
623
+ },
624
+ "heating_loads": {
625
+ "total": 4500,
626
+ "walls": 900,
627
+ "roofs": 600,
628
+ "floors": 300,
629
+ "windows": 1200,
630
+ "doors": 200,
631
+ "infiltration_sensible": 800,
632
+ "infiltration_latent": 0,
633
+ "ventilation_sensible": 800,
634
+ "ventilation_latent": 0,
635
+ "internal_gains_offset": 1000
636
+ }
637
+ },
638
+ "Better Windows": {
639
+ "cooling_loads": {
640
+ "total": 4000,
641
+ "total_sensible": 3300,
642
+ "total_latent": 700,
643
+ "sensible_heat_ratio": 0.83,
644
+ "walls": 1000,
645
+ "roofs": 800,
646
+ "floors": 200,
647
+ "windows_conduction": 250,
648
+ "windows_solar": 400,
649
+ "doors": 100,
650
+ "infiltration_sensible": 300,
651
+ "infiltration_latent": 200,
652
+ "people_sensible": 300,
653
+ "people_latent": 200,
654
+ "lights": 400,
655
+ "equipment_sensible": 400,
656
+ "equipment_latent": 300
657
+ },
658
+ "heating_loads": {
659
+ "total": 5000,
660
+ "walls": 1500,
661
+ "roofs": 1000,
662
+ "floors": 500,
663
+ "windows": 600,
664
+ "doors": 200,
665
+ "infiltration_sensible": 800,
666
+ "infiltration_latent": 0,
667
+ "ventilation_sensible": 800,
668
+ "ventilation_latent": 0,
669
+ "internal_gains_offset": 1000
670
+ }
671
+ }
672
+ }
673
+
674
+ # Display scenario comparison
675
+ scenario_comparison.display_scenario_comparison(scenarios)
utils/shading_system.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shading system module for HVAC Load Calculator.
3
+ This module implements shading type selection and coverage percentage interface.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ from enum import Enum
12
+ from dataclasses import dataclass
13
+
14
+ # Define paths
15
+ DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
16
+
17
+
18
+ class ShadingType(Enum):
19
+ """Enumeration for shading types."""
20
+ NONE = "None"
21
+ INTERNAL = "Internal"
22
+ EXTERNAL = "External"
23
+ BETWEEN_GLASS = "Between-glass"
24
+
25
+
26
+ @dataclass
27
+ class ShadingDevice:
28
+ """Class representing a shading device."""
29
+
30
+ id: str
31
+ name: str
32
+ shading_type: ShadingType
33
+ shading_coefficient: float # 0-1 (1 = no shading)
34
+ coverage_percentage: float = 100.0 # 0-100%
35
+ description: str = ""
36
+
37
+ def __post_init__(self):
38
+ """Validate shading device data after initialization."""
39
+ if self.shading_coefficient < 0 or self.shading_coefficient > 1:
40
+ raise ValueError("Shading coefficient must be between 0 and 1")
41
+ if self.coverage_percentage < 0 or self.coverage_percentage > 100:
42
+ raise ValueError("Coverage percentage must be between 0 and 100")
43
+
44
+ @property
45
+ def effective_shading_coefficient(self) -> float:
46
+ """Calculate the effective shading coefficient considering coverage percentage."""
47
+ # If coverage is less than 100%, the effective coefficient is a weighted average
48
+ # between the device coefficient and 1.0 (no shading)
49
+ coverage_factor = self.coverage_percentage / 100.0
50
+ return self.shading_coefficient * coverage_factor + 1.0 * (1 - coverage_factor)
51
+
52
+ def to_dict(self) -> Dict[str, Any]:
53
+ """Convert the shading device to a dictionary."""
54
+ return {
55
+ "id": self.id,
56
+ "name": self.name,
57
+ "shading_type": self.shading_type.value,
58
+ "shading_coefficient": self.shading_coefficient,
59
+ "coverage_percentage": self.coverage_percentage,
60
+ "description": self.description,
61
+ "effective_shading_coefficient": self.effective_shading_coefficient
62
+ }
63
+
64
+
65
+ class ShadingSystem:
66
+ """Class for managing shading devices and calculations."""
67
+
68
+ def __init__(self):
69
+ """Initialize shading system."""
70
+ self.shading_devices = {}
71
+ self.load_preset_devices()
72
+
73
+ def load_preset_devices(self) -> None:
74
+ """Load preset shading devices."""
75
+ # Internal shading devices
76
+ self.shading_devices["preset_venetian_blinds"] = ShadingDevice(
77
+ id="preset_venetian_blinds",
78
+ name="Venetian Blinds",
79
+ shading_type=ShadingType.INTERNAL,
80
+ shading_coefficient=0.6,
81
+ description="Standard internal venetian blinds"
82
+ )
83
+
84
+ self.shading_devices["preset_roller_shade"] = ShadingDevice(
85
+ id="preset_roller_shade",
86
+ name="Roller Shade",
87
+ shading_type=ShadingType.INTERNAL,
88
+ shading_coefficient=0.7,
89
+ description="Standard internal roller shade"
90
+ )
91
+
92
+ self.shading_devices["preset_drapes_light"] = ShadingDevice(
93
+ id="preset_drapes_light",
94
+ name="Light Drapes",
95
+ shading_type=ShadingType.INTERNAL,
96
+ shading_coefficient=0.8,
97
+ description="Light-colored internal drapes"
98
+ )
99
+
100
+ self.shading_devices["preset_drapes_dark"] = ShadingDevice(
101
+ id="preset_drapes_dark",
102
+ name="Dark Drapes",
103
+ shading_type=ShadingType.INTERNAL,
104
+ shading_coefficient=0.5,
105
+ description="Dark-colored internal drapes"
106
+ )
107
+
108
+ # External shading devices
109
+ self.shading_devices["preset_overhang"] = ShadingDevice(
110
+ id="preset_overhang",
111
+ name="Overhang",
112
+ shading_type=ShadingType.EXTERNAL,
113
+ shading_coefficient=0.4,
114
+ description="External overhang"
115
+ )
116
+
117
+ self.shading_devices["preset_louvers"] = ShadingDevice(
118
+ id="preset_louvers",
119
+ name="Louvers",
120
+ shading_type=ShadingType.EXTERNAL,
121
+ shading_coefficient=0.3,
122
+ description="External louvers"
123
+ )
124
+
125
+ self.shading_devices["preset_exterior_screen"] = ShadingDevice(
126
+ id="preset_exterior_screen",
127
+ name="Exterior Screen",
128
+ shading_type=ShadingType.EXTERNAL,
129
+ shading_coefficient=0.5,
130
+ description="External screen"
131
+ )
132
+
133
+ # Between-glass shading devices
134
+ self.shading_devices["preset_between_glass_blinds"] = ShadingDevice(
135
+ id="preset_between_glass_blinds",
136
+ name="Between-glass Blinds",
137
+ shading_type=ShadingType.BETWEEN_GLASS,
138
+ shading_coefficient=0.5,
139
+ description="Blinds between glass panes"
140
+ )
141
+
142
+ def get_device(self, device_id: str) -> Optional[ShadingDevice]:
143
+ """
144
+ Get a shading device by ID.
145
+
146
+ Args:
147
+ device_id: Device identifier
148
+
149
+ Returns:
150
+ ShadingDevice object or None if not found
151
+ """
152
+ return self.shading_devices.get(device_id)
153
+
154
+ def get_devices_by_type(self, shading_type: ShadingType) -> List[ShadingDevice]:
155
+ """
156
+ Get all shading devices of a specific type.
157
+
158
+ Args:
159
+ shading_type: Shading type
160
+
161
+ Returns:
162
+ List of ShadingDevice objects
163
+ """
164
+ return [device for device in self.shading_devices.values()
165
+ if device.shading_type == shading_type]
166
+
167
+ def get_preset_devices(self) -> List[ShadingDevice]:
168
+ """
169
+ Get all preset shading devices.
170
+
171
+ Returns:
172
+ List of ShadingDevice objects
173
+ """
174
+ return [device for device_id, device in self.shading_devices.items()
175
+ if device_id.startswith("preset_")]
176
+
177
+ def get_custom_devices(self) -> List[ShadingDevice]:
178
+ """
179
+ Get all custom shading devices.
180
+
181
+ Returns:
182
+ List of ShadingDevice objects
183
+ """
184
+ return [device for device_id, device in self.shading_devices.items()
185
+ if device_id.startswith("custom_")]
186
+
187
+ def add_device(self, name: str, shading_type: ShadingType,
188
+ shading_coefficient: float, coverage_percentage: float = 100.0,
189
+ description: str = "") -> str:
190
+ """
191
+ Add a custom shading device.
192
+
193
+ Args:
194
+ name: Device name
195
+ shading_type: Shading type
196
+ shading_coefficient: Shading coefficient (0-1)
197
+ coverage_percentage: Coverage percentage (0-100)
198
+ description: Device description
199
+
200
+ Returns:
201
+ Device ID
202
+ """
203
+ import uuid
204
+
205
+ device_id = f"custom_shading_{str(uuid.uuid4())[:8]}"
206
+ device = ShadingDevice(
207
+ id=device_id,
208
+ name=name,
209
+ shading_type=shading_type,
210
+ shading_coefficient=shading_coefficient,
211
+ coverage_percentage=coverage_percentage,
212
+ description=description
213
+ )
214
+
215
+ self.shading_devices[device_id] = device
216
+ return device_id
217
+
218
+ def update_device(self, device_id: str, name: str = None,
219
+ shading_coefficient: float = None,
220
+ coverage_percentage: float = None,
221
+ description: str = None) -> bool:
222
+ """
223
+ Update a shading device.
224
+
225
+ Args:
226
+ device_id: Device identifier
227
+ name: New device name (optional)
228
+ shading_coefficient: New shading coefficient (optional)
229
+ coverage_percentage: New coverage percentage (optional)
230
+ description: New device description (optional)
231
+
232
+ Returns:
233
+ True if the device was updated, False otherwise
234
+ """
235
+ if device_id not in self.shading_devices:
236
+ return False
237
+
238
+ # Don't allow updating preset devices
239
+ if device_id.startswith("preset_"):
240
+ return False
241
+
242
+ device = self.shading_devices[device_id]
243
+
244
+ if name is not None:
245
+ device.name = name
246
+
247
+ if shading_coefficient is not None:
248
+ if shading_coefficient < 0 or shading_coefficient > 1:
249
+ return False
250
+ device.shading_coefficient = shading_coefficient
251
+
252
+ if coverage_percentage is not None:
253
+ if coverage_percentage < 0 or coverage_percentage > 100:
254
+ return False
255
+ device.coverage_percentage = coverage_percentage
256
+
257
+ if description is not None:
258
+ device.description = description
259
+
260
+ return True
261
+
262
+ def remove_device(self, device_id: str) -> bool:
263
+ """
264
+ Remove a shading device.
265
+
266
+ Args:
267
+ device_id: Device identifier
268
+
269
+ Returns:
270
+ True if the device was removed, False otherwise
271
+ """
272
+ if device_id not in self.shading_devices:
273
+ return False
274
+
275
+ # Don't allow removing preset devices
276
+ if device_id.startswith("preset_"):
277
+ return False
278
+
279
+ del self.shading_devices[device_id]
280
+ return True
281
+
282
+ def calculate_effective_shgc(self, base_shgc: float, device_id: str) -> float:
283
+ """
284
+ Calculate the effective SHGC (Solar Heat Gain Coefficient) with shading.
285
+
286
+ Args:
287
+ base_shgc: Base SHGC of the window
288
+ device_id: Shading device identifier
289
+
290
+ Returns:
291
+ Effective SHGC with shading
292
+ """
293
+ if device_id not in self.shading_devices:
294
+ return base_shgc
295
+
296
+ device = self.shading_devices[device_id]
297
+ return base_shgc * device.effective_shading_coefficient
298
+
299
+ def export_to_json(self, file_path: str) -> None:
300
+ """
301
+ Export all shading devices to a JSON file.
302
+
303
+ Args:
304
+ file_path: Path to the output JSON file
305
+ """
306
+ data = {device_id: device.to_dict() for device_id, device in self.shading_devices.items()}
307
+
308
+ with open(file_path, 'w') as f:
309
+ json.dump(data, f, indent=4)
310
+
311
+ def import_from_json(self, file_path: str) -> int:
312
+ """
313
+ Import shading devices from a JSON file.
314
+
315
+ Args:
316
+ file_path: Path to the input JSON file
317
+
318
+ Returns:
319
+ Number of devices imported
320
+ """
321
+ with open(file_path, 'r') as f:
322
+ data = json.load(f)
323
+
324
+ count = 0
325
+ for device_id, device_data in data.items():
326
+ try:
327
+ shading_type = ShadingType(device_data["shading_type"])
328
+ device = ShadingDevice(
329
+ id=device_id,
330
+ name=device_data["name"],
331
+ shading_type=shading_type,
332
+ shading_coefficient=device_data["shading_coefficient"],
333
+ coverage_percentage=device_data.get("coverage_percentage", 100.0),
334
+ description=device_data.get("description", "")
335
+ )
336
+
337
+ self.shading_devices[device_id] = device
338
+ count += 1
339
+ except Exception as e:
340
+ print(f"Error importing shading device {device_id}: {e}")
341
+
342
+ return count
343
+
344
+
345
+ # Create a singleton instance
346
+ shading_system = ShadingSystem()
347
+
348
+ # Export shading system to JSON if needed
349
+ if __name__ == "__main__":
350
+ shading_system.export_to_json(os.path.join(DATA_DIR, "data", "shading_system.json"))
utils/time_based_visualization.py ADDED
@@ -0,0 +1,745 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Time-based visualization module for HVAC Load Calculator.
3
+ This module provides visualization tools for time-based load analysis.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ import plotly.graph_objects as go
10
+ import plotly.express as px
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ import math
13
+ import calendar
14
+ from datetime import datetime, timedelta
15
+
16
+
17
+ class TimeBasedVisualization:
18
+ """Class for time-based visualization."""
19
+
20
+ @staticmethod
21
+ def create_hourly_load_profile(hourly_loads: Dict[str, List[float]],
22
+ date: str = "Jul 15") -> go.Figure:
23
+ """
24
+ Create an hourly load profile chart.
25
+
26
+ Args:
27
+ hourly_loads: Dictionary with hourly load data
28
+ date: Date for the profile (e.g., "Jul 15")
29
+
30
+ Returns:
31
+ Plotly figure with hourly load profile
32
+ """
33
+ # Create hour labels
34
+ hours = list(range(24))
35
+ hour_labels = [f"{h}:00" for h in hours]
36
+
37
+ # Create figure
38
+ fig = go.Figure()
39
+
40
+ # Add total load trace
41
+ if "total" in hourly_loads:
42
+ fig.add_trace(go.Scatter(
43
+ x=hour_labels,
44
+ y=hourly_loads["total"],
45
+ mode="lines+markers",
46
+ name="Total Load",
47
+ line=dict(color="rgba(55, 83, 109, 1)", width=3),
48
+ marker=dict(size=8)
49
+ ))
50
+
51
+ # Add component load traces
52
+ for component, loads in hourly_loads.items():
53
+ if component == "total":
54
+ continue
55
+
56
+ # Format component name for display
57
+ display_name = component.replace("_", " ").title()
58
+
59
+ fig.add_trace(go.Scatter(
60
+ x=hour_labels,
61
+ y=loads,
62
+ mode="lines+markers",
63
+ name=display_name,
64
+ marker=dict(size=6),
65
+ line=dict(width=2)
66
+ ))
67
+
68
+ # Update layout
69
+ fig.update_layout(
70
+ title=f"Hourly Load Profile ({date})",
71
+ xaxis_title="Hour of Day",
72
+ yaxis_title="Load (W)",
73
+ height=500,
74
+ legend=dict(
75
+ orientation="h",
76
+ yanchor="bottom",
77
+ y=1.02,
78
+ xanchor="right",
79
+ x=1
80
+ ),
81
+ hovermode="x unified"
82
+ )
83
+
84
+ return fig
85
+
86
+ @staticmethod
87
+ def create_daily_load_profile(daily_loads: Dict[str, List[float]],
88
+ month: str = "July") -> go.Figure:
89
+ """
90
+ Create a daily load profile chart for a month.
91
+
92
+ Args:
93
+ daily_loads: Dictionary with daily load data
94
+ month: Month name
95
+
96
+ Returns:
97
+ Plotly figure with daily load profile
98
+ """
99
+ # Get number of days in month
100
+ month_num = list(calendar.month_name).index(month)
101
+ year = datetime.now().year
102
+ num_days = calendar.monthrange(year, month_num)[1]
103
+
104
+ # Create day labels
105
+ days = list(range(1, num_days + 1))
106
+ day_labels = [f"{d}" for d in days]
107
+
108
+ # Create figure
109
+ fig = go.Figure()
110
+
111
+ # Add total load trace
112
+ if "total" in daily_loads:
113
+ fig.add_trace(go.Scatter(
114
+ x=day_labels,
115
+ y=daily_loads["total"][:num_days],
116
+ mode="lines+markers",
117
+ name="Total Load",
118
+ line=dict(color="rgba(55, 83, 109, 1)", width=3),
119
+ marker=dict(size=8)
120
+ ))
121
+
122
+ # Add component load traces
123
+ for component, loads in daily_loads.items():
124
+ if component == "total":
125
+ continue
126
+
127
+ # Format component name for display
128
+ display_name = component.replace("_", " ").title()
129
+
130
+ fig.add_trace(go.Scatter(
131
+ x=day_labels,
132
+ y=loads[:num_days],
133
+ mode="lines+markers",
134
+ name=display_name,
135
+ marker=dict(size=6),
136
+ line=dict(width=2)
137
+ ))
138
+
139
+ # Update layout
140
+ fig.update_layout(
141
+ title=f"Daily Load Profile ({month})",
142
+ xaxis_title="Day of Month",
143
+ yaxis_title="Load (W)",
144
+ height=500,
145
+ legend=dict(
146
+ orientation="h",
147
+ yanchor="bottom",
148
+ y=1.02,
149
+ xanchor="right",
150
+ x=1
151
+ ),
152
+ hovermode="x unified"
153
+ )
154
+
155
+ return fig
156
+
157
+ @staticmethod
158
+ def create_monthly_load_comparison(monthly_loads: Dict[str, List[float]],
159
+ load_type: str = "cooling") -> go.Figure:
160
+ """
161
+ Create a monthly load comparison chart.
162
+
163
+ Args:
164
+ monthly_loads: Dictionary with monthly load data
165
+ load_type: Type of load ("cooling" or "heating")
166
+
167
+ Returns:
168
+ Plotly figure with monthly load comparison
169
+ """
170
+ # Create month labels
171
+ months = list(calendar.month_name)[1:]
172
+
173
+ # Create figure
174
+ fig = go.Figure()
175
+
176
+ # Add total load bars
177
+ if "total" in monthly_loads:
178
+ fig.add_trace(go.Bar(
179
+ x=months,
180
+ y=monthly_loads["total"],
181
+ name="Total Load",
182
+ marker_color="rgba(55, 83, 109, 0.7)",
183
+ opacity=0.7
184
+ ))
185
+
186
+ # Add component load bars
187
+ for component, loads in monthly_loads.items():
188
+ if component == "total":
189
+ continue
190
+
191
+ # Format component name for display
192
+ display_name = component.replace("_", " ").title()
193
+
194
+ fig.add_trace(go.Bar(
195
+ x=months,
196
+ y=loads,
197
+ name=display_name,
198
+ visible="legendonly"
199
+ ))
200
+
201
+ # Update layout
202
+ title = f"Monthly {load_type.title()} Load Comparison"
203
+ y_title = f"{load_type.title()} Load (kWh)"
204
+
205
+ fig.update_layout(
206
+ title=title,
207
+ xaxis_title="Month",
208
+ yaxis_title=y_title,
209
+ height=500,
210
+ legend=dict(
211
+ orientation="h",
212
+ yanchor="bottom",
213
+ y=1.02,
214
+ xanchor="right",
215
+ x=1
216
+ ),
217
+ hovermode="x unified"
218
+ )
219
+
220
+ return fig
221
+
222
+ @staticmethod
223
+ def create_annual_load_distribution(annual_loads: Dict[str, float],
224
+ load_type: str = "cooling") -> go.Figure:
225
+ """
226
+ Create an annual load distribution pie chart.
227
+
228
+ Args:
229
+ annual_loads: Dictionary with annual load data by component
230
+ load_type: Type of load ("cooling" or "heating")
231
+
232
+ Returns:
233
+ Plotly figure with annual load distribution
234
+ """
235
+ # Extract components and values
236
+ components = []
237
+ values = []
238
+
239
+ for component, load in annual_loads.items():
240
+ if component == "total":
241
+ continue
242
+
243
+ # Format component name for display
244
+ display_name = component.replace("_", " ").title()
245
+ components.append(display_name)
246
+ values.append(load)
247
+
248
+ # Create pie chart
249
+ fig = go.Figure(data=[go.Pie(
250
+ labels=components,
251
+ values=values,
252
+ hole=0.3,
253
+ textinfo="label+percent",
254
+ insidetextorientation="radial"
255
+ )])
256
+
257
+ # Update layout
258
+ title = f"Annual {load_type.title()} Load Distribution"
259
+
260
+ fig.update_layout(
261
+ title=title,
262
+ height=500,
263
+ legend=dict(
264
+ orientation="h",
265
+ yanchor="bottom",
266
+ y=1.02,
267
+ xanchor="right",
268
+ x=1
269
+ )
270
+ )
271
+
272
+ return fig
273
+
274
+ @staticmethod
275
+ def create_peak_load_analysis(peak_loads: Dict[str, Dict[str, Any]],
276
+ load_type: str = "cooling") -> go.Figure:
277
+ """
278
+ Create a peak load analysis chart.
279
+
280
+ Args:
281
+ peak_loads: Dictionary with peak load data
282
+ load_type: Type of load ("cooling" or "heating")
283
+
284
+ Returns:
285
+ Plotly figure with peak load analysis
286
+ """
287
+ # Extract peak load data
288
+ components = []
289
+ values = []
290
+ times = []
291
+
292
+ for component, data in peak_loads.items():
293
+ if component == "total":
294
+ continue
295
+
296
+ # Format component name for display
297
+ display_name = component.replace("_", " ").title()
298
+ components.append(display_name)
299
+ values.append(data["value"])
300
+ times.append(data["time"])
301
+
302
+ # Create bar chart
303
+ fig = go.Figure(data=[go.Bar(
304
+ x=components,
305
+ y=values,
306
+ text=times,
307
+ textposition="auto",
308
+ hovertemplate="<b>%{x}</b><br>Peak Load: %{y:.0f} W<br>Time: %{text}<extra></extra>"
309
+ )])
310
+
311
+ # Update layout
312
+ title = f"Peak {load_type.title()} Load Analysis"
313
+ y_title = f"Peak {load_type.title()} Load (W)"
314
+
315
+ fig.update_layout(
316
+ title=title,
317
+ xaxis_title="Component",
318
+ yaxis_title=y_title,
319
+ height=500
320
+ )
321
+
322
+ return fig
323
+
324
+ @staticmethod
325
+ def create_load_duration_curve(hourly_loads: List[float],
326
+ load_type: str = "cooling") -> go.Figure:
327
+ """
328
+ Create a load duration curve.
329
+
330
+ Args:
331
+ hourly_loads: List of hourly loads for the year
332
+ load_type: Type of load ("cooling" or "heating")
333
+
334
+ Returns:
335
+ Plotly figure with load duration curve
336
+ """
337
+ # Sort loads in descending order
338
+ sorted_loads = sorted(hourly_loads, reverse=True)
339
+
340
+ # Create hour indices
341
+ hours = list(range(1, len(sorted_loads) + 1))
342
+
343
+ # Create figure
344
+ fig = go.Figure(data=[go.Scatter(
345
+ x=hours,
346
+ y=sorted_loads,
347
+ mode="lines",
348
+ line=dict(color="rgba(55, 83, 109, 1)", width=2),
349
+ fill="tozeroy",
350
+ fillcolor="rgba(55, 83, 109, 0.2)"
351
+ )])
352
+
353
+ # Update layout
354
+ title = f"{load_type.title()} Load Duration Curve"
355
+ x_title = "Hours"
356
+ y_title = f"{load_type.title()} Load (W)"
357
+
358
+ fig.update_layout(
359
+ title=title,
360
+ xaxis_title=x_title,
361
+ yaxis_title=y_title,
362
+ height=500,
363
+ xaxis=dict(
364
+ type="log",
365
+ range=[0, math.log10(len(hours))]
366
+ )
367
+ )
368
+
369
+ return fig
370
+
371
+ @staticmethod
372
+ def create_heat_map(hourly_data: List[List[float]],
373
+ x_labels: List[str],
374
+ y_labels: List[str],
375
+ title: str,
376
+ colorscale: str = "Viridis") -> go.Figure:
377
+ """
378
+ Create a heat map visualization.
379
+
380
+ Args:
381
+ hourly_data: 2D list of hourly data
382
+ x_labels: Labels for x-axis
383
+ y_labels: Labels for y-axis
384
+ title: Chart title
385
+ colorscale: Colorscale for the heatmap
386
+
387
+ Returns:
388
+ Plotly figure with heat map
389
+ """
390
+ # Create figure
391
+ fig = go.Figure(data=go.Heatmap(
392
+ z=hourly_data,
393
+ x=x_labels,
394
+ y=y_labels,
395
+ colorscale=colorscale,
396
+ colorbar=dict(title="Load (W)")
397
+ ))
398
+
399
+ # Update layout
400
+ fig.update_layout(
401
+ title=title,
402
+ height=600,
403
+ xaxis=dict(
404
+ title="Hour of Day",
405
+ tickmode="array",
406
+ tickvals=list(range(0, 24, 2)),
407
+ ticktext=[f"{h}:00" for h in range(0, 24, 2)]
408
+ ),
409
+ yaxis=dict(
410
+ title="Day",
411
+ autorange="reversed"
412
+ )
413
+ )
414
+
415
+ return fig
416
+
417
+ @staticmethod
418
+ def display_time_based_visualization(cooling_loads: Dict[str, Any] = None,
419
+ heating_loads: Dict[str, Any] = None) -> None:
420
+ """
421
+ Display time-based visualization in Streamlit.
422
+
423
+ Args:
424
+ cooling_loads: Dictionary with cooling load data
425
+ heating_loads: Dictionary with heating load data
426
+ """
427
+ st.header("Time-Based Visualization")
428
+
429
+ # Check if load data exists
430
+ if cooling_loads is None and heating_loads is None:
431
+ st.warning("No load data available for visualization.")
432
+
433
+ # Create sample data for demonstration
434
+ st.info("Using sample data for demonstration.")
435
+
436
+ # Generate sample cooling loads
437
+ cooling_loads = {
438
+ "hourly": {
439
+ "total": [1000 + 500 * math.sin(h * math.pi / 12) + 1000 * math.sin(h * math.pi / 6) for h in range(24)],
440
+ "walls": [300 + 150 * math.sin(h * math.pi / 12) for h in range(24)],
441
+ "roofs": [400 + 200 * math.sin(h * math.pi / 12) for h in range(24)],
442
+ "windows": [500 + 300 * math.sin(h * math.pi / 6) for h in range(24)],
443
+ "internal": [200 + 100 * math.sin(h * math.pi / 8) for h in range(24)]
444
+ },
445
+ "daily": {
446
+ "total": [2000 + 1000 * math.sin(d * math.pi / 15) for d in range(1, 32)],
447
+ "walls": [600 + 300 * math.sin(d * math.pi / 15) for d in range(1, 32)],
448
+ "roofs": [800 + 400 * math.sin(d * math.pi / 15) for d in range(1, 32)],
449
+ "windows": [1000 + 500 * math.sin(d * math.pi / 15) for d in range(1, 32)]
450
+ },
451
+ "monthly": {
452
+ "total": [1000, 1200, 1500, 2000, 2500, 3000, 3500, 3200, 2800, 2000, 1500, 1200],
453
+ "walls": [300, 350, 400, 500, 600, 700, 800, 750, 650, 500, 400, 350],
454
+ "roofs": [400, 450, 500, 600, 700, 800, 900, 850, 750, 600, 500, 450],
455
+ "windows": [500, 550, 600, 700, 800, 900, 1000, 950, 850, 700, 600, 550]
456
+ },
457
+ "annual": {
458
+ "total": 25000,
459
+ "walls": 6000,
460
+ "roofs": 8000,
461
+ "windows": 9000,
462
+ "internal": 2000
463
+ },
464
+ "peak": {
465
+ "total": {"value": 3500, "time": "Jul 15, 15:00"},
466
+ "walls": {"value": 800, "time": "Jul 15, 16:00"},
467
+ "roofs": {"value": 900, "time": "Jul 15, 14:00"},
468
+ "windows": {"value": 1000, "time": "Jul 15, 15:00"},
469
+ "internal": {"value": 200, "time": "Jul 15, 17:00"}
470
+ }
471
+ }
472
+
473
+ # Generate sample heating loads
474
+ heating_loads = {
475
+ "hourly": {
476
+ "total": [3000 - 1000 * math.sin(h * math.pi / 12) for h in range(24)],
477
+ "walls": [900 - 300 * math.sin(h * math.pi / 12) for h in range(24)],
478
+ "roofs": [1200 - 400 * math.sin(h * math.pi / 12) for h in range(24)],
479
+ "windows": [1500 - 500 * math.sin(h * math.pi / 12) for h in range(24)]
480
+ },
481
+ "daily": {
482
+ "total": [3000 - 1000 * math.sin(d * math.pi / 15) for d in range(1, 32)],
483
+ "walls": [900 - 300 * math.sin(d * math.pi / 15) for d in range(1, 32)],
484
+ "roofs": [1200 - 400 * math.sin(d * math.pi / 15) for d in range(1, 32)],
485
+ "windows": [1500 - 500 * math.sin(d * math.pi / 15) for d in range(1, 32)]
486
+ },
487
+ "monthly": {
488
+ "total": [3500, 3200, 2800, 2000, 1500, 1000, 800, 1000, 1500, 2000, 2800, 3500],
489
+ "walls": [1050, 960, 840, 600, 450, 300, 240, 300, 450, 600, 840, 1050],
490
+ "roofs": [1400, 1280, 1120, 800, 600, 400, 320, 400, 600, 800, 1120, 1400],
491
+ "windows": [1750, 1600, 1400, 1000, 750, 500, 400, 500, 750, 1000, 1400, 1750]
492
+ },
493
+ "annual": {
494
+ "total": 25000,
495
+ "walls": 7500,
496
+ "roofs": 10000,
497
+ "windows": 12500,
498
+ "infiltration": 5000
499
+ },
500
+ "peak": {
501
+ "total": {"value": 3500, "time": "Jan 15, 06:00"},
502
+ "walls": {"value": 1050, "time": "Jan 15, 06:00"},
503
+ "roofs": {"value": 1400, "time": "Jan 15, 06:00"},
504
+ "windows": {"value": 1750, "time": "Jan 15, 06:00"},
505
+ "infiltration": {"value": 500, "time": "Jan 15, 06:00"}
506
+ }
507
+ }
508
+
509
+ # Create tabs for different visualizations
510
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
511
+ "Hourly Profiles",
512
+ "Monthly Comparison",
513
+ "Annual Distribution",
514
+ "Peak Load Analysis",
515
+ "Heat Maps"
516
+ ])
517
+
518
+ with tab1:
519
+ st.subheader("Hourly Load Profiles")
520
+
521
+ # Add load type selector
522
+ load_type = st.radio(
523
+ "Select Load Type",
524
+ ["cooling", "heating"],
525
+ horizontal=True,
526
+ key="hourly_profile_type"
527
+ )
528
+
529
+ # Add date selector
530
+ date = st.selectbox(
531
+ "Select Date",
532
+ ["Jan 15", "Apr 15", "Jul 15", "Oct 15"],
533
+ index=2,
534
+ key="hourly_profile_date"
535
+ )
536
+
537
+ # Get appropriate load data
538
+ if load_type == "cooling":
539
+ hourly_data = cooling_loads.get("hourly", {})
540
+ else:
541
+ hourly_data = heating_loads.get("hourly", {})
542
+
543
+ # Create and display chart
544
+ fig = TimeBasedVisualization.create_hourly_load_profile(hourly_data, date)
545
+ st.plotly_chart(fig, use_container_width=True)
546
+
547
+ # Add daily profile option
548
+ st.subheader("Daily Load Profiles")
549
+
550
+ # Add month selector
551
+ month = st.selectbox(
552
+ "Select Month",
553
+ list(calendar.month_name)[1:],
554
+ index=6, # July
555
+ key="daily_profile_month"
556
+ )
557
+
558
+ # Get appropriate load data
559
+ if load_type == "cooling":
560
+ daily_data = cooling_loads.get("daily", {})
561
+ else:
562
+ daily_data = heating_loads.get("daily", {})
563
+
564
+ # Create and display chart
565
+ fig = TimeBasedVisualization.create_daily_load_profile(daily_data, month)
566
+ st.plotly_chart(fig, use_container_width=True)
567
+
568
+ with tab2:
569
+ st.subheader("Monthly Load Comparison")
570
+
571
+ # Add load type selector
572
+ load_type = st.radio(
573
+ "Select Load Type",
574
+ ["cooling", "heating"],
575
+ horizontal=True,
576
+ key="monthly_comparison_type"
577
+ )
578
+
579
+ # Get appropriate load data
580
+ if load_type == "cooling":
581
+ monthly_data = cooling_loads.get("monthly", {})
582
+ else:
583
+ monthly_data = heating_loads.get("monthly", {})
584
+
585
+ # Create and display chart
586
+ fig = TimeBasedVisualization.create_monthly_load_comparison(monthly_data, load_type)
587
+ st.plotly_chart(fig, use_container_width=True)
588
+
589
+ # Add download button for CSV
590
+ monthly_df = pd.DataFrame(monthly_data)
591
+ monthly_df.index = list(calendar.month_name)[1:]
592
+
593
+ csv = monthly_df.to_csv().encode('utf-8')
594
+ st.download_button(
595
+ label=f"Download Monthly {load_type.title()} Loads as CSV",
596
+ data=csv,
597
+ file_name=f"monthly_{load_type}_loads.csv",
598
+ mime="text/csv"
599
+ )
600
+
601
+ with tab3:
602
+ st.subheader("Annual Load Distribution")
603
+
604
+ # Add load type selector
605
+ load_type = st.radio(
606
+ "Select Load Type",
607
+ ["cooling", "heating"],
608
+ horizontal=True,
609
+ key="annual_distribution_type"
610
+ )
611
+
612
+ # Get appropriate load data
613
+ if load_type == "cooling":
614
+ annual_data = cooling_loads.get("annual", {})
615
+ else:
616
+ annual_data = heating_loads.get("annual", {})
617
+
618
+ # Create and display chart
619
+ fig = TimeBasedVisualization.create_annual_load_distribution(annual_data, load_type)
620
+ st.plotly_chart(fig, use_container_width=True)
621
+
622
+ # Display annual total
623
+ total = annual_data.get("total", 0)
624
+ st.metric(f"Total Annual {load_type.title()} Load", f"{total:,.0f} kWh")
625
+
626
+ # Add download button for CSV
627
+ annual_df = pd.DataFrame({"Component": list(annual_data.keys()), "Load (kWh)": list(annual_data.values())})
628
+
629
+ csv = annual_df.to_csv(index=False).encode('utf-8')
630
+ st.download_button(
631
+ label=f"Download Annual {load_type.title()} Loads as CSV",
632
+ data=csv,
633
+ file_name=f"annual_{load_type}_loads.csv",
634
+ mime="text/csv"
635
+ )
636
+
637
+ with tab4:
638
+ st.subheader("Peak Load Analysis")
639
+
640
+ # Add load type selector
641
+ load_type = st.radio(
642
+ "Select Load Type",
643
+ ["cooling", "heating"],
644
+ horizontal=True,
645
+ key="peak_load_type"
646
+ )
647
+
648
+ # Get appropriate load data
649
+ if load_type == "cooling":
650
+ peak_data = cooling_loads.get("peak", {})
651
+ else:
652
+ peak_data = heating_loads.get("peak", {})
653
+
654
+ # Create and display chart
655
+ fig = TimeBasedVisualization.create_peak_load_analysis(peak_data, load_type)
656
+ st.plotly_chart(fig, use_container_width=True)
657
+
658
+ # Display peak total
659
+ peak_total = peak_data.get("total", {}).get("value", 0)
660
+ peak_time = peak_data.get("total", {}).get("time", "")
661
+
662
+ st.metric(f"Peak {load_type.title()} Load", f"{peak_total:,.0f} W")
663
+ st.write(f"Peak Time: {peak_time}")
664
+
665
+ # Add download button for CSV
666
+ peak_df = pd.DataFrame({
667
+ "Component": list(peak_data.keys()),
668
+ "Peak Load (W)": [data.get("value", 0) for data in peak_data.values()],
669
+ "Time": [data.get("time", "") for data in peak_data.values()]
670
+ })
671
+
672
+ csv = peak_df.to_csv(index=False).encode('utf-8')
673
+ st.download_button(
674
+ label=f"Download Peak {load_type.title()} Loads as CSV",
675
+ data=csv,
676
+ file_name=f"peak_{load_type}_loads.csv",
677
+ mime="text/csv"
678
+ )
679
+
680
+ with tab5:
681
+ st.subheader("Heat Maps")
682
+
683
+ # Add load type selector
684
+ load_type = st.radio(
685
+ "Select Load Type",
686
+ ["cooling", "heating"],
687
+ horizontal=True,
688
+ key="heat_map_type"
689
+ )
690
+
691
+ # Add month selector
692
+ month = st.selectbox(
693
+ "Select Month",
694
+ list(calendar.month_name)[1:],
695
+ index=6, # July
696
+ key="heat_map_month"
697
+ )
698
+
699
+ # Generate heat map data
700
+ month_num = list(calendar.month_name).index(month)
701
+ year = datetime.now().year
702
+ num_days = calendar.monthrange(year, month_num)[1]
703
+
704
+ # Get appropriate hourly data
705
+ if load_type == "cooling":
706
+ hourly_data = cooling_loads.get("hourly", {}).get("total", [])
707
+ else:
708
+ hourly_data = heating_loads.get("hourly", {}).get("total", [])
709
+
710
+ # Create 2D array for heat map
711
+ heat_map_data = []
712
+ for day in range(1, num_days + 1):
713
+ # Generate hourly data with day-to-day variation
714
+ day_factor = 1 + 0.2 * math.sin(day * math.pi / 15)
715
+ day_data = [load * day_factor for load in hourly_data]
716
+ heat_map_data.append(day_data)
717
+
718
+ # Create hour and day labels
719
+ hour_labels = list(range(24))
720
+ day_labels = list(range(1, num_days + 1))
721
+
722
+ # Create and display heat map
723
+ title = f"{load_type.title()} Load Heat Map ({month})"
724
+ colorscale = "Hot" if load_type == "cooling" else "Ice"
725
+
726
+ fig = TimeBasedVisualization.create_heat_map(heat_map_data, hour_labels, day_labels, title, colorscale)
727
+ st.plotly_chart(fig, use_container_width=True)
728
+
729
+ # Add explanation
730
+ st.info(
731
+ "The heat map shows the hourly load pattern for each day of the selected month. "
732
+ "Darker colors indicate higher loads. This visualization helps identify peak load periods "
733
+ "and daily/weekly patterns."
734
+ )
735
+
736
+
737
+ # Create a singleton instance
738
+ time_based_visualization = TimeBasedVisualization()
739
+
740
+ # Example usage
741
+ if __name__ == "__main__":
742
+ import streamlit as st
743
+
744
+ # Display time-based visualization with sample data
745
+ time_based_visualization.display_time_based_visualization()
utils/u_value_calculator.py ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ U-Value calculator module for HVAC Load Calculator.
3
+ This module implements the layer-by-layer assembly builder and U-value calculation functions.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ from dataclasses import dataclass, field
12
+
13
+ # Import data models
14
+ from data.building_components import MaterialLayer
15
+ from data.reference_data import reference_data
16
+
17
+ # Define paths
18
+ DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
+
20
+
21
+ @dataclass
22
+ class MaterialAssembly:
23
+ """Class representing a material assembly for U-value calculation."""
24
+
25
+ name: str
26
+ description: str = ""
27
+ layers: List[MaterialLayer] = field(default_factory=list)
28
+
29
+ # Surface resistances (m²·K/W)
30
+ r_si: float = 0.13 # Interior surface resistance
31
+ r_se: float = 0.04 # Exterior surface resistance
32
+
33
+ def add_layer(self, layer: MaterialLayer) -> None:
34
+ """
35
+ Add a material layer to the assembly.
36
+
37
+ Args:
38
+ layer: MaterialLayer object
39
+ """
40
+ self.layers.append(layer)
41
+
42
+ def remove_layer(self, index: int) -> bool:
43
+ """
44
+ Remove a material layer from the assembly.
45
+
46
+ Args:
47
+ index: Index of the layer to remove
48
+
49
+ Returns:
50
+ True if the layer was removed, False otherwise
51
+ """
52
+ if index < 0 or index >= len(self.layers):
53
+ return False
54
+
55
+ self.layers.pop(index)
56
+ return True
57
+
58
+ def move_layer(self, from_index: int, to_index: int) -> bool:
59
+ """
60
+ Move a material layer within the assembly.
61
+
62
+ Args:
63
+ from_index: Current index of the layer
64
+ to_index: New index for the layer
65
+
66
+ Returns:
67
+ True if the layer was moved, False otherwise
68
+ """
69
+ if (from_index < 0 or from_index >= len(self.layers) or
70
+ to_index < 0 or to_index >= len(self.layers)):
71
+ return False
72
+
73
+ layer = self.layers.pop(from_index)
74
+ self.layers.insert(to_index, layer)
75
+ return True
76
+
77
+ @property
78
+ def total_thickness(self) -> float:
79
+ """Calculate the total thickness of the assembly in meters."""
80
+ return sum(layer.thickness for layer in self.layers)
81
+
82
+ @property
83
+ def r_value_layers(self) -> float:
84
+ """Calculate the total thermal resistance of all layers in m²·K/W."""
85
+ return sum(layer.r_value for layer in self.layers)
86
+
87
+ @property
88
+ def r_value_total(self) -> float:
89
+ """Calculate the total thermal resistance including surface resistances in m²·K/W."""
90
+ return self.r_si + self.r_value_layers + self.r_se
91
+
92
+ @property
93
+ def u_value(self) -> float:
94
+ """Calculate the U-value of the assembly in W/(m²·K)."""
95
+ if self.r_value_total == 0:
96
+ return float('inf')
97
+ return 1 / self.r_value_total
98
+
99
+ @property
100
+ def thermal_mass(self) -> Optional[float]:
101
+ """Calculate the total thermal mass of the assembly in J/(m²·K)."""
102
+ masses = [layer.thermal_mass for layer in self.layers]
103
+ if None in masses:
104
+ return None
105
+ return sum(masses)
106
+
107
+ def to_dict(self) -> Dict[str, Any]:
108
+ """Convert the material assembly to a dictionary."""
109
+ return {
110
+ "name": self.name,
111
+ "description": self.description,
112
+ "layers": [layer.to_dict() for layer in self.layers],
113
+ "r_si": self.r_si,
114
+ "r_se": self.r_se,
115
+ "total_thickness": self.total_thickness,
116
+ "r_value_layers": self.r_value_layers,
117
+ "r_value_total": self.r_value_total,
118
+ "u_value": self.u_value,
119
+ "thermal_mass": self.thermal_mass
120
+ }
121
+
122
+
123
+ class UValueCalculator:
124
+ """Class for calculating U-values of material assemblies."""
125
+
126
+ def __init__(self):
127
+ """Initialize U-value calculator."""
128
+ self.assemblies = {}
129
+ self.load_preset_assemblies()
130
+
131
+ def load_preset_assemblies(self) -> None:
132
+ """Load preset material assemblies."""
133
+ # Create preset assemblies from reference data
134
+
135
+ # Wall assemblies
136
+ for wall_id, wall_data in reference_data.wall_types.items():
137
+ # Create material layers
138
+ layers = []
139
+ for layer_data in wall_data.get("layers", []):
140
+ material_id = layer_data.get("material")
141
+ thickness = layer_data.get("thickness")
142
+
143
+ material = reference_data.get_material(material_id)
144
+ if material:
145
+ layer = MaterialLayer(
146
+ name=material["name"],
147
+ thickness=thickness,
148
+ conductivity=material["conductivity"],
149
+ density=material.get("density"),
150
+ specific_heat=material.get("specific_heat")
151
+ )
152
+ layers.append(layer)
153
+
154
+ # Create assembly
155
+ assembly_id = f"preset_wall_{wall_id}"
156
+ assembly = MaterialAssembly(
157
+ name=wall_data["name"],
158
+ description=wall_data["description"],
159
+ layers=layers
160
+ )
161
+
162
+ self.assemblies[assembly_id] = assembly
163
+
164
+ # Roof assemblies
165
+ for roof_id, roof_data in reference_data.roof_types.items():
166
+ # Create material layers
167
+ layers = []
168
+ for layer_data in roof_data.get("layers", []):
169
+ material_id = layer_data.get("material")
170
+ thickness = layer_data.get("thickness")
171
+
172
+ material = reference_data.get_material(material_id)
173
+ if material:
174
+ layer = MaterialLayer(
175
+ name=material["name"],
176
+ thickness=thickness,
177
+ conductivity=material["conductivity"],
178
+ density=material.get("density"),
179
+ specific_heat=material.get("specific_heat")
180
+ )
181
+ layers.append(layer)
182
+
183
+ # Create assembly
184
+ assembly_id = f"preset_roof_{roof_id}"
185
+ assembly = MaterialAssembly(
186
+ name=roof_data["name"],
187
+ description=roof_data["description"],
188
+ layers=layers
189
+ )
190
+
191
+ self.assemblies[assembly_id] = assembly
192
+
193
+ # Floor assemblies
194
+ for floor_id, floor_data in reference_data.floor_types.items():
195
+ # Create material layers
196
+ layers = []
197
+ for layer_data in floor_data.get("layers", []):
198
+ material_id = layer_data.get("material")
199
+ thickness = layer_data.get("thickness")
200
+
201
+ material = reference_data.get_material(material_id)
202
+ if material:
203
+ layer = MaterialLayer(
204
+ name=material["name"],
205
+ thickness=thickness,
206
+ conductivity=material["conductivity"],
207
+ density=material.get("density"),
208
+ specific_heat=material.get("specific_heat")
209
+ )
210
+ layers.append(layer)
211
+
212
+ # Create assembly
213
+ assembly_id = f"preset_floor_{floor_id}"
214
+ assembly = MaterialAssembly(
215
+ name=floor_data["name"],
216
+ description=floor_data["description"],
217
+ layers=layers
218
+ )
219
+
220
+ self.assemblies[assembly_id] = assembly
221
+
222
+ def get_assembly(self, assembly_id: str) -> Optional[MaterialAssembly]:
223
+ """
224
+ Get a material assembly by ID.
225
+
226
+ Args:
227
+ assembly_id: Assembly identifier
228
+
229
+ Returns:
230
+ MaterialAssembly object or None if not found
231
+ """
232
+ return self.assemblies.get(assembly_id)
233
+
234
+ def get_preset_assemblies(self) -> Dict[str, MaterialAssembly]:
235
+ """
236
+ Get all preset material assemblies.
237
+
238
+ Returns:
239
+ Dictionary of preset MaterialAssembly objects
240
+ """
241
+ return {assembly_id: assembly for assembly_id, assembly in self.assemblies.items()
242
+ if assembly_id.startswith("preset_")}
243
+
244
+ def get_custom_assemblies(self) -> Dict[str, MaterialAssembly]:
245
+ """
246
+ Get all custom material assemblies.
247
+
248
+ Returns:
249
+ Dictionary of custom MaterialAssembly objects
250
+ """
251
+ return {assembly_id: assembly for assembly_id, assembly in self.assemblies.items()
252
+ if assembly_id.startswith("custom_")}
253
+
254
+ def create_assembly(self, name: str, description: str = "") -> str:
255
+ """
256
+ Create a new material assembly.
257
+
258
+ Args:
259
+ name: Assembly name
260
+ description: Assembly description
261
+
262
+ Returns:
263
+ Assembly ID
264
+ """
265
+ import uuid
266
+
267
+ assembly_id = f"custom_assembly_{str(uuid.uuid4())[:8]}"
268
+ assembly = MaterialAssembly(name=name, description=description)
269
+
270
+ self.assemblies[assembly_id] = assembly
271
+ return assembly_id
272
+
273
+ def add_layer_to_assembly(self, assembly_id: str, material_id: str, thickness: float) -> bool:
274
+ """
275
+ Add a material layer to an assembly.
276
+
277
+ Args:
278
+ assembly_id: Assembly identifier
279
+ material_id: Material identifier
280
+ thickness: Layer thickness in meters
281
+
282
+ Returns:
283
+ True if the layer was added, False otherwise
284
+ """
285
+ if assembly_id not in self.assemblies:
286
+ return False
287
+
288
+ material = reference_data.get_material(material_id)
289
+ if not material:
290
+ return False
291
+
292
+ layer = MaterialLayer(
293
+ name=material["name"],
294
+ thickness=thickness,
295
+ conductivity=material["conductivity"],
296
+ density=material.get("density"),
297
+ specific_heat=material.get("specific_heat")
298
+ )
299
+
300
+ self.assemblies[assembly_id].add_layer(layer)
301
+ return True
302
+
303
+ def add_custom_layer_to_assembly(self, assembly_id: str, name: str, thickness: float,
304
+ conductivity: float, density: float = None,
305
+ specific_heat: float = None) -> bool:
306
+ """
307
+ Add a custom material layer to an assembly.
308
+
309
+ Args:
310
+ assembly_id: Assembly identifier
311
+ name: Layer name
312
+ thickness: Layer thickness in meters
313
+ conductivity: Thermal conductivity in W/(m·K)
314
+ density: Density in kg/m³ (optional)
315
+ specific_heat: Specific heat capacity in J/(kg·K) (optional)
316
+
317
+ Returns:
318
+ True if the layer was added, False otherwise
319
+ """
320
+ if assembly_id not in self.assemblies:
321
+ return False
322
+
323
+ layer = MaterialLayer(
324
+ name=name,
325
+ thickness=thickness,
326
+ conductivity=conductivity,
327
+ density=density,
328
+ specific_heat=specific_heat
329
+ )
330
+
331
+ self.assemblies[assembly_id].add_layer(layer)
332
+ return True
333
+
334
+ def remove_layer_from_assembly(self, assembly_id: str, layer_index: int) -> bool:
335
+ """
336
+ Remove a material layer from an assembly.
337
+
338
+ Args:
339
+ assembly_id: Assembly identifier
340
+ layer_index: Index of the layer to remove
341
+
342
+ Returns:
343
+ True if the layer was removed, False otherwise
344
+ """
345
+ if assembly_id not in self.assemblies:
346
+ return False
347
+
348
+ return self.assemblies[assembly_id].remove_layer(layer_index)
349
+
350
+ def move_layer_in_assembly(self, assembly_id: str, from_index: int, to_index: int) -> bool:
351
+ """
352
+ Move a material layer within an assembly.
353
+
354
+ Args:
355
+ assembly_id: Assembly identifier
356
+ from_index: Current index of the layer
357
+ to_index: New index for the layer
358
+
359
+ Returns:
360
+ True if the layer was moved, False otherwise
361
+ """
362
+ if assembly_id not in self.assemblies:
363
+ return False
364
+
365
+ return self.assemblies[assembly_id].move_layer(from_index, to_index)
366
+
367
+ def calculate_u_value(self, assembly_id: str) -> Optional[float]:
368
+ """
369
+ Calculate the U-value of an assembly.
370
+
371
+ Args:
372
+ assembly_id: Assembly identifier
373
+
374
+ Returns:
375
+ U-value in W/(m²·K) or None if the assembly was not found
376
+ """
377
+ if assembly_id not in self.assemblies:
378
+ return None
379
+
380
+ return self.assemblies[assembly_id].u_value
381
+
382
+ def calculate_r_value(self, assembly_id: str) -> Optional[float]:
383
+ """
384
+ Calculate the R-value of an assembly.
385
+
386
+ Args:
387
+ assembly_id: Assembly identifier
388
+
389
+ Returns:
390
+ R-value in m²·K/W or None if the assembly was not found
391
+ """
392
+ if assembly_id not in self.assemblies:
393
+ return None
394
+
395
+ return self.assemblies[assembly_id].r_value_total
396
+
397
+ def export_to_json(self, file_path: str) -> None:
398
+ """
399
+ Export all assemblies to a JSON file.
400
+
401
+ Args:
402
+ file_path: Path to the output JSON file
403
+ """
404
+ data = {assembly_id: assembly.to_dict() for assembly_id, assembly in self.assemblies.items()}
405
+
406
+ with open(file_path, 'w') as f:
407
+ json.dump(data, f, indent=4)
408
+
409
+ def import_from_json(self, file_path: str) -> int:
410
+ """
411
+ Import assemblies from a JSON file.
412
+
413
+ Args:
414
+ file_path: Path to the input JSON file
415
+
416
+ Returns:
417
+ Number of assemblies imported
418
+ """
419
+ with open(file_path, 'r') as f:
420
+ data = json.load(f)
421
+
422
+ count = 0
423
+ for assembly_id, assembly_data in data.items():
424
+ try:
425
+ # Create assembly
426
+ assembly = MaterialAssembly(
427
+ name=assembly_data["name"],
428
+ description=assembly_data.get("description", ""),
429
+ r_si=assembly_data.get("r_si", 0.13),
430
+ r_se=assembly_data.get("r_se", 0.04)
431
+ )
432
+
433
+ # Add layers
434
+ for layer_data in assembly_data.get("layers", []):
435
+ layer = MaterialLayer(
436
+ name=layer_data["name"],
437
+ thickness=layer_data["thickness"],
438
+ conductivity=layer_data["conductivity"],
439
+ density=layer_data.get("density"),
440
+ specific_heat=layer_data.get("specific_heat")
441
+ )
442
+ assembly.add_layer(layer)
443
+
444
+ self.assemblies[assembly_id] = assembly
445
+ count += 1
446
+ except Exception as e:
447
+ print(f"Error importing assembly {assembly_id}: {e}")
448
+
449
+ return count
450
+
451
+
452
+ # Create a singleton instance
453
+ u_value_calculator = UValueCalculator()
454
+
455
+ # Export U-value calculator to JSON if needed
456
+ if __name__ == "__main__":
457
+ u_value_calculator.export_to_json(os.path.join(DATA_DIR, "data", "u_value_calculator.json"))