mabuseif commited on
Commit
845939b
·
verified ·
1 Parent(s): 34f7124

Upload 27 files

Browse files
README.md CHANGED
@@ -1,19 +1,13 @@
1
  ---
2
- title: HVAC 03
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
  pinned: false
11
- short_description: Streamlit template space
12
  ---
13
 
14
- # Welcome to Streamlit!
15
-
16
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
17
-
18
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
- forums](https://discuss.streamlit.io).
 
1
  ---
2
+ name: hvac-calculator
3
+ title: HVAC Load Calculator
4
+ emoji: 🌡️
5
+ colorFrom: blue
6
+ colorTo: green
7
+ sdk: streamlit
8
+ sdk_version: 1.28.0
9
+ app_file: app/main.py
10
  pinned: false
 
11
  ---
12
 
13
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
app/building_info_form.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Building information input form for HVAC Load Calculator.
3
+ This module provides the UI components for entering building information.
4
+
5
+ Author: Dr Majed Abuseif
6
+ Date: March 2025
7
+ Version: 1.0.0
8
+ """
9
+
10
+ import streamlit as st
11
+ import pandas as pd
12
+ import numpy as np
13
+ import pycountry
14
+ from typing import Dict, List, Any, Optional, Tuple
15
+ import os
16
+
17
+ # Import data models
18
+ from data.building_components import Orientation, ComponentType
19
+
20
+
21
+ class BuildingInfoForm:
22
+ """Class for building information input form."""
23
+
24
+ def __init__(self):
25
+ """Initialize the building information form."""
26
+ self.countries = sorted([country.name for country in pycountry.countries])
27
+
28
+ def display(self):
29
+ """Display the building information form."""
30
+ self.display_building_info_form(st.session_state)
31
+
32
+ def display_building_info_form(self, session_state: Dict[str, Any]) -> None:
33
+ """Display building information input form in Streamlit."""
34
+ st.header("Building Information")
35
+
36
+ if "building_info" not in session_state:
37
+ session_state["building_info"] = {
38
+ "project_name": "",
39
+ "building_name": "",
40
+ "country": "",
41
+ "city": "",
42
+ "building_type": "",
43
+ "floor_area": 0.0,
44
+ "width": 0.0,
45
+ "depth": 0.0,
46
+ "building_height": 3.0,
47
+ "orientation": "NORTH",
48
+ "operating_hours": "8:00-18:00"
49
+ }
50
+
51
+ default_values = {
52
+ "project_name": "",
53
+ "building_name": "",
54
+ "country": "",
55
+ "city": "",
56
+ "building_type": "",
57
+ "floor_area": 0.0,
58
+ "width": 0.0,
59
+ "depth": 0.0,
60
+ "building_height": 3.0,
61
+ "orientation": "NORTH",
62
+ "operating_hours": "8:00-18:00"
63
+ }
64
+
65
+ for key, default_value in default_values.items():
66
+ if key not in session_state["building_info"]:
67
+ session_state["building_info"][key] = default_value
68
+
69
+ if "data_saved" not in session_state:
70
+ session_state["data_saved"] = False
71
+
72
+ with st.form(key="building_info_form"):
73
+ st.subheader("Project Information")
74
+
75
+ col1, col2 = st.columns(2)
76
+ with col1:
77
+ session_state["building_info"]["project_name"] = st.text_input(
78
+ "Project Name",
79
+ value=session_state["building_info"]["project_name"],
80
+ help="Enter the project's identification name"
81
+ )
82
+ session_state["building_info"]["building_name"] = st.text_input(
83
+ "Building Name",
84
+ value=session_state["building_info"]["building_name"],
85
+ help="Enter the building's identification name"
86
+ )
87
+
88
+ with col2:
89
+ session_state["building_info"]["country"] = st.selectbox(
90
+ "Country",
91
+ options=[""] + self.countries,
92
+ index=0 if not session_state["building_info"]["country"] else
93
+ self.countries.index(session_state["building_info"]["country"]) + 1,
94
+ help="Select the building's country location"
95
+ )
96
+ session_state["building_info"]["city"] = st.text_input(
97
+ "City",
98
+ value=session_state["building_info"]["city"],
99
+ help="Enter the building's city location"
100
+ )
101
+
102
+ st.subheader("Building Characteristics")
103
+
104
+ col1, col2 = st.columns(2)
105
+ with col1:
106
+ session_state["building_info"]["building_type"] = st.selectbox(
107
+ "Building Type",
108
+ ["Residential", "Office", "Retail", "Educational", "Healthcare", "Industrial", "Other"],
109
+ index=1 if session_state["building_info"]["building_type"] == "" else
110
+ ["Residential", "Office", "Retail", "Educational", "Healthcare", "Industrial", "Other"].index(session_state["building_info"]["building_type"]),
111
+ help="Select the building's purpose or usage type"
112
+ )
113
+
114
+ with col2:
115
+ session_state["building_info"]["building_height"] = st.number_input(
116
+ "Building Height (m)",
117
+ min_value=2.0,
118
+ max_value=1000.0,
119
+ value=float(session_state["building_info"]["building_height"]),
120
+ step=0.1,
121
+ help="Enter the total height of the building in meters"
122
+ )
123
+
124
+ st.subheader("Building Dimensions")
125
+ session_state["building_info"]["floor_area"] = st.number_input(
126
+ "Total Floor Area (m²)",
127
+ min_value=0.0,
128
+ value=float(session_state["building_info"]["floor_area"]),
129
+ step=10.0,
130
+ help="Enter the total floor area of the building in square meters (optional if width and depth provided)"
131
+ )
132
+
133
+ # Center the OR using columns
134
+ col1, col2, col3 = st.columns([2, 1, 2])
135
+ with col2:
136
+ st.markdown("Enter the total floor area above OR the building width and depth below")
137
+
138
+ col1, col2 = st.columns(2)
139
+ with col1:
140
+ session_state["building_info"]["width"] = st.number_input(
141
+ "Width (m)",
142
+ min_value=0.0,
143
+ value=float(session_state["building_info"]["width"]),
144
+ step=1.0,
145
+ help="Enter the building's width in meters (optional if area provided)"
146
+ )
147
+ with col2:
148
+ session_state["building_info"]["depth"] = st.number_input(
149
+ "Depth (m)",
150
+ min_value=0.0,
151
+ value=float(session_state["building_info"]["depth"]),
152
+ step=1.0,
153
+ help="Enter the building's depth in meters (optional if area provided)"
154
+ )
155
+
156
+ st.subheader("Building Orientation")
157
+ session_state["building_info"]["orientation"] = st.selectbox(
158
+ "Building Orientation",
159
+ ["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"],
160
+ index=["NORTH", "NORTHEAST", "EAST", "SOUTHEAST", "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST"].index(session_state["building_info"]["orientation"]),
161
+ help="Select the direction of the building's main facade"
162
+ )
163
+
164
+ st.subheader("Operating Hours")
165
+ session_state["building_info"]["operating_hours"] = st.text_input(
166
+ "Operating Hours",
167
+ value=session_state["building_info"]["operating_hours"],
168
+ help="Enter the building's daily operating hours (e.g., 8:00-18:00)"
169
+ )
170
+
171
+ submitted = st.form_submit_button("Save Building Information")
172
+
173
+ if submitted:
174
+ valid, errors = self.validate_building_info(session_state["building_info"])
175
+ if not valid:
176
+ for error in errors:
177
+ st.error(error)
178
+ else:
179
+ if session_state["building_info"]["width"] > 0 and session_state["building_info"]["depth"] > 0:
180
+ calculated_area = session_state["building_info"]["width"] * session_state["building_info"]["depth"]
181
+ if session_state["building_info"]["floor_area"] == 0:
182
+ session_state["building_info"]["floor_area"] = calculated_area
183
+
184
+ total_volume = session_state["building_info"]["floor_area"] * session_state["building_info"]["building_height"]
185
+
186
+ session_state["save_results"] = {
187
+ "success": "Building information saved successfully!",
188
+ "area": f"Total Floor Area: {session_state['building_info']['floor_area']:.1f} m²",
189
+ "volume": f"Total Building Volume: {total_volume:.1f} m³"
190
+ }
191
+ session_state["data_saved"] = True
192
+
193
+ # Display results if they exist
194
+ if "save_results" in session_state and session_state["data_saved"]:
195
+ st.success(session_state["save_results"]["success"])
196
+ st.info(session_state["save_results"]["area"])
197
+ st.info(session_state["save_results"]["volume"])
198
+
199
+ # Proceed button with immediate navigation
200
+ if session_state["data_saved"]:
201
+ if st.button("Proceed to Climate Data"):
202
+ session_state["page"] = "Climate Data"
203
+ session_state["data_saved"] = False
204
+ if "save_results" in session_state:
205
+ del session_state["save_results"]
206
+
207
+ @staticmethod
208
+ def validate_building_info(building_info: Dict[str, Any]) -> Tuple[bool, List[str]]:
209
+ """Validate building information."""
210
+ valid = True
211
+ errors = []
212
+
213
+ required_fields = ["project_name", "building_name", "country", "city", "building_type"]
214
+ for field in required_fields:
215
+ if field not in building_info or not building_info[field]:
216
+ valid = False
217
+ errors.append(f"Missing required field: {field}")
218
+
219
+ if building_info.get("floor_area", 0) <= 0 and (building_info.get("width", 0) <= 0 or building_info.get("depth", 0) <= 0):
220
+ valid = False
221
+ errors.append("Must provide either floor area or both width and depth dimensions")
222
+
223
+ if building_info.get("building_height", 0) <= 0:
224
+ valid = False
225
+ errors.append("Building height must be greater than zero")
226
+
227
+ return valid, errors
228
+
229
+
230
+ if __name__ == "__main__":
231
+ form = BuildingInfoForm()
232
+ form.display()
app/component_selection.py ADDED
@@ -0,0 +1,801 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Component Selection Module
3
+ Provides UI for selecting building components in the HVAC Load Calculator.
4
+ All dependencies are included within this file for standalone operation.
5
+ Updated 2025-04-28: Added perimeter field to Floor class for F-factor calculations.
6
+ Updated 2025-04-29: Fixed Floors table headings, added insulated field for dynamic F-factor.
7
+ """
8
+
9
+ import streamlit as st
10
+ import pandas as pd
11
+ import numpy as np
12
+ import json
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from enum import Enum
16
+ from typing import Dict, List, Any, Optional
17
+ import io
18
+
19
+ # --- Enums ---
20
+ class Orientation(Enum):
21
+ NORTH = "North"
22
+ NORTHEAST = "Northeast"
23
+ EAST = "East"
24
+ SOUTHEAST = "Southeast"
25
+ SOUTH = "South"
26
+ SOUTHWEST = "Southwest"
27
+ WEST = "West"
28
+ NORTHWEST = "Northwest"
29
+ HORIZONTAL = "Horizontal"
30
+ NOT_APPLICABLE = "N/A"
31
+
32
+ class ComponentType(Enum):
33
+ WALL = "Wall"
34
+ ROOF = "Roof"
35
+ FLOOR = "Floor"
36
+ WINDOW = "Window"
37
+ DOOR = "Door"
38
+
39
+ # --- Data Models ---
40
+ @dataclass
41
+ class MaterialLayer:
42
+ name: str
43
+ thickness: float # in mm
44
+ conductivity: float # W/(m·K)
45
+
46
+ @dataclass
47
+ class BuildingComponent:
48
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
49
+ name: str = "Unnamed Component"
50
+ component_type: ComponentType = ComponentType.WALL
51
+ u_value: float = 0.0 # W/(m²·K)
52
+ area: float = 0.0 # m²
53
+ orientation: Orientation = Orientation.NOT_APPLICABLE
54
+
55
+ def __post_init__(self):
56
+ if self.area <= 0:
57
+ raise ValueError("Area must be greater than zero")
58
+ if self.u_value <= 0:
59
+ raise ValueError("U-value must be greater than zero")
60
+
61
+ def to_dict(self) -> dict:
62
+ return {
63
+ "id": self.id, "name": self.name, "component_type": self.component_type.value,
64
+ "u_value": self.u_value, "area": self.area, "orientation": self.orientation.value
65
+ }
66
+
67
+ @dataclass
68
+ class Wall(BuildingComponent):
69
+ wall_type: str = "Brick"
70
+ wall_group: str = "A" # ASHRAE group
71
+ absorptivity: float = 0.6
72
+ shading_coefficient: float = 1.0
73
+ infiltration_rate_cfm: float = 0.0
74
+
75
+ def __post_init__(self):
76
+ super().__post_init__()
77
+ self.component_type = ComponentType.WALL
78
+ if not 0 <= self.absorptivity <= 1:
79
+ raise ValueError("Absorptivity must be between 0 and 1")
80
+ if not 0 <= self.shading_coefficient <= 1:
81
+ raise ValueError("Shading coefficient must be between 0 and 1")
82
+ if self.infiltration_rate_cfm < 0:
83
+ raise ValueError("Infiltration rate cannot be negative")
84
+ VALID_WALL_GROUPS = {"A", "B", "C", "D", "E", "F", "G", "H"}
85
+ if self.wall_group not in VALID_WALL_GROUPS:
86
+ st.warning(f"Invalid wall_group '{self.wall_group}' for wall '{self.name}'. Defaulting to 'A'.")
87
+ self.wall_group = "A"
88
+
89
+ def to_dict(self) -> dict:
90
+ base_dict = super().to_dict()
91
+ base_dict.update({
92
+ "wall_type": self.wall_type, "wall_group": self.wall_group, "absorptivity": self.absorptivity,
93
+ "shading_coefficient": self.shading_coefficient, "infiltration_rate_cfm": self.infiltration_rate_cfm
94
+ })
95
+ return base_dict
96
+
97
+ @dataclass
98
+ class Roof(BuildingComponent):
99
+ roof_type: str = "Concrete"
100
+ roof_group: str = "A" # ASHRAE group
101
+ slope: str = "Flat"
102
+ absorptivity: float = 0.6
103
+
104
+ def __post_init__(self):
105
+ super().__post_init__()
106
+ self.component_type = ComponentType.ROOF
107
+ if not self.orientation == Orientation.HORIZONTAL:
108
+ self.orientation = Orientation.HORIZONTAL
109
+ if not 0 <= self.absorptivity <= 1:
110
+ raise ValueError("Absorptivity must be between 0 and 1")
111
+ VALID_ROOF_GROUPS = {"A", "B", "C", "D", "E", "F", "G"}
112
+ if self.roof_group not in VALID_ROOF_GROUPS:
113
+ st.warning(f"Invalid roof_group '{self.roof_group}' for roof '{self.name}'. Defaulting to 'A'.")
114
+ self.roof_group = "A"
115
+
116
+ def to_dict(self) -> dict:
117
+ base_dict = super().to_dict()
118
+ base_dict.update({
119
+ "roof_type": self.roof_type, "roof_group": self.roof_group, "slope": self.slope, "absorptivity": self.absorptivity
120
+ })
121
+ return base_dict
122
+
123
+ @dataclass
124
+ class Floor(BuildingComponent):
125
+ floor_type: str = "Concrete"
126
+ ground_contact: bool = True
127
+ ground_temperature_c: float = 25.0
128
+ perimeter: float = 0.0
129
+ insulated: bool = False # NEW: For dynamic F-factor
130
+
131
+ def __post_init__(self):
132
+ super().__post_init__()
133
+ self.component_type = ComponentType.FLOOR
134
+ self.orientation = Orientation.NOT_APPLICABLE
135
+ if self.perimeter < 0:
136
+ raise ValueError("Perimeter cannot be negative")
137
+ if self.ground_contact and not (-10 <= self.ground_temperature_c <= 40):
138
+ raise ValueError("Ground temperature must be between -10°C and 40°C for ground-contact floors")
139
+
140
+ def to_dict(self) -> dict:
141
+ base_dict = super().to_dict()
142
+ base_dict.update({
143
+ "floor_type": self.floor_type, "ground_contact": self.ground_contact,
144
+ "ground_temperature_c": self.ground_temperature_c, "perimeter": self.perimeter,
145
+ "insulated": self.insulated
146
+ })
147
+ return base_dict
148
+
149
+ @dataclass
150
+ class Window(BuildingComponent):
151
+ shgc: float = 0.7
152
+ shading_device: str = "None"
153
+ shading_coefficient: float = 1.0
154
+ frame_type: str = "Aluminum"
155
+ frame_percentage: float = 20.0
156
+ infiltration_rate_cfm: float = 0.0
157
+
158
+ def __post_init__(self):
159
+ super().__post_init__()
160
+ self.component_type = ComponentType.WINDOW
161
+ if not 0 <= self.shgc <= 1:
162
+ raise ValueError("SHGC must be between 0 and 1")
163
+ if not 0 <= self.shading_coefficient <= 1:
164
+ raise ValueError("Shading coefficient must be between 0 and 1")
165
+ if not 0 <= self.frame_percentage <= 30:
166
+ raise ValueError("Frame percentage must be between 0 and 30")
167
+ if self.infiltration_rate_cfm < 0:
168
+ raise ValueError("Infiltration rate cannot be negative")
169
+
170
+ def to_dict(self) -> dict:
171
+ base_dict = super().to_dict()
172
+ base_dict.update({
173
+ "shgc": self.shgc, "shading_device": self.shading_device, "shading_coefficient": self.shading_coefficient,
174
+ "frame_type": self.frame_type, "frame_percentage": self.frame_percentage, "infiltration_rate_cfm": self.infiltration_rate_cfm
175
+ })
176
+ return base_dict
177
+
178
+ @dataclass
179
+ class Door(BuildingComponent):
180
+ door_type: str = "Solid Wood"
181
+ infiltration_rate_cfm: float = 0.0
182
+
183
+ def __post_init__(self):
184
+ super().__post_init__()
185
+ self.component_type = ComponentType.DOOR
186
+ if self.infiltration_rate_cfm < 0:
187
+ raise ValueError("Infiltration rate cannot be negative")
188
+
189
+ def to_dict(self) -> dict:
190
+ base_dict = super().to_dict()
191
+ base_dict.update({"door_type": self.door_type, "infiltration_rate_cfm": self.infiltration_rate_cfm})
192
+ return base_dict
193
+
194
+ # --- Reference Data ---
195
+ class ReferenceData:
196
+ def __init__(self):
197
+ self.data = {
198
+ "materials": {
199
+ "Concrete": {"conductivity": 1.4},
200
+ "Insulation": {"conductivity": 0.04},
201
+ "Brick": {"conductivity": 0.8},
202
+ "Glass": {"conductivity": 1.0},
203
+ "Wood": {"conductivity": 0.15}
204
+ },
205
+ "wall_types": {
206
+ "Brick Wall": {"u_value": 2.0, "absorptivity": 0.6, "wall_group": "A"},
207
+ "Insulated Brick": {"u_value": 0.5, "absorptivity": 0.6, "wall_group": "B"},
208
+ "Concrete Block": {"u_value": 1.8, "absorptivity": 0.6, "wall_group": "C"},
209
+ "Insulated Concrete": {"u_value": 0.4, "absorptivity": 0.6, "wall_group": "D"},
210
+ "Timber Frame": {"u_value": 0.3, "absorptivity": 0.6, "wall_group": "E"},
211
+ "Cavity Brick": {"u_value": 0.6, "absorptivity": 0.6, "wall_group": "F"},
212
+ "Lightweight Panel": {"u_value": 1.0, "absorptivity": 0.6, "wall_group": "G"},
213
+ "Reinforced Concrete": {"u_value": 1.5, "absorptivity": 0.6, "wall_group": "H"},
214
+ "SIP": {"u_value": 0.25, "absorptivity": 0.6, "wall_group": "A"},
215
+ "Custom": {"u_value": 0.5, "absorptivity": 0.6, "wall_group": "A"}
216
+ },
217
+ "roof_types": {
218
+ "Concrete Roof": {"u_value": 0.3, "absorptivity": 0.6, "group": "A"},
219
+ "Metal Roof": {"u_value": 1.0, "absorptivity": 0.75, "group": "B"}
220
+ },
221
+ "roof_ventilation_methods": {
222
+ "No Ventilation": 0.0,
223
+ "Natural Low": 0.1,
224
+ "Natural High": 0.5,
225
+ "Mechanical": 1.0
226
+ },
227
+ "floor_types": {
228
+ "Concrete Slab": {"u_value": 0.4, "ground_contact": True},
229
+ "Wood Floor": {"u_value": 0.8, "ground_contact": False}
230
+ },
231
+ "window_types": {
232
+ "Double Glazed": {"u_value": 2.8, "shgc": 0.7, "frame_type": "Aluminum"},
233
+ "Single Glazed": {"u_value": 5.0, "shgc": 0.9, "frame_type": "Wood"}
234
+ },
235
+ "shading_devices": {
236
+ "None": 1.0,
237
+ "Venetian Blinds": 0.6,
238
+ "Overhang": 0.4,
239
+ "Roller Shades": 0.5,
240
+ "Drapes": 0.7
241
+ },
242
+ "door_types": {
243
+ "Solid Wood": {"u_value": 2.0},
244
+ "Glass Door": {"u_value": 3.5}
245
+ }
246
+ }
247
+
248
+ def get_materials(self) -> List[Dict[str, Any]]:
249
+ return [{"name": k, "conductivity": v["conductivity"]} for k, v in self.data["materials"].items()]
250
+
251
+ reference_data = ReferenceData()
252
+
253
+ # --- Component Library ---
254
+ class ComponentLibrary:
255
+ def __init__(self):
256
+ self.components = {}
257
+
258
+ def add_component(self, component: BuildingComponent):
259
+ self.components[component.id] = component
260
+
261
+ def remove_component(self, component_id: str):
262
+ if not component_id.startswith("preset_") and component_id in self.components:
263
+ del self.components[component_id]
264
+
265
+ component_library = ComponentLibrary()
266
+
267
+ # --- U-Value Calculator ---
268
+ class UValueCalculator:
269
+ def __init__(self):
270
+ self.materials = reference_data.get_materials()
271
+
272
+ def calculate_u_value(self, layers: List[Dict[str, float]], outside_resistance: float, inside_resistance: float) -> float:
273
+ r_layers = sum(layer["thickness"] / 1000 / layer["conductivity"] for layer in layers)
274
+ r_total = outside_resistance + r_layers + inside_resistance
275
+ return 1 / r_total if r_total > 0 else 0
276
+
277
+ u_value_calculator = UValueCalculator()
278
+
279
+ # --- Component Selection Interface ---
280
+ class ComponentSelectionInterface:
281
+ def __init__(self):
282
+ self.component_library = component_library
283
+ self.u_value_calculator = u_value_calculator
284
+ self.reference_data = reference_data
285
+
286
+ def display_component_selection(self, session_state: Any) -> None:
287
+ st.title("Building Components")
288
+
289
+ if 'components' not in session_state:
290
+ session_state.components = {'walls': [], 'roofs': [], 'floors': [], 'windows': [], 'doors': []}
291
+ if 'roof_air_volume_m3' not in session_state:
292
+ session_state.roof_air_volume_m3 = 0.0
293
+ if 'roof_ventilation_ach' not in session_state:
294
+ session_state.roof_ventilation_ach = 0.0
295
+
296
+ tabs = st.tabs(["Walls", "Roofs", "Floors", "Windows", "Doors", "U-Value Calculator"])
297
+
298
+ with tabs[0]:
299
+ self._display_component_tab(session_state, ComponentType.WALL)
300
+ with tabs[1]:
301
+ self._display_component_tab(session_state, ComponentType.ROOF)
302
+ with tabs[2]:
303
+ self._display_component_tab(session_state, ComponentType.FLOOR)
304
+ with tabs[3]:
305
+ self._display_component_tab(session_state, ComponentType.WINDOW)
306
+ with tabs[4]:
307
+ self._display_component_tab(session_state, ComponentType.DOOR)
308
+ with tabs[5]:
309
+ self._display_u_value_calculator_tab(session_state)
310
+
311
+ if st.button("Save Components"):
312
+ self._save_components(session_state)
313
+
314
+ def _display_component_tab(self, session_state: Any, component_type: ComponentType) -> None:
315
+ type_name = component_type.value.lower()
316
+ st.subheader(f"{type_name.capitalize()} Components")
317
+
318
+ with st.expander(f"Add {type_name.capitalize()}", expanded=True):
319
+ if component_type == ComponentType.WALL:
320
+ self._display_add_wall_form(session_state)
321
+ elif component_type == ComponentType.ROOF:
322
+ self._display_add_roof_form(session_state)
323
+ elif component_type == ComponentType.FLOOR:
324
+ self._display_add_floor_form(session_state)
325
+ elif component_type == ComponentType.WINDOW:
326
+ self._display_add_window_form(session_state)
327
+ elif component_type == ComponentType.DOOR:
328
+ self._display_add_door_form(session_state)
329
+
330
+ components = session_state.components.get(type_name + 's', [])
331
+ if components or component_type == ComponentType.ROOF:
332
+ st.subheader(f"Existing {type_name.capitalize()} Components")
333
+ self._display_components_table(session_state, component_type, components)
334
+
335
+ def _display_add_wall_form(self, session_state: Any) -> None:
336
+ st.write("Add walls manually or upload a file.")
337
+ method = st.radio("Add Wall Method", ["Manual Entry", "File Upload"])
338
+ if "add_wall_submitted" not in session_state:
339
+ session_state.add_wall_submitted = False
340
+
341
+ if method == "Manual Entry":
342
+ with st.form("add_wall_form", clear_on_submit=True):
343
+ col1, col2 = st.columns(2)
344
+ with col1:
345
+ name = st.text_input("Name", "New Wall")
346
+ area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
347
+ orientation = st.selectbox("Orientation", [o.value for o in Orientation if o != Orientation.HORIZONTAL and o != Orientation.NOT_APPLICABLE], index=0)
348
+ with col2:
349
+ wall_options = self.reference_data.data["wall_types"]
350
+ selected_wall = st.selectbox("Wall Type", options=list(wall_options.keys()))
351
+ u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(wall_options[selected_wall]["u_value"]), step=0.01)
352
+ wall_group = st.selectbox("Wall Group (ASHRAE)", ["A", "B", "C", "D", "E", "F", "G", "H"], index=0)
353
+ absorptivity = st.selectbox("Solar Absorptivity", ["Light (0.3)", "Light to Medium (0.45)", "Medium (0.6)", "Medium to Dark (0.75)", "Dark (0.9)"], index=2)
354
+ shading_coefficient = st.number_input("Shading Coefficient", min_value=0.0, max_value=1.0, value=1.0, step=0.05)
355
+ infiltration_rate = st.number_input("Infiltration Rate (CFM)", min_value=0.0, value=0.0, step=0.1)
356
+
357
+ submitted = st.form_submit_button("Add Wall")
358
+ if submitted and not session_state.add_wall_submitted:
359
+ try:
360
+ absorptivity_value = float(absorptivity.split("(")[1].strip(")"))
361
+ new_wall = Wall(
362
+ name=name, u_value=u_value, area=area, orientation=Orientation(orientation),
363
+ wall_type=selected_wall, wall_group=wall_group, absorptivity=absorptivity_value,
364
+ shading_coefficient=shading_coefficient, infiltration_rate_cfm=infiltration_rate
365
+ )
366
+ self.component_library.add_component(new_wall)
367
+ session_state.components['walls'].append(new_wall)
368
+ st.success(f"Added {new_wall.name}")
369
+ session_state.add_wall_submitted = True
370
+ st.rerun()
371
+ except ValueError as e:
372
+ st.error(f"Error: {str(e)}")
373
+
374
+ if session_state.add_wall_submitted:
375
+ session_state.add_wall_submitted = False
376
+
377
+ elif method == "File Upload":
378
+ uploaded_file = st.file_uploader("Upload Walls File", type=["csv", "xlsx"], key="wall_upload")
379
+ required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Wall Type", "Wall Group", "Absorptivity", "Shading Coefficient", "Infiltration Rate (CFM)"]
380
+ template_data = pd.DataFrame(columns=required_cols)
381
+ template_data.loc[0] = ["Example Wall", 10.0, 2.0, "North", "Brick Wall", "A", 0.6, 1.0, 0.0]
382
+ st.download_button(label="Download Wall Template", data=template_data.to_csv(index=False), file_name="wall_template.csv", mime="text/csv")
383
+ if uploaded_file:
384
+ df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
385
+ if all(col in df.columns for col in required_cols):
386
+ valid_wall_groups = {"A", "B", "C", "D", "E", "F", "G", "H"}
387
+ for _, row in df.iterrows():
388
+ try:
389
+ wall_group = str(row["Wall Group"])
390
+ if wall_group not in valid_wall_groups:
391
+ st.warning(f"Invalid Wall Group '{wall_group}' in row '{row['Name']}'. Defaulting to 'A'.")
392
+ wall_group = "A"
393
+ new_wall = Wall(
394
+ name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
395
+ orientation=Orientation(row["Orientation"]), wall_type=str(row["Wall Type"]),
396
+ wall_group=wall_group, absorptivity=float(row["Absorptivity"]),
397
+ shading_coefficient=float(row["Shading Coefficient"]), infiltration_rate_cfm=float(row["Infiltration Rate (CFM)"])
398
+ )
399
+ self.component_library.add_component(new_wall)
400
+ session_state.components['walls'].append(new_wall)
401
+ except ValueError as e:
402
+ st.error(f"Error in row {row['Name']}: {str(e)}")
403
+ st.success("Walls uploaded successfully!")
404
+ st.rerun()
405
+ else:
406
+ st.error(f"File must contain: {', '.join(required_cols)}")
407
+
408
+ def _display_add_roof_form(self, session_state: Any) -> None:
409
+ st.write("Add roofs manually or upload a file.")
410
+ method = st.radio("Add Roof Method", ["Manual Entry", "File Upload"])
411
+ if "add_roof_submitted" not in session_state:
412
+ session_state.add_roof_submitted = False
413
+
414
+ st.subheader("Roof System Ventilation")
415
+ air_volume = st.number_input("Air Volume (m³)", min_value=0.0, value=session_state.roof_air_volume_m3, step=1.0, help="Total volume between roof and ceiling")
416
+ vent_options = {f"{k} (ACH={v})": v for k, v in self.reference_data.data["roof_ventilation_methods"].items()}
417
+ vent_options["Custom"] = None
418
+ ventilation_method = st.selectbox("Ventilation Method", options=list(vent_options.keys()), index=0, help="Applies to entire roof system")
419
+ ventilation_ach = st.number_input("Custom Ventilation Rate (ACH)", min_value=0.0, max_value=10.0, value=0.0, step=0.1) if ventilation_method == "Custom" else vent_options[ventilation_method]
420
+ session_state.roof_air_volume_m3 = air_volume
421
+ session_state.roof_ventilation_ach = ventilation_ach
422
+
423
+ if method == "Manual Entry":
424
+ with st.form("add_roof_form", clear_on_submit=True):
425
+ col1, col2 = st.columns(2)
426
+ with col1:
427
+ name = st.text_input("Name", "New Roof")
428
+ area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
429
+ orientation = Orientation.HORIZONTAL.value
430
+ with col2:
431
+ roof_options = self.reference_data.data["roof_types"]
432
+ selected_roof = st.selectbox("Roof Type", options=list(roof_options.keys()))
433
+ u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(roof_options[selected_roof]["u_value"]), step=0.01)
434
+ roof_group = st.selectbox("Roof Group (ASHRAE)", ["A", "B", "C", "D", "E", "F", "G"], index=0)
435
+ slope = st.selectbox("Slope", ["Flat", "Pitched"], index=0)
436
+ absorptivity = st.selectbox("Solar Absorptivity", ["Light (0.3)", "Light to Medium (0.45)", "Medium (0.6)", "Medium to Dark (0.75)", "Dark (0.9)"], index=2)
437
+
438
+ submitted = st.form_submit_button("Add Roof")
439
+ if submitted and not session_state.add_roof_submitted:
440
+ try:
441
+ absorptivity_value = float(absorptivity.split("(")[1].strip(")"))
442
+ new_roof = Roof(
443
+ name=name, u_value=u_value, area=area, orientation=Orientation(orientation),
444
+ roof_type=selected_roof, roof_group=roof_group, slope=slope, absorptivity=absorptivity_value
445
+ )
446
+ self.component_library.add_component(new_roof)
447
+ session_state.components['roofs'].append(new_roof)
448
+ st.success(f"Added {new_roof.name}")
449
+ session_state.add_roof_submitted = True
450
+ st.rerun()
451
+ except ValueError as e:
452
+ st.error(f"Error: {str(e)}")
453
+
454
+ if session_state.add_roof_submitted:
455
+ session_state.add_roof_submitted = False
456
+
457
+ elif method == "File Upload":
458
+ uploaded_file = st.file_uploader("Upload Roofs File", type=["csv", "xlsx"], key="roof_upload")
459
+ required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Roof Type", "Roof Group", "Slope", "Absorptivity"]
460
+ template_data = pd.DataFrame(columns=required_cols)
461
+ template_data.loc[0] = ["Example Roof", 10.0, 0.3, "Horizontal", "Concrete Roof", "A", "Flat", 0.6]
462
+ st.download_button(label="Download Roof Template", data=template_data.to_csv(index=False), file_name="roof_template.csv", mime="text/csv")
463
+ if uploaded_file:
464
+ df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
465
+ if all(col in df.columns for col in required_cols):
466
+ valid_roof_groups = {"A", "B", "C", "D", "E", "F", "G"}
467
+ for _, row in df.iterrows():
468
+ try:
469
+ roof_group = str(row["Roof Group"])
470
+ if roof_group not in valid_roof_groups:
471
+ st.warning(f"Invalid Roof Group '{roof_group}' in row '{row['Name']}'. Defaulting to 'A'.")
472
+ roof_group = "A"
473
+ new_roof = Roof(
474
+ name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
475
+ orientation=Orientation(row["Orientation"]), roof_type=str(row["Roof Type"]),
476
+ roof_group=roof_group, slope=str(row["Slope"]), absorptivity=float(row["Absorptivity"])
477
+ )
478
+ self.component_library.add_component(new_roof)
479
+ session_state.components['roofs'].append(new_roof)
480
+ except ValueError as e:
481
+ st.error(f"Error in row {row['Name']}: {str(e)}")
482
+ st.success("Roofs uploaded successfully!")
483
+ st.rerun()
484
+ else:
485
+ st.error(f"File must contain: {', '.join(required_cols)}")
486
+
487
+ def _display_add_floor_form(self, session_state: Any) -> None:
488
+ st.write("Add floors manually or upload a file.")
489
+ method = st.radio("Add Floor Method", ["Manual Entry", "File Upload"])
490
+ if "add_floor_submitted" not in session_state:
491
+ session_state.add_floor_submitted = False
492
+
493
+ if method == "Manual Entry":
494
+ with st.form("add_floor_form", clear_on_submit=True):
495
+ col1, col2 = st.columns(2)
496
+ with col1:
497
+ name = st.text_input("Name", "New Floor")
498
+ area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
499
+ perimeter = st.number_input("Perimeter (m)", min_value=0.0, value=0.0, step=0.1)
500
+ with col2:
501
+ floor_options = self.reference_data.data["floor_types"]
502
+ selected_floor = st.selectbox("Floor Type", options=list(floor_options.keys()))
503
+ u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(floor_options[selected_floor]["u_value"]), step=0.01)
504
+ ground_contact = st.selectbox("Ground Contact", ["Yes", "No"], index=0 if floor_options[selected_floor]["ground_contact"] else 1)
505
+ ground_temp = st.number_input("Ground Temperature (°C)", min_value=-10.0, max_value=40.0, value=25.0, step=0.1) if ground_contact == "Yes" else 25.0
506
+ insulated = st.checkbox("Insulated Floor (e.g., R-10)", value=False) # NEW: Insulation option
507
+
508
+ submitted = st.form_submit_button("Add Floor")
509
+ if submitted and not session_state.add_floor_submitted:
510
+ try:
511
+ new_floor = Floor(
512
+ name=name, u_value=u_value, area=area, floor_type=selected_floor,
513
+ ground_contact=(ground_contact == "Yes"), ground_temperature_c=ground_temp,
514
+ perimeter=perimeter, insulated=insulated
515
+ )
516
+ self.component_library.add_component(new_floor)
517
+ session_state.components['floors'].append(new_floor)
518
+ st.success(f"Added {new_floor.name}")
519
+ session_state.add_floor_submitted = True
520
+ st.rerun()
521
+ except ValueError as e:
522
+ st.error(f"Error: {str(e)}")
523
+
524
+ if session_state.add_floor_submitted:
525
+ session_state.add_floor_submitted = False
526
+
527
+ elif method == "File Upload":
528
+ uploaded_file = st.file_uploader("Upload Floors File", type=["csv", "xlsx"], key="floor_upload")
529
+ required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Floor Type", "Ground Contact", "Ground Temperature (°C)", "Perimeter (m)", "Insulated"] # NEW: Added Insulated
530
+ template_data = pd.DataFrame(columns=required_cols)
531
+ template_data.loc[0] = ["Example Floor", 10.0, 0.4, "Concrete Slab", "Yes", 25.0, 12.0, "No"]
532
+ st.download_button(label="Download Floor Template", data=template_data.to_csv(index=False), file_name="floor_template.csv", mime="text/csv")
533
+ if uploaded_file:
534
+ df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
535
+ if all(col in df.columns for col in required_cols):
536
+ for _, row in df.iterrows():
537
+ try:
538
+ insulated = str(row["Insulated"]).lower() in ["yes", "true", "1"]
539
+ new_floor = Floor(
540
+ name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
541
+ floor_type=str(row["Floor Type"]), ground_contact=(str(row["Ground Contact"]).lower() == "yes"),
542
+ ground_temperature_c=float(row["Ground Temperature (°C)"]), perimeter=float(row["Perimeter (m)"]),
543
+ insulated=insulated
544
+ )
545
+ self.component_library.add_component(new_floor)
546
+ session_state.components['floors'].append(new_floor)
547
+ except ValueError as e:
548
+ st.error(f"Error in row {row['Name']}: {str(e)}")
549
+ st.success("Floors uploaded successfully!")
550
+ st.rerun()
551
+ else:
552
+ st.error(f"File must contain: {', '.join(required_cols)}")
553
+
554
+ def _display_add_window_form(self, session_state: Any) -> None:
555
+ st.write("Add windows manually or upload a file.")
556
+ method = st.radio("Add Window Method", ["Manual Entry", "File Upload"])
557
+ if "add_window_submitted" not in session_state:
558
+ session_state.add_window_submitted = False
559
+
560
+ if method == "Manual Entry":
561
+ with st.form("add_window_form", clear_on_submit=True):
562
+ col1, col2 = st.columns(2)
563
+ with col1:
564
+ name = st.text_input("Name", "New Window")
565
+ area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
566
+ orientation = st.selectbox("Orientation", [o.value for o in Orientation if o != Orientation.HORIZONTAL and o != Orientation.NOT_APPLICABLE], index=0)
567
+ shgc = st.number_input("SHGC", min_value=0.0, max_value=1.0, value=0.7, step=0.01)
568
+ with col2:
569
+ window_options = self.reference_data.data["window_types"]
570
+ selected_window = st.selectbox("Window Type", options=list(window_options.keys()))
571
+ u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(window_options[selected_window]["u_value"]), step=0.01)
572
+ shading_options = {f"{k} (SC={v})": k for k, v in self.reference_data.data["shading_devices"].items()}
573
+ shading_options["Custom"] = "Custom"
574
+ shading_device = st.selectbox("Shading Device", options=list(shading_options.keys()), index=0)
575
+ shading_coefficient = st.number_input("Custom Shading Coefficient", min_value=0.0, max_value=1.0, value=1.0, step=0.05) if shading_device == "Custom" else self.reference_data.data["shading_devices"][shading_options[shading_device]]
576
+ frame_type = st.selectbox("Frame Type", ["Aluminum", "Wood", "Vinyl"], index=0)
577
+ frame_percentage = st.slider("Frame Percentage (%)", min_value=0.0, max_value=30.0, value=20.0)
578
+ infiltration_rate = st.number_input("Infiltration Rate (CFM)", min_value=0.0, value=0.0, step=0.1)
579
+
580
+ submitted = st.form_submit_button("Add Window")
581
+ if submitted and not session_state.add_window_submitted:
582
+ try:
583
+ new_window = Window(
584
+ name=name, u_value=u_value, area=area, orientation=Orientation(orientation),
585
+ shgc=shgc, shading_device=shading_options[shading_device], shading_coefficient=shading_coefficient,
586
+ frame_type=frame_type, frame_percentage=frame_percentage, infiltration_rate_cfm=infiltration_rate
587
+ )
588
+ self.component_library.add_component(new_window)
589
+ session_state.components['windows'].append(new_window)
590
+ st.success(f"Added {new_window.name}")
591
+ session_state.add_window_submitted = True
592
+ st.rerun()
593
+ except ValueError as e:
594
+ st.error(f"Error: {str(e)}")
595
+
596
+ if session_state.add_window_submitted:
597
+ session_state.add_window_submitted = False
598
+
599
+ elif method == "File Upload":
600
+ uploaded_file = st.file_uploader("Upload Windows File", type=["csv", "xlsx"], key="window_upload")
601
+ required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "SHGC", "Shading Device", "Shading Coefficient", "Frame Type", "Frame Percentage", "Infiltration Rate (CFM)"]
602
+ st.download_button(label="Download Window Template", data=pd.DataFrame(columns=required_cols).to_csv(index=False), file_name="window_template.csv", mime="text/csv")
603
+ if uploaded_file:
604
+ df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
605
+ if all(col in df.columns for col in required_cols):
606
+ for _, row in df.iterrows():
607
+ try:
608
+ new_window = Window(
609
+ name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
610
+ orientation=Orientation(row["Orientation"]), shgc=float(row["SHGC"]),
611
+ shading_device=str(row["Shading Device"]), shading_coefficient=float(row["Shading Coefficient"]),
612
+ frame_type=str(row["Frame Type"]), frame_percentage=float(row["Frame Percentage"]),
613
+ infiltration_rate_cfm=float(row["Infiltration Rate (CFM)"])
614
+ )
615
+ self.component_library.add_component(new_window)
616
+ session_state.components['windows'].append(new_window)
617
+ except ValueError as e:
618
+ st.error(f"Error in row {row['Name']}: {str(e)}")
619
+ st.success("Windows uploaded successfully!")
620
+ st.rerun()
621
+ else:
622
+ st.error(f"File must contain: {', '.join(required_cols)}")
623
+
624
+ def _display_add_door_form(self, session_state: Any) -> None:
625
+ st.write("Add doors manually or upload a file.")
626
+ method = st.radio("Add Door Method", ["Manual Entry", "File Upload"])
627
+ if "add_door_submitted" not in session_state:
628
+ session_state.add_door_submitted = False
629
+
630
+ if method == "Manual Entry":
631
+ with st.form("add_door_form", clear_on_submit=True):
632
+ col1, col2 = st.columns(2)
633
+ with col1:
634
+ name = st.text_input("Name", "New Door")
635
+ area = st.number_input("Area (m²)", min_value=0.0, value=1.0, step=0.1)
636
+ orientation = st.selectbox("Orientation", [o.value for o in Orientation if o != Orientation.HORIZONTAL and o != Orientation.NOT_APPLICABLE], index=0)
637
+ with col2:
638
+ door_options = self.reference_data.data["door_types"]
639
+ selected_door = st.selectbox("Door Type", options=list(door_options.keys()))
640
+ u_value = st.number_input("U-Value (W/m²·K)", min_value=0.0, value=float(door_options[selected_door]["u_value"]), step=0.01)
641
+ infiltration_rate = st.number_input("Infiltration Rate (CFM)", min_value=0.0, value=0.0, step=0.1)
642
+
643
+ submitted = st.form_submit_button("Add Door")
644
+ if submitted and not session_state.add_door_submitted:
645
+ try:
646
+ new_door = Door(
647
+ name=name, u_value=u_value, area=area, orientation=Orientation(orientation),
648
+ door_type=selected_door, infiltration_rate_cfm=infiltration_rate
649
+ )
650
+ self.component_library.add_component(new_door)
651
+ session_state.components['doors'].append(new_door)
652
+ st.success(f"Added {new_door.name}")
653
+ session_state.add_door_submitted = True
654
+ st.rerun()
655
+ except ValueError as e:
656
+ st.error(f"Error: {str(e)}")
657
+
658
+ if session_state.add_door_submitted:
659
+ session_state.add_door_submitted = False
660
+
661
+ elif method == "File Upload":
662
+ uploaded_file = st.file_uploader("Upload Doors File", type=["csv", "xlsx"], key="door_upload")
663
+ required_cols = ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Door Type", "Infiltration Rate (CFM)"]
664
+ st.download_button(label="Download Door Template", data=pd.DataFrame(columns=required_cols).to_csv(index=False), file_name="door_template.csv", mime="text/csv")
665
+ if uploaded_file:
666
+ df = pd.read_csv(uploaded_file) if uploaded_file.name.endswith('.csv') else pd.read_excel(uploaded_file)
667
+ if all(col in df.columns for col in required_cols):
668
+ for _, row in df.iterrows():
669
+ try:
670
+ new_door = Door(
671
+ name=str(row["Name"]), u_value=float(row["U-Value (W/m²·K)"]), area=float(row["Area (m²)"]),
672
+ orientation=Orientation(row["Orientation"]), door_type=str(row["Door Type"]),
673
+ infiltration_rate_cfm=float(row["Infiltration Rate (CFM)"])
674
+ )
675
+ self.component_library.add_component(new_door)
676
+ session_state.components['doors'].append(new_door)
677
+ except ValueError as e:
678
+ st.error(f"Error in row {row['Name']}: {str(e)}")
679
+ st.success("Doors uploaded successfully!")
680
+ st.rerun()
681
+ else:
682
+ st.error(f"File must contain: {', '.join(required_cols)}")
683
+
684
+ def _display_components_table(self, session_state: Any, component_type: ComponentType, components: List[BuildingComponent]) -> None:
685
+ type_name = component_type.value.lower()
686
+ if component_type == ComponentType.ROOF:
687
+ st.write(f"Roof Air Volume: {session_state.roof_air_volume_m3} m³, Ventilation Rate: {session_state.roof_ventilation_ach} ACH")
688
+
689
+ if components:
690
+ headers = {
691
+ ComponentType.WALL: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Wall Type", "Wall Group", "Absorptivity", "Shading Coefficient", "Infiltration Rate (CFM)", "Delete"],
692
+ ComponentType.ROOF: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Roof Type", "Roof Group", "Slope", "Absorptivity", "Delete"],
693
+ ComponentType.FLOOR: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Floor Type", "Ground Contact", "Ground Temperature (°C)", "Perimeter (m)", "Insulated", "Delete"], # NEW: Added Insulated
694
+ ComponentType.WINDOW: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "SHGC", "Shading Device", "Shading Coefficient", "Frame Type", "Frame Percentage", "Infiltration Rate (CFM)", "Delete"],
695
+ ComponentType.DOOR: ["Name", "Area (m²)", "U-Value (W/m²·K)", "Orientation", "Door Type", "Infiltration Rate (CFM)", "Delete"]
696
+ }[component_type]
697
+ cols = st.columns([1] * len(headers))
698
+ for i, header in enumerate(headers):
699
+ cols[i].write(f"**{header}**")
700
+
701
+ for comp in components:
702
+ cols = st.columns([1] * len(headers))
703
+ cols[0].write(comp.name)
704
+ cols[1].write(comp.area)
705
+ cols[2].write(comp.u_value)
706
+ cols[3].write(comp.orientation.value)
707
+ if component_type == ComponentType.WALL:
708
+ cols[4].write(comp.wall_type)
709
+ cols[5].write(comp.wall_group)
710
+ cols[6].write(comp.absorptivity)
711
+ cols[7].write(comp.shading_coefficient)
712
+ cols[8].write(comp.infiltration_rate_cfm)
713
+ elif component_type == ComponentType.ROOF:
714
+ cols[4].write(comp.roof_type)
715
+ cols[5].write(comp.roof_group)
716
+ cols[6].write(comp.slope)
717
+ cols[7].write(comp.absorptivity)
718
+ elif component_type == ComponentType.FLOOR:
719
+ cols[4].write(comp.floor_type)
720
+ cols[5].write("Yes" if comp.ground_contact else "No")
721
+ cols[6].write(comp.ground_temperature_c if comp.ground_contact else "N/A")
722
+ cols[7].write(comp.perimeter)
723
+ cols[8].write("Yes" if comp.insulated else "No") # NEW: Display insulated
724
+ elif component_type == ComponentType.WINDOW:
725
+ cols[4].write(comp.shgc)
726
+ cols[5].write(comp.shading_device)
727
+ cols[6].write(comp.shading_coefficient)
728
+ cols[7].write(comp.frame_type)
729
+ cols[8].write(comp.frame_percentage)
730
+ cols[9].write(comp.infiltration_rate_cfm)
731
+ elif component_type == ComponentType.DOOR:
732
+ cols[4].write(comp.door_type)
733
+ cols[5].write(comp.infiltration_rate_cfm)
734
+ if cols[-1].button("Delete", key=f"delete_{comp.id}"):
735
+ self.component_library.remove_component(comp.id)
736
+ session_state.components[type_name + 's'] = [c for c in components if c.id != comp.id]
737
+ st.success(f"Deleted {comp.name}")
738
+ st.rerun()
739
+
740
+ def _display_u_value_calculator_tab(self, session_state: Any) -> None:
741
+ st.subheader("U-Value Calculator (Standalone)")
742
+ if "u_value_layers" not in session_state:
743
+ session_state.u_value_layers = []
744
+
745
+ if session_state.u_value_layers:
746
+ st.write("Material Layers (Outside to Inside):")
747
+ layer_data = [{"Layer": i+1, "Material": l["name"], "Thickness (mm)": l["thickness"],
748
+ "Conductivity (W/m·K)": l["conductivity"], "R-Value (m²·K/W)": l["thickness"] / 1000 / l["conductivity"]}
749
+ for i, l in enumerate(session_state.u_value_layers)]
750
+ st.dataframe(pd.DataFrame(layer_data))
751
+ outside_resistance = st.selectbox("Outside Resistance (m²·K/W)", ["Summer (0.04)", "Winter (0.03)", "Custom"], index=0)
752
+ outside_r = float(st.number_input("Custom Outside Resistance", min_value=0.0, value=0.04, step=0.01)) if outside_resistance == "Custom" else (0.04 if outside_resistance.startswith("Summer") else 0.03)
753
+ inside_r = st.number_input("Inside Resistance (m²·K/W)", min_value=0.0, value=0.13, step=0.01)
754
+ u_value = self.u_value_calculator.calculate_u_value(session_state.u_value_layers, outside_r, inside_r)
755
+ st.metric("U-Value", f"{u_value:.3f} W/m²·K")
756
+
757
+ with st.form("u_value_form"):
758
+ col1, col2 = st.columns(2)
759
+ with col1:
760
+ material_options = {m["name"]: m["conductivity"] for m in self.u_value_calculator.materials}
761
+ material_name = st.selectbox("Material", options=list(material_options.keys()))
762
+ conductivity = st.number_input("Conductivity (W/m·K)", min_value=0.0, value=material_options[material_name], step=0.01)
763
+ with col2:
764
+ thickness = st.number_input("Thickness (mm)", min_value=0.0, value=100.0, step=1.0)
765
+ submitted = st.form_submit_button("Add Layer")
766
+ if submitted:
767
+ session_state.u_value_layers.append({"name": material_name, "thickness": thickness, "conductivity": conductivity})
768
+ st.rerun()
769
+
770
+ col1, col2 = st.columns(2)
771
+ with col1:
772
+ if st.button("Remove Last Layer"):
773
+ if session_state.u_value_layers:
774
+ session_state.u_value_layers.pop()
775
+ st.rerun()
776
+ with col2:
777
+ if st.button("Reset"):
778
+ session_state.u_value_layers = []
779
+ st.rerun()
780
+
781
+ def _save_components(self, session_state: Any) -> None:
782
+ components_dict = {
783
+ "walls": [c.to_dict() for c in session_state.components["walls"]],
784
+ "roofs": [c.to_dict() for c in session_state.components["roofs"]],
785
+ "floors": [c.to_dict() for c in session_state.components["floors"]],
786
+ "windows": [c.to_dict() for c in session_state.components["windows"]],
787
+ "doors": [c.to_dict() for c in session_state.components["doors"]],
788
+ "roof_air_volume_m3": session_state.roof_air_volume_m3,
789
+ "roof_ventilation_ach": session_state.roof_ventilation_ach
790
+ }
791
+ file_path = "components_export.json"
792
+ with open(file_path, 'w') as f:
793
+ json.dump(components_dict, f, indent=4)
794
+ with open(file_path, 'r') as f:
795
+ st.download_button(label="Download Components", data=f, file_name="components.json", mime="application/json")
796
+ st.success("Components saved successfully.")
797
+
798
+ # --- Main Execution ---
799
+ if __name__ == "__main__":
800
+ interface = ComponentSelectionInterface()
801
+ interface.display_component_selection(st.session_state)
app/data_export.py ADDED
@@ -0,0 +1,870 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data export module for HVAC Load Calculator.
3
+ This module provides functionality for exporting calculation results.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ import json
11
+ import os
12
+ import base64
13
+ import io
14
+ from datetime import datetime
15
+ import xlsxwriter
16
+
17
+
18
+ class DataExport:
19
+ """Class for data export functionality."""
20
+
21
+ @staticmethod
22
+ def export_to_csv(data: Dict[str, Any], file_path: str = None) -> Optional[str]:
23
+ """
24
+ Export data to CSV format.
25
+
26
+ Args:
27
+ data: Dictionary with data to export
28
+ file_path: Optional path to save the CSV file
29
+
30
+ Returns:
31
+ CSV string if file_path is None, otherwise None
32
+ """
33
+ try:
34
+ # Create DataFrame from data
35
+ df = pd.DataFrame(data)
36
+
37
+ # Convert to CSV
38
+ csv_data = df.to_csv(index=False)
39
+
40
+ # Save to file if path provided
41
+ if file_path:
42
+ df.to_csv(file_path, index=False)
43
+ return None
44
+
45
+ # Return CSV string if no path provided
46
+ return csv_data
47
+
48
+ except Exception as e:
49
+ st.error(f"Error exporting to CSV: {e}")
50
+ return None
51
+
52
+ @staticmethod
53
+ def export_to_excel(data_dict: Dict[str, pd.DataFrame], file_path: str = None) -> Optional[bytes]:
54
+ """
55
+ Export data to Excel format.
56
+
57
+ Args:
58
+ data_dict: Dictionary with sheet names and DataFrames
59
+ file_path: Optional path to save the Excel file
60
+
61
+ Returns:
62
+ Excel bytes if file_path is None, otherwise None
63
+ """
64
+ try:
65
+ # Create Excel file in memory or on disk
66
+ if file_path:
67
+ writer = pd.ExcelWriter(file_path, engine='xlsxwriter')
68
+ else:
69
+ output = io.BytesIO()
70
+ writer = pd.ExcelWriter(output, engine='xlsxwriter')
71
+
72
+ # Write each DataFrame to a different sheet
73
+ for sheet_name, df in data_dict.items():
74
+ df.to_excel(writer, sheet_name=sheet_name, index=False)
75
+
76
+ # Auto-adjust column widths
77
+ worksheet = writer.sheets[sheet_name]
78
+ for i, col in enumerate(df.columns):
79
+ max_width = max(
80
+ df[col].astype(str).map(len).max(),
81
+ len(col)
82
+ ) + 2
83
+ worksheet.set_column(i, i, max_width)
84
+
85
+ # Save the Excel file
86
+ writer.close()
87
+
88
+ # Return Excel bytes if no path provided
89
+ if not file_path:
90
+ output.seek(0)
91
+ return output.getvalue()
92
+
93
+ return None
94
+
95
+ except Exception as e:
96
+ st.error(f"Error exporting to Excel: {e}")
97
+ return None
98
+
99
+ @staticmethod
100
+ def export_scenario_to_json(scenario: Dict[str, Any], file_path: str = None) -> Optional[str]:
101
+ """
102
+ Export scenario data to JSON format.
103
+
104
+ Args:
105
+ scenario: Dictionary with scenario data
106
+ file_path: Optional path to save the JSON file
107
+
108
+ Returns:
109
+ JSON string if file_path is None, otherwise None
110
+ """
111
+ try:
112
+ # Convert to JSON
113
+ json_data = json.dumps(scenario, indent=4)
114
+
115
+ # Save to file if path provided
116
+ if file_path:
117
+ with open(file_path, "w") as f:
118
+ f.write(json_data)
119
+ return None
120
+
121
+ # Return JSON string if no path provided
122
+ return json_data
123
+
124
+ except Exception as e:
125
+ st.error(f"Error exporting scenario to JSON: {e}")
126
+ return None
127
+
128
+ @staticmethod
129
+ def get_download_link(data: Any, filename: str, text: str, mime_type: str = "text/csv") -> str:
130
+ """
131
+ Generate a download link for data.
132
+
133
+ Args:
134
+ data: Data to download
135
+ filename: Name of the file to download
136
+ text: Text to display for the download link
137
+ mime_type: MIME type of the file
138
+
139
+ Returns:
140
+ HTML string with download link
141
+ """
142
+ if isinstance(data, str):
143
+ b64 = base64.b64encode(data.encode()).decode()
144
+ else:
145
+ b64 = base64.b64encode(data).decode()
146
+
147
+ href = f'<a href="data:{mime_type};base64,{b64}" download="{filename}">{text}</a>'
148
+ return href
149
+
150
+ @staticmethod
151
+ def create_cooling_load_dataframes(results: Dict[str, Any]) -> Dict[str, pd.DataFrame]:
152
+ """
153
+ Create DataFrames for cooling load results.
154
+
155
+ Args:
156
+ results: Dictionary with calculation results
157
+
158
+ Returns:
159
+ Dictionary with DataFrames for Excel export
160
+ """
161
+ dataframes = {}
162
+
163
+ # Create summary DataFrame
164
+ summary_data = {
165
+ "Metric": [
166
+ "Total Cooling Load",
167
+ "Sensible Cooling Load",
168
+ "Latent Cooling Load",
169
+ "Cooling Load per Area"
170
+ ],
171
+ "Value": [
172
+ results["cooling"]["total_load"],
173
+ results["cooling"]["sensible_load"],
174
+ results["cooling"]["latent_load"],
175
+ results["cooling"]["load_per_area"]
176
+ ],
177
+ "Unit": [
178
+ "kW",
179
+ "kW",
180
+ "kW",
181
+ "W/m²"
182
+ ]
183
+ }
184
+
185
+ dataframes["Cooling Summary"] = pd.DataFrame(summary_data)
186
+
187
+ # Create component breakdown DataFrame
188
+ component_data = {
189
+ "Component": [
190
+ "Walls",
191
+ "Roof",
192
+ "Windows",
193
+ "Doors",
194
+ "People",
195
+ "Lighting",
196
+ "Equipment",
197
+ "Infiltration",
198
+ "Ventilation"
199
+ ],
200
+ "Load (kW)": [
201
+ results["cooling"]["component_loads"]["walls"],
202
+ results["cooling"]["component_loads"]["roof"],
203
+ results["cooling"]["component_loads"]["windows"],
204
+ results["cooling"]["component_loads"]["doors"],
205
+ results["cooling"]["component_loads"]["people"],
206
+ results["cooling"]["component_loads"]["lighting"],
207
+ results["cooling"]["component_loads"]["equipment"],
208
+ results["cooling"]["component_loads"]["infiltration"],
209
+ results["cooling"]["component_loads"]["ventilation"]
210
+ ],
211
+ "Percentage (%)": [
212
+ results["cooling"]["component_loads"]["walls"] / results["cooling"]["total_load"] * 100,
213
+ results["cooling"]["component_loads"]["roof"] / results["cooling"]["total_load"] * 100,
214
+ results["cooling"]["component_loads"]["windows"] / results["cooling"]["total_load"] * 100,
215
+ results["cooling"]["component_loads"]["doors"] / results["cooling"]["total_load"] * 100,
216
+ results["cooling"]["component_loads"]["people"] / results["cooling"]["total_load"] * 100,
217
+ results["cooling"]["component_loads"]["lighting"] / results["cooling"]["total_load"] * 100,
218
+ results["cooling"]["component_loads"]["equipment"] / results["cooling"]["total_load"] * 100,
219
+ results["cooling"]["component_loads"]["infiltration"] / results["cooling"]["total_load"] * 100,
220
+ results["cooling"]["component_loads"]["ventilation"] / results["cooling"]["total_load"] * 100
221
+ ]
222
+ }
223
+
224
+ dataframes["Cooling Components"] = pd.DataFrame(component_data)
225
+
226
+ # Create detailed loads DataFrames
227
+
228
+ # Walls
229
+ wall_data = []
230
+ for wall in results["cooling"]["detailed_loads"]["walls"]:
231
+ wall_data.append({
232
+ "Name": wall["name"],
233
+ "Orientation": wall["orientation"],
234
+ "Area (m²)": wall["area"],
235
+ "U-Value (W/m²·K)": wall["u_value"],
236
+ "CLTD (°C)": wall["cltd"],
237
+ "Load (kW)": wall["load"]
238
+ })
239
+
240
+ if wall_data:
241
+ dataframes["Cooling Walls"] = pd.DataFrame(wall_data)
242
+
243
+ # Roofs
244
+ roof_data = []
245
+ for roof in results["cooling"]["detailed_loads"]["roofs"]:
246
+ roof_data.append({
247
+ "Name": roof["name"],
248
+ "Orientation": roof["orientation"],
249
+ "Area (m²)": roof["area"],
250
+ "U-Value (W/m²·K)": roof["u_value"],
251
+ "CLTD (°C)": roof["cltd"],
252
+ "Load (kW)": roof["load"]
253
+ })
254
+
255
+ if roof_data:
256
+ dataframes["Cooling Roofs"] = pd.DataFrame(roof_data)
257
+
258
+ # Windows
259
+ window_data = []
260
+ for window in results["cooling"]["detailed_loads"]["windows"]:
261
+ window_data.append({
262
+ "Name": window["name"],
263
+ "Orientation": window["orientation"],
264
+ "Area (m²)": window["area"],
265
+ "U-Value (W/m²·K)": window["u_value"],
266
+ "SHGC": window["shgc"],
267
+ "SCL (W/m²)": window["scl"],
268
+ "Load (kW)": window["load"]
269
+ })
270
+
271
+ if window_data:
272
+ dataframes["Cooling Windows"] = pd.DataFrame(window_data)
273
+
274
+ # Doors
275
+ door_data = []
276
+ for door in results["cooling"]["detailed_loads"]["doors"]:
277
+ door_data.append({
278
+ "Name": door["name"],
279
+ "Orientation": door["orientation"],
280
+ "Area (m²)": door["area"],
281
+ "U-Value (W/m²·K)": door["u_value"],
282
+ "CLTD (°C)": door["cltd"],
283
+ "Load (kW)": door["load"]
284
+ })
285
+
286
+ if door_data:
287
+ dataframes["Cooling Doors"] = pd.DataFrame(door_data)
288
+
289
+ # Internal loads
290
+ internal_data = []
291
+ for internal_load in results["cooling"]["detailed_loads"]["internal"]:
292
+ internal_data.append({
293
+ "Type": internal_load["type"],
294
+ "Name": internal_load["name"],
295
+ "Quantity": internal_load["quantity"],
296
+ "Heat Gain (W)": internal_load["heat_gain"],
297
+ "CLF": internal_load["clf"],
298
+ "Load (kW)": internal_load["load"]
299
+ })
300
+
301
+ if internal_data:
302
+ dataframes["Cooling Internal Loads"] = pd.DataFrame(internal_data)
303
+
304
+ # Infiltration and ventilation
305
+ air_data = [
306
+ {
307
+ "Type": "Infiltration",
308
+ "Air Flow (m³/s)": results["cooling"]["detailed_loads"]["infiltration"]["air_flow"],
309
+ "Sensible Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["sensible_load"],
310
+ "Latent Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["latent_load"],
311
+ "Total Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["total_load"]
312
+ },
313
+ {
314
+ "Type": "Ventilation",
315
+ "Air Flow (m³/s)": results["cooling"]["detailed_loads"]["ventilation"]["air_flow"],
316
+ "Sensible Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["sensible_load"],
317
+ "Latent Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["latent_load"],
318
+ "Total Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["total_load"]
319
+ }
320
+ ]
321
+
322
+ dataframes["Cooling Air Exchange"] = pd.DataFrame(air_data)
323
+
324
+ return dataframes
325
+
326
+ @staticmethod
327
+ def create_heating_load_dataframes(results: Dict[str, Any]) -> Dict[str, pd.DataFrame]:
328
+ """
329
+ Create DataFrames for heating load results.
330
+
331
+ Args:
332
+ results: Dictionary with calculation results
333
+
334
+ Returns:
335
+ Dictionary with DataFrames for Excel export
336
+ """
337
+ dataframes = {}
338
+
339
+ # Create summary DataFrame
340
+ summary_data = {
341
+ "Metric": [
342
+ "Total Heating Load",
343
+ "Heating Load per Area",
344
+ "Design Heat Loss",
345
+ "Safety Factor"
346
+ ],
347
+ "Value": [
348
+ results["heating"]["total_load"],
349
+ results["heating"]["load_per_area"],
350
+ results["heating"]["design_heat_loss"],
351
+ results["heating"]["safety_factor"]
352
+ ],
353
+ "Unit": [
354
+ "kW",
355
+ "W/m²",
356
+ "kW",
357
+ "%"
358
+ ]
359
+ }
360
+
361
+ dataframes["Heating Summary"] = pd.DataFrame(summary_data)
362
+
363
+ # Create component breakdown DataFrame
364
+ component_data = {
365
+ "Component": [
366
+ "Walls",
367
+ "Roof",
368
+ "Floor",
369
+ "Windows",
370
+ "Doors",
371
+ "Infiltration",
372
+ "Ventilation"
373
+ ],
374
+ "Load (kW)": [
375
+ results["heating"]["component_loads"]["walls"],
376
+ results["heating"]["component_loads"]["roof"],
377
+ results["heating"]["component_loads"]["floor"],
378
+ results["heating"]["component_loads"]["windows"],
379
+ results["heating"]["component_loads"]["doors"],
380
+ results["heating"]["component_loads"]["infiltration"],
381
+ results["heating"]["component_loads"]["ventilation"]
382
+ ],
383
+ "Percentage (%)": [
384
+ results["heating"]["component_loads"]["walls"] / results["heating"]["total_load"] * 100,
385
+ results["heating"]["component_loads"]["roof"] / results["heating"]["total_load"] * 100,
386
+ results["heating"]["component_loads"]["floor"] / results["heating"]["total_load"] * 100,
387
+ results["heating"]["component_loads"]["windows"] / results["heating"]["total_load"] * 100,
388
+ results["heating"]["component_loads"]["doors"] / results["heating"]["total_load"] * 100,
389
+ results["heating"]["component_loads"]["infiltration"] / results["heating"]["total_load"] * 100,
390
+ results["heating"]["component_loads"]["ventilation"] / results["heating"]["total_load"] * 100
391
+ ]
392
+ }
393
+
394
+ dataframes["Heating Components"] = pd.DataFrame(component_data)
395
+
396
+ # Create detailed loads DataFrames
397
+
398
+ # Walls
399
+ wall_data = []
400
+ for wall in results["heating"]["detailed_loads"]["walls"]:
401
+ wall_data.append({
402
+ "Name": wall["name"],
403
+ "Orientation": wall["orientation"],
404
+ "Area (m²)": wall["area"],
405
+ "U-Value (W/m²·K)": wall["u_value"],
406
+ "Temperature Difference (°C)": wall["delta_t"],
407
+ "Load (kW)": wall["load"]
408
+ })
409
+
410
+ if wall_data:
411
+ dataframes["Heating Walls"] = pd.DataFrame(wall_data)
412
+
413
+ # Roofs
414
+ roof_data = []
415
+ for roof in results["heating"]["detailed_loads"]["roofs"]:
416
+ roof_data.append({
417
+ "Name": roof["name"],
418
+ "Orientation": roof["orientation"],
419
+ "Area (m²)": roof["area"],
420
+ "U-Value (W/m²·K)": roof["u_value"],
421
+ "Temperature Difference (°C)": roof["delta_t"],
422
+ "Load (kW)": roof["load"]
423
+ })
424
+
425
+ if roof_data:
426
+ dataframes["Heating Roofs"] = pd.DataFrame(roof_data)
427
+
428
+ # Floors
429
+ floor_data = []
430
+ for floor in results["heating"]["detailed_loads"]["floors"]:
431
+ floor_data.append({
432
+ "Name": floor["name"],
433
+ "Area (m²)": floor["area"],
434
+ "U-Value (W/m²·K)": floor["u_value"],
435
+ "Temperature Difference (°C)": floor["delta_t"],
436
+ "Load (kW)": floor["load"]
437
+ })
438
+
439
+ if floor_data:
440
+ dataframes["Heating Floors"] = pd.DataFrame(floor_data)
441
+
442
+ # Windows
443
+ window_data = []
444
+ for window in results["heating"]["detailed_loads"]["windows"]:
445
+ window_data.append({
446
+ "Name": window["name"],
447
+ "Orientation": window["orientation"],
448
+ "Area (m²)": window["area"],
449
+ "U-Value (W/m²·K)": window["u_value"],
450
+ "Temperature Difference (°C)": window["delta_t"],
451
+ "Load (kW)": window["load"]
452
+ })
453
+
454
+ if window_data:
455
+ dataframes["Heating Windows"] = pd.DataFrame(window_data)
456
+
457
+ # Doors
458
+ door_data = []
459
+ for door in results["heating"]["detailed_loads"]["doors"]:
460
+ door_data.append({
461
+ "Name": door["name"],
462
+ "Orientation": door["orientation"],
463
+ "Area (m²)": door["area"],
464
+ "U-Value (W/m²·K)": door["u_value"],
465
+ "Temperature Difference (°C)": door["delta_t"],
466
+ "Load (kW)": door["load"]
467
+ })
468
+
469
+ if door_data:
470
+ dataframes["Heating Doors"] = pd.DataFrame(door_data)
471
+
472
+ # Infiltration and ventilation
473
+ air_data = [
474
+ {
475
+ "Type": "Infiltration",
476
+ "Air Flow (m³/s)": results["heating"]["detailed_loads"]["infiltration"]["air_flow"],
477
+ "Temperature Difference (°C)": results["heating"]["detailed_loads"]["infiltration"]["delta_t"],
478
+ "Load (kW)": results["heating"]["detailed_loads"]["infiltration"]["load"]
479
+ },
480
+ {
481
+ "Type": "Ventilation",
482
+ "Air Flow (m³/s)": results["heating"]["detailed_loads"]["ventilation"]["air_flow"],
483
+ "Temperature Difference (°C)": results["heating"]["detailed_loads"]["ventilation"]["delta_t"],
484
+ "Load (kW)": results["heating"]["detailed_loads"]["ventilation"]["load"]
485
+ }
486
+ ]
487
+
488
+ dataframes["Heating Air Exchange"] = pd.DataFrame(air_data)
489
+
490
+ return dataframes
491
+
492
+ @staticmethod
493
+ def display_export_interface(session_state: Dict[str, Any]) -> None:
494
+ """
495
+ Display export interface in Streamlit.
496
+
497
+ Args:
498
+ session_state: Streamlit session state containing calculation results
499
+ """
500
+ st.header("Export Results")
501
+
502
+ # Check if calculations have been performed
503
+ if "calculation_results" not in session_state or not session_state["calculation_results"]:
504
+ st.warning("No calculation results available. Please run calculations first.")
505
+ return
506
+
507
+ # Create tabs for different export options
508
+ tab1, tab2, tab3 = st.tabs(["CSV Export", "Excel Export", "Scenario Export"])
509
+
510
+ with tab1:
511
+ DataExport._display_csv_export(session_state)
512
+
513
+ with tab2:
514
+ DataExport._display_excel_export(session_state)
515
+
516
+ with tab3:
517
+ DataExport._display_scenario_export(session_state)
518
+
519
+ @staticmethod
520
+ def _display_csv_export(session_state: Dict[str, Any]) -> None:
521
+ """
522
+ Display CSV export interface.
523
+
524
+ Args:
525
+ session_state: Streamlit session state containing calculation results
526
+ """
527
+ st.subheader("CSV Export")
528
+
529
+ # Get results
530
+ results = session_state["calculation_results"]
531
+
532
+ # Create tabs for cooling and heating loads
533
+ tab1, tab2 = st.tabs(["Cooling Load CSV", "Heating Load CSV"])
534
+
535
+ with tab1:
536
+ # Create cooling load DataFrames
537
+ cooling_dfs = DataExport.create_cooling_load_dataframes(results)
538
+
539
+ # Display and export each DataFrame
540
+ for sheet_name, df in cooling_dfs.items():
541
+ st.write(f"### {sheet_name}")
542
+ st.dataframe(df)
543
+
544
+ # Add download button
545
+ csv_data = DataExport.export_to_csv(df)
546
+ if csv_data:
547
+ filename = f"{sheet_name.replace(' ', '_').lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
548
+ download_link = DataExport.get_download_link(csv_data, filename, f"Download {sheet_name} CSV")
549
+ st.markdown(download_link, unsafe_allow_html=True)
550
+
551
+ with tab2:
552
+ # Create heating load DataFrames
553
+ heating_dfs = DataExport.create_heating_load_dataframes(results)
554
+
555
+ # Display and export each DataFrame
556
+ for sheet_name, df in heating_dfs.items():
557
+ st.write(f"### {sheet_name}")
558
+ st.dataframe(df)
559
+
560
+ # Add download button
561
+ csv_data = DataExport.export_to_csv(df)
562
+ if csv_data:
563
+ filename = f"{sheet_name.replace(' ', '_').lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
564
+ download_link = DataExport.get_download_link(csv_data, filename, f"Download {sheet_name} CSV")
565
+ st.markdown(download_link, unsafe_allow_html=True)
566
+
567
+ @staticmethod
568
+ def _display_excel_export(session_state: Dict[str, Any]) -> None:
569
+ """
570
+ Display Excel export interface.
571
+
572
+ Args:
573
+ session_state: Streamlit session state containing calculation results
574
+ """
575
+ st.subheader("Excel Export")
576
+
577
+ # Get results
578
+ results = session_state["calculation_results"]
579
+
580
+ # Create tabs for cooling, heating, and combined loads
581
+ tab1, tab2, tab3 = st.tabs(["Cooling Load Excel", "Heating Load Excel", "Combined Excel"])
582
+
583
+ with tab1:
584
+ # Create cooling load DataFrames
585
+ cooling_dfs = DataExport.create_cooling_load_dataframes(results)
586
+
587
+ # Add download button
588
+ excel_data = DataExport.export_to_excel(cooling_dfs)
589
+ if excel_data:
590
+ filename = f"cooling_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
591
+ download_link = DataExport.get_download_link(
592
+ excel_data,
593
+ filename,
594
+ "Download Cooling Load Excel",
595
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
596
+ )
597
+ st.markdown(download_link, unsafe_allow_html=True)
598
+
599
+ # Display preview
600
+ st.write("### Excel Preview")
601
+ st.write("The Excel file will contain the following sheets:")
602
+ for sheet_name in cooling_dfs.keys():
603
+ st.write(f"- {sheet_name}")
604
+
605
+ with tab2:
606
+ # Create heating load DataFrames
607
+ heating_dfs = DataExport.create_heating_load_dataframes(results)
608
+
609
+ # Add download button
610
+ excel_data = DataExport.export_to_excel(heating_dfs)
611
+ if excel_data:
612
+ filename = f"heating_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
613
+ download_link = DataExport.get_download_link(
614
+ excel_data,
615
+ filename,
616
+ "Download Heating Load Excel",
617
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
618
+ )
619
+ st.markdown(download_link, unsafe_allow_html=True)
620
+
621
+ # Display preview
622
+ st.write("### Excel Preview")
623
+ st.write("The Excel file will contain the following sheets:")
624
+ for sheet_name in heating_dfs.keys():
625
+ st.write(f"- {sheet_name}")
626
+
627
+ with tab3:
628
+ # Create combined DataFrames
629
+ combined_dfs = {}
630
+
631
+ # Add project information
632
+ if "building_info" in session_state:
633
+ project_info = [
634
+ {"Parameter": "Project Name", "Value": session_state["building_info"].get("project_name", "")},
635
+ {"Parameter": "Building Name", "Value": session_state["building_info"].get("building_name", "")},
636
+ {"Parameter": "Location", "Value": session_state["building_info"].get("location", "")},
637
+ {"Parameter": "Climate Zone", "Value": session_state["building_info"].get("climate_zone", "")},
638
+ {"Parameter": "Building Type", "Value": session_state["building_info"].get("building_type", "")},
639
+ {"Parameter": "Floor Area", "Value": session_state["building_info"].get("floor_area", "")},
640
+ {"Parameter": "Number of Floors", "Value": session_state["building_info"].get("num_floors", "")},
641
+ {"Parameter": "Floor Height", "Value": session_state["building_info"].get("floor_height", "")},
642
+ {"Parameter": "Orientation", "Value": session_state["building_info"].get("orientation", "")},
643
+ {"Parameter": "Occupancy", "Value": session_state["building_info"].get("occupancy", "")},
644
+ {"Parameter": "Operating Hours", "Value": session_state["building_info"].get("operating_hours", "")},
645
+ {"Parameter": "Date", "Value": datetime.now().strftime("%Y-%m-%d")},
646
+ {"Parameter": "Time", "Value": datetime.now().strftime("%H:%M:%S")}
647
+ ]
648
+
649
+ combined_dfs["Project Information"] = pd.DataFrame(project_info)
650
+
651
+ # Add cooling load DataFrames
652
+ cooling_dfs = DataExport.create_cooling_load_dataframes(results)
653
+ for sheet_name, df in cooling_dfs.items():
654
+ combined_dfs[sheet_name] = df
655
+
656
+ # Add heating load DataFrames
657
+ heating_dfs = DataExport.create_heating_load_dataframes(results)
658
+ for sheet_name, df in heating_dfs.items():
659
+ combined_dfs[sheet_name] = df
660
+
661
+ # Add download button
662
+ excel_data = DataExport.export_to_excel(combined_dfs)
663
+ if excel_data:
664
+ filename = f"hvac_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
665
+ download_link = DataExport.get_download_link(
666
+ excel_data,
667
+ filename,
668
+ "Download Combined Excel Report",
669
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
670
+ )
671
+ st.markdown(download_link, unsafe_allow_html=True)
672
+
673
+ # Display preview
674
+ st.write("### Excel Preview")
675
+ st.write("The Excel file will contain the following sheets:")
676
+ for sheet_name in combined_dfs.keys():
677
+ st.write(f"- {sheet_name}")
678
+
679
+ @staticmethod
680
+ def _display_scenario_export(session_state: Dict[str, Any]) -> None:
681
+ """
682
+ Display scenario export interface.
683
+
684
+ Args:
685
+ session_state: Streamlit session state containing calculation results
686
+ """
687
+ st.subheader("Scenario Export")
688
+
689
+ # Check if there are saved scenarios
690
+ if "saved_scenarios" not in session_state or not session_state["saved_scenarios"]:
691
+ st.info("No saved scenarios available for export. Save the current results as a scenario to enable export.")
692
+
693
+ # Add button to save current results as a scenario
694
+ scenario_name = st.text_input("Scenario Name", value="Baseline")
695
+
696
+ if st.button("Save Current Results as Scenario"):
697
+ if "saved_scenarios" not in session_state:
698
+ session_state["saved_scenarios"] = {}
699
+
700
+ # Save current results as a scenario
701
+ session_state["saved_scenarios"][scenario_name] = {
702
+ "results": session_state["calculation_results"],
703
+ "building_info": session_state["building_info"],
704
+ "components": session_state["components"],
705
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
706
+ }
707
+
708
+ st.success(f"Scenario '{scenario_name}' saved successfully!")
709
+ st.experimental_rerun()
710
+ else:
711
+ # Display saved scenarios
712
+ st.write("### Saved Scenarios")
713
+
714
+ # Create selectbox for scenarios
715
+ scenario_names = list(session_state["saved_scenarios"].keys())
716
+ selected_scenario = st.selectbox("Select Scenario to Export", scenario_names)
717
+
718
+ if selected_scenario:
719
+ # Get selected scenario
720
+ scenario = session_state["saved_scenarios"][selected_scenario]
721
+
722
+ # Display scenario information
723
+ st.write(f"**Scenario:** {selected_scenario}")
724
+ st.write(f"**Timestamp:** {scenario['timestamp']}")
725
+
726
+ # Add download button
727
+ json_data = DataExport.export_scenario_to_json(scenario)
728
+ if json_data:
729
+ filename = f"{selected_scenario.replace(' ', '_').lower()}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
730
+ download_link = DataExport.get_download_link(
731
+ json_data,
732
+ filename,
733
+ "Download Scenario JSON",
734
+ "application/json"
735
+ )
736
+ st.markdown(download_link, unsafe_allow_html=True)
737
+
738
+ # Add button to export all scenarios
739
+ if st.button("Export All Scenarios"):
740
+ # Create a zip file in memory
741
+ import zipfile
742
+ from io import BytesIO
743
+
744
+ zip_buffer = BytesIO()
745
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
746
+ for scenario_name, scenario in session_state["saved_scenarios"].items():
747
+ # Export scenario to JSON
748
+ json_data = DataExport.export_scenario_to_json(scenario)
749
+ if json_data:
750
+ filename = f"{scenario_name.replace(' ', '_').lower()}.json"
751
+ zip_file.writestr(filename, json_data)
752
+
753
+ # Add download button for zip file
754
+ zip_buffer.seek(0)
755
+ zip_data = zip_buffer.getvalue()
756
+
757
+ filename = f"all_scenarios_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
758
+ download_link = DataExport.get_download_link(
759
+ zip_data,
760
+ filename,
761
+ "Download All Scenarios (ZIP)",
762
+ "application/zip"
763
+ )
764
+ st.markdown(download_link, unsafe_allow_html=True)
765
+
766
+
767
+ # Create a singleton instance
768
+ data_export = DataExport()
769
+
770
+ # Example usage
771
+ if __name__ == "__main__":
772
+ import streamlit as st
773
+
774
+ # Initialize session state with dummy data for testing
775
+ if "calculation_results" not in st.session_state:
776
+ st.session_state["calculation_results"] = {
777
+ "cooling": {
778
+ "total_load": 25.5,
779
+ "sensible_load": 20.0,
780
+ "latent_load": 5.5,
781
+ "load_per_area": 85.0,
782
+ "component_loads": {
783
+ "walls": 5.0,
784
+ "roof": 3.0,
785
+ "windows": 8.0,
786
+ "doors": 1.0,
787
+ "people": 2.5,
788
+ "lighting": 2.0,
789
+ "equipment": 1.5,
790
+ "infiltration": 1.0,
791
+ "ventilation": 1.5
792
+ },
793
+ "detailed_loads": {
794
+ "walls": [
795
+ {"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "cltd": 10.0, "load": 1.0}
796
+ ],
797
+ "roofs": [
798
+ {"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "cltd": 15.0, "load": 3.0}
799
+ ],
800
+ "windows": [
801
+ {"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "shgc": 0.7, "scl": 800.0, "load": 8.0}
802
+ ],
803
+ "doors": [
804
+ {"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "cltd": 10.0, "load": 1.0}
805
+ ],
806
+ "internal": [
807
+ {"type": "People", "name": "Occupants", "quantity": 10, "heat_gain": 250, "clf": 1.0, "load": 2.5},
808
+ {"type": "Lighting", "name": "General Lighting", "quantity": 1000, "heat_gain": 2000, "clf": 1.0, "load": 2.0},
809
+ {"type": "Equipment", "name": "Office Equipment", "quantity": 5, "heat_gain": 300, "clf": 1.0, "load": 1.5}
810
+ ],
811
+ "infiltration": {
812
+ "air_flow": 0.05,
813
+ "sensible_load": 0.8,
814
+ "latent_load": 0.2,
815
+ "total_load": 1.0
816
+ },
817
+ "ventilation": {
818
+ "air_flow": 0.1,
819
+ "sensible_load": 1.0,
820
+ "latent_load": 0.5,
821
+ "total_load": 1.5
822
+ }
823
+ }
824
+ },
825
+ "heating": {
826
+ "total_load": 30.0,
827
+ "load_per_area": 100.0,
828
+ "design_heat_loss": 27.0,
829
+ "safety_factor": 10.0,
830
+ "component_loads": {
831
+ "walls": 8.0,
832
+ "roof": 5.0,
833
+ "floor": 4.0,
834
+ "windows": 7.0,
835
+ "doors": 1.0,
836
+ "infiltration": 2.0,
837
+ "ventilation": 3.0
838
+ },
839
+ "detailed_loads": {
840
+ "walls": [
841
+ {"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "delta_t": 25.0, "load": 8.0}
842
+ ],
843
+ "roofs": [
844
+ {"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "delta_t": 25.0, "load": 5.0}
845
+ ],
846
+ "floors": [
847
+ {"name": "Ground Floor", "area": 100.0, "u_value": 0.4, "delta_t": 10.0, "load": 4.0}
848
+ ],
849
+ "windows": [
850
+ {"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "delta_t": 25.0, "load": 7.0}
851
+ ],
852
+ "doors": [
853
+ {"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "delta_t": 25.0, "load": 1.0}
854
+ ],
855
+ "infiltration": {
856
+ "air_flow": 0.05,
857
+ "delta_t": 25.0,
858
+ "load": 2.0
859
+ },
860
+ "ventilation": {
861
+ "air_flow": 0.1,
862
+ "delta_t": 25.0,
863
+ "load": 3.0
864
+ }
865
+ }
866
+ }
867
+ }
868
+
869
+ # Display export interface
870
+ data_export.display_export_interface(st.session_state)
app/data_persistence.py ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data persistence module for HVAC Load Calculator.
3
+ This module provides functionality for saving and loading project data.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ import json
11
+ import os
12
+ import base64
13
+ import io
14
+ import pickle
15
+ from datetime import datetime
16
+
17
+ # Import data models
18
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
19
+
20
+
21
+ class DataPersistence:
22
+ """Class for data persistence functionality."""
23
+
24
+ @staticmethod
25
+ def save_project_to_json(session_state: Dict[str, Any], file_path: str = None) -> Optional[str]:
26
+ """
27
+ Save project data to a JSON file.
28
+
29
+ Args:
30
+ session_state: Streamlit session state containing project data
31
+ file_path: Optional path to save the JSON file
32
+
33
+ Returns:
34
+ JSON string if file_path is None, otherwise None
35
+ """
36
+ try:
37
+ # Create project data dictionary
38
+ project_data = {
39
+ "building_info": session_state.get("building_info", {}),
40
+ "components": DataPersistence._serialize_components(session_state.get("components", {})),
41
+ "internal_loads": session_state.get("internal_loads", {}),
42
+ "calculation_settings": session_state.get("calculation_settings", {}),
43
+ "saved_scenarios": DataPersistence._serialize_scenarios(session_state.get("saved_scenarios", {})),
44
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
45
+ }
46
+
47
+ # Convert to JSON
48
+ json_data = json.dumps(project_data, indent=4)
49
+
50
+ # Save to file if path provided
51
+ if file_path:
52
+ with open(file_path, "w") as f:
53
+ f.write(json_data)
54
+ return None
55
+
56
+ # Return JSON string if no path provided
57
+ return json_data
58
+
59
+ except Exception as e:
60
+ st.error(f"Error saving project data: {e}")
61
+ return None
62
+
63
+ @staticmethod
64
+ def load_project_from_json(json_data: str = None, file_path: str = None) -> Optional[Dict[str, Any]]:
65
+ """
66
+ Load project data from a JSON file or string.
67
+
68
+ Args:
69
+ json_data: Optional JSON string containing project data
70
+ file_path: Optional path to the JSON file
71
+
72
+ Returns:
73
+ Dictionary with project data if successful, None otherwise
74
+ """
75
+ try:
76
+ # Load from file if path provided
77
+ if file_path and not json_data:
78
+ with open(file_path, "r") as f:
79
+ json_data = f.read()
80
+
81
+ # Parse JSON data
82
+ if json_data:
83
+ project_data = json.loads(json_data)
84
+
85
+ # Deserialize components
86
+ if "components" in project_data:
87
+ project_data["components"] = DataPersistence._deserialize_components(project_data["components"])
88
+
89
+ # Deserialize scenarios
90
+ if "saved_scenarios" in project_data:
91
+ project_data["saved_scenarios"] = DataPersistence._deserialize_scenarios(project_data["saved_scenarios"])
92
+
93
+ return project_data
94
+
95
+ return None
96
+
97
+ except Exception as e:
98
+ st.error(f"Error loading project data: {e}")
99
+ return None
100
+
101
+ @staticmethod
102
+ def _serialize_components(components: Dict[str, List[Any]]) -> Dict[str, List[Dict[str, Any]]]:
103
+ """
104
+ Serialize components for JSON storage.
105
+
106
+ Args:
107
+ components: Dictionary with building components
108
+
109
+ Returns:
110
+ Dictionary with serialized components
111
+ """
112
+ serialized_components = {
113
+ "walls": [],
114
+ "roofs": [],
115
+ "floors": [],
116
+ "windows": [],
117
+ "doors": []
118
+ }
119
+
120
+ # Serialize walls
121
+ for wall in components.get("walls", []):
122
+ serialized_wall = wall.__dict__.copy()
123
+
124
+ # Convert enums to strings
125
+ if hasattr(serialized_wall["orientation"], "name"):
126
+ serialized_wall["orientation"] = serialized_wall["orientation"].name
127
+
128
+ if hasattr(serialized_wall["component_type"], "name"):
129
+ serialized_wall["component_type"] = serialized_wall["component_type"].name
130
+
131
+ serialized_components["walls"].append(serialized_wall)
132
+
133
+ # Serialize roofs
134
+ for roof in components.get("roofs", []):
135
+ serialized_roof = roof.__dict__.copy()
136
+
137
+ # Convert enums to strings
138
+ if hasattr(serialized_roof["orientation"], "name"):
139
+ serialized_roof["orientation"] = serialized_roof["orientation"].name
140
+
141
+ if hasattr(serialized_roof["component_type"], "name"):
142
+ serialized_roof["component_type"] = serialized_roof["component_type"].name
143
+
144
+ serialized_components["roofs"].append(serialized_roof)
145
+
146
+ # Serialize floors
147
+ for floor in components.get("floors", []):
148
+ serialized_floor = floor.__dict__.copy()
149
+
150
+ # Convert enums to strings
151
+ if hasattr(serialized_floor["component_type"], "name"):
152
+ serialized_floor["component_type"] = serialized_floor["component_type"].name
153
+
154
+ serialized_components["floors"].append(serialized_floor)
155
+
156
+ # Serialize windows
157
+ for window in components.get("windows", []):
158
+ serialized_window = window.__dict__.copy()
159
+
160
+ # Convert enums to strings
161
+ if hasattr(serialized_window["orientation"], "name"):
162
+ serialized_window["orientation"] = serialized_window["orientation"].name
163
+
164
+ if hasattr(serialized_window["component_type"], "name"):
165
+ serialized_window["component_type"] = serialized_window["component_type"].name
166
+
167
+ serialized_components["windows"].append(serialized_window)
168
+
169
+ # Serialize doors
170
+ for door in components.get("doors", []):
171
+ serialized_door = door.__dict__.copy()
172
+
173
+ # Convert enums to strings
174
+ if hasattr(serialized_door["orientation"], "name"):
175
+ serialized_door["orientation"] = serialized_door["orientation"].name
176
+
177
+ if hasattr(serialized_door["component_type"], "name"):
178
+ serialized_door["component_type"] = serialized_door["component_type"].name
179
+
180
+ serialized_components["doors"].append(serialized_door)
181
+
182
+ return serialized_components
183
+
184
+ @staticmethod
185
+ def _deserialize_components(serialized_components: Dict[str, List[Dict[str, Any]]]) -> Dict[str, List[Any]]:
186
+ """
187
+ Deserialize components from JSON storage.
188
+
189
+ Args:
190
+ serialized_components: Dictionary with serialized components
191
+
192
+ Returns:
193
+ Dictionary with deserialized components
194
+ """
195
+ components = {
196
+ "walls": [],
197
+ "roofs": [],
198
+ "floors": [],
199
+ "windows": [],
200
+ "doors": []
201
+ }
202
+
203
+ # Deserialize walls
204
+ for wall_dict in serialized_components.get("walls", []):
205
+ wall = Wall(
206
+ id=wall_dict.get("id", ""),
207
+ name=wall_dict.get("name", ""),
208
+ component_type=ComponentType[wall_dict.get("component_type", "WALL")],
209
+ u_value=wall_dict.get("u_value", 0.0),
210
+ area=wall_dict.get("area", 0.0),
211
+ orientation=Orientation[wall_dict.get("orientation", "NORTH")],
212
+ wall_type=wall_dict.get("wall_type", ""),
213
+ wall_group=wall_dict.get("wall_group", "")
214
+ )
215
+ components["walls"].append(wall)
216
+
217
+ # Deserialize roofs
218
+ for roof_dict in serialized_components.get("roofs", []):
219
+ roof = Roof(
220
+ id=roof_dict.get("id", ""),
221
+ name=roof_dict.get("name", ""),
222
+ component_type=ComponentType[roof_dict.get("component_type", "ROOF")],
223
+ u_value=roof_dict.get("u_value", 0.0),
224
+ area=roof_dict.get("area", 0.0),
225
+ orientation=Orientation[roof_dict.get("orientation", "HORIZONTAL")],
226
+ roof_type=roof_dict.get("roof_type", ""),
227
+ roof_group=roof_dict.get("roof_group", "")
228
+ )
229
+ components["roofs"].append(roof)
230
+
231
+ # Deserialize floors
232
+ for floor_dict in serialized_components.get("floors", []):
233
+ floor = Floor(
234
+ id=floor_dict.get("id", ""),
235
+ name=floor_dict.get("name", ""),
236
+ component_type=ComponentType[floor_dict.get("component_type", "FLOOR")],
237
+ u_value=floor_dict.get("u_value", 0.0),
238
+ area=floor_dict.get("area", 0.0),
239
+ floor_type=floor_dict.get("floor_type", "")
240
+ )
241
+ components["floors"].append(floor)
242
+
243
+ # Deserialize windows
244
+ for window_dict in serialized_components.get("windows", []):
245
+ window = Window(
246
+ id=window_dict.get("id", ""),
247
+ name=window_dict.get("name", ""),
248
+ component_type=ComponentType[window_dict.get("component_type", "WINDOW")],
249
+ u_value=window_dict.get("u_value", 0.0),
250
+ area=window_dict.get("area", 0.0),
251
+ orientation=Orientation[window_dict.get("orientation", "NORTH")],
252
+ shgc=window_dict.get("shgc", 0.0),
253
+ vt=window_dict.get("vt", 0.0),
254
+ window_type=window_dict.get("window_type", ""),
255
+ glazing_layers=window_dict.get("glazing_layers", 1),
256
+ gas_fill=window_dict.get("gas_fill", ""),
257
+ low_e_coating=window_dict.get("low_e_coating", False)
258
+ )
259
+ components["windows"].append(window)
260
+
261
+ # Deserialize doors
262
+ for door_dict in serialized_components.get("doors", []):
263
+ door = Door(
264
+ id=door_dict.get("id", ""),
265
+ name=door_dict.get("name", ""),
266
+ component_type=ComponentType[door_dict.get("component_type", "DOOR")],
267
+ u_value=door_dict.get("u_value", 0.0),
268
+ area=door_dict.get("area", 0.0),
269
+ orientation=Orientation[door_dict.get("orientation", "NORTH")],
270
+ door_type=door_dict.get("door_type", "")
271
+ )
272
+ components["doors"].append(door)
273
+
274
+ return components
275
+
276
+ @staticmethod
277
+ def _serialize_scenarios(scenarios: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
278
+ """
279
+ Serialize scenarios for JSON storage.
280
+
281
+ Args:
282
+ scenarios: Dictionary with saved scenarios
283
+
284
+ Returns:
285
+ Dictionary with serialized scenarios
286
+ """
287
+ serialized_scenarios = {}
288
+
289
+ for scenario_name, scenario_data in scenarios.items():
290
+ serialized_scenario = {
291
+ "results": scenario_data.get("results", {}),
292
+ "building_info": scenario_data.get("building_info", {}),
293
+ "components": DataPersistence._serialize_components(scenario_data.get("components", {})),
294
+ "timestamp": scenario_data.get("timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
295
+ }
296
+
297
+ serialized_scenarios[scenario_name] = serialized_scenario
298
+
299
+ return serialized_scenarios
300
+
301
+ @staticmethod
302
+ def _deserialize_scenarios(serialized_scenarios: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
303
+ """
304
+ Deserialize scenarios from JSON storage.
305
+
306
+ Args:
307
+ serialized_scenarios: Dictionary with serialized scenarios
308
+
309
+ Returns:
310
+ Dictionary with deserialized scenarios
311
+ """
312
+ scenarios = {}
313
+
314
+ for scenario_name, serialized_scenario in serialized_scenarios.items():
315
+ scenario = {
316
+ "results": serialized_scenario.get("results", {}),
317
+ "building_info": serialized_scenario.get("building_info", {}),
318
+ "components": DataPersistence._deserialize_components(serialized_scenario.get("components", {})),
319
+ "timestamp": serialized_scenario.get("timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
320
+ }
321
+
322
+ scenarios[scenario_name] = scenario
323
+
324
+ return scenarios
325
+
326
+ @staticmethod
327
+ def get_download_link(data: str, filename: str, text: str) -> str:
328
+ """
329
+ Generate a download link for data.
330
+
331
+ Args:
332
+ data: Data to download
333
+ filename: Name of the file to download
334
+ text: Text to display for the download link
335
+
336
+ Returns:
337
+ HTML string with download link
338
+ """
339
+ b64 = base64.b64encode(data.encode()).decode()
340
+ href = f'<a href="data:file/txt;base64,{b64}" download="{filename}">{text}</a>'
341
+ return href
342
+
343
+ @staticmethod
344
+ def display_project_management(session_state: Dict[str, Any]) -> None:
345
+ """
346
+ Display project management interface in Streamlit.
347
+
348
+ Args:
349
+ session_state: Streamlit session state containing project data
350
+ """
351
+ st.header("Project Management")
352
+
353
+ # Create tabs for different project management functions
354
+ tab1, tab2, tab3 = st.tabs(["Save Project", "Load Project", "Project History"])
355
+
356
+ with tab1:
357
+ DataPersistence._display_save_project(session_state)
358
+
359
+ with tab2:
360
+ DataPersistence._display_load_project(session_state)
361
+
362
+ with tab3:
363
+ DataPersistence._display_project_history(session_state)
364
+
365
+ @staticmethod
366
+ def _display_save_project(session_state: Dict[str, Any]) -> None:
367
+ """
368
+ Display save project interface.
369
+
370
+ Args:
371
+ session_state: Streamlit session state containing project data
372
+ """
373
+ st.subheader("Save Project")
374
+
375
+ # Get project name
376
+ project_name = st.text_input(
377
+ "Project Name",
378
+ value=session_state.get("building_info", {}).get("project_name", "HVAC_Project"),
379
+ key="save_project_name"
380
+ )
381
+
382
+ # Add description
383
+ project_description = st.text_area(
384
+ "Project Description",
385
+ value=session_state.get("project_description", ""),
386
+ key="save_project_description"
387
+ )
388
+
389
+ # Save project description
390
+ session_state["project_description"] = project_description
391
+
392
+ # Add save button
393
+ if st.button("Save Project"):
394
+ # Validate project data
395
+ if "building_info" not in session_state or not session_state["building_info"]:
396
+ st.error("No building information found. Please enter building information before saving.")
397
+ return
398
+
399
+ if "components" not in session_state or not any(session_state["components"].values()):
400
+ st.warning("No building components found. It's recommended to add components before saving.")
401
+
402
+ # Save project data to JSON
403
+ json_data = DataPersistence.save_project_to_json(session_state)
404
+
405
+ if json_data:
406
+ # Generate download link
407
+ filename = f"{project_name.replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.hvac"
408
+ download_link = DataPersistence.get_download_link(json_data, filename, "Download Project File")
409
+
410
+ # Display download link
411
+ st.success("Project saved successfully!")
412
+ st.markdown(download_link, unsafe_allow_html=True)
413
+
414
+ # Save to project history
415
+ if "project_history" not in session_state:
416
+ session_state["project_history"] = []
417
+
418
+ session_state["project_history"].append({
419
+ "name": project_name,
420
+ "description": project_description,
421
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
422
+ "data": json_data
423
+ })
424
+ else:
425
+ st.error("Error saving project data.")
426
+
427
+ @staticmethod
428
+ def _display_load_project(session_state: Dict[str, Any]) -> None:
429
+ """
430
+ Display load project interface.
431
+
432
+ Args:
433
+ session_state: Streamlit session state containing project data
434
+ """
435
+ st.subheader("Load Project")
436
+
437
+ # Add file uploader
438
+ uploaded_file = st.file_uploader("Upload Project File", type=["hvac", "json"])
439
+
440
+ if uploaded_file is not None:
441
+ # Read file content
442
+ json_data = uploaded_file.read().decode("utf-8")
443
+
444
+ # Load project data
445
+ project_data = DataPersistence.load_project_from_json(json_data)
446
+
447
+ if project_data:
448
+ # Add load button
449
+ if st.button("Load Project Data"):
450
+ # Update session state with project data
451
+ for key, value in project_data.items():
452
+ session_state[key] = value
453
+
454
+ st.success("Project loaded successfully!")
455
+ st.experimental_rerun()
456
+ else:
457
+ st.error("Error loading project data. Invalid file format.")
458
+
459
+ @staticmethod
460
+ def _display_project_history(session_state: Dict[str, Any]) -> None:
461
+ """
462
+ Display project history interface.
463
+
464
+ Args:
465
+ session_state: Streamlit session state containing project data
466
+ """
467
+ st.subheader("Project History")
468
+
469
+ # Check if project history exists
470
+ if "project_history" not in session_state or not session_state["project_history"]:
471
+ st.info("No project history found. Save a project to see it in the history.")
472
+ return
473
+
474
+ # Display project history
475
+ for i, project in enumerate(reversed(session_state["project_history"])):
476
+ with st.expander(f"{project['name']} - {project['timestamp']}"):
477
+ st.write(f"**Description:** {project['description']}")
478
+
479
+ # Add load button
480
+ if st.button(f"Load Project", key=f"load_history_{i}"):
481
+ # Load project data
482
+ project_data = DataPersistence.load_project_from_json(project["data"])
483
+
484
+ if project_data:
485
+ # Update session state with project data
486
+ for key, value in project_data.items():
487
+ session_state[key] = value
488
+
489
+ st.success("Project loaded successfully!")
490
+ st.experimental_rerun()
491
+
492
+ # Add download button
493
+ filename = f"{project['name'].replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.hvac"
494
+ download_link = DataPersistence.get_download_link(project["data"], filename, "Download Project File")
495
+ st.markdown(download_link, unsafe_allow_html=True)
496
+
497
+ # Add delete button
498
+ if st.button(f"Delete from History", key=f"delete_history_{i}"):
499
+ # Remove project from history
500
+ session_state["project_history"].remove(project)
501
+
502
+ st.success("Project removed from history.")
503
+ st.experimental_rerun()
504
+
505
+
506
+ # Create a singleton instance
507
+ data_persistence = DataPersistence()
508
+
509
+ # Example usage
510
+ if __name__ == "__main__":
511
+ import streamlit as st
512
+
513
+ # Initialize session state with dummy data for testing
514
+ if "building_info" not in st.session_state:
515
+ st.session_state["building_info"] = {
516
+ "project_name": "Test Project",
517
+ "building_name": "Test Building",
518
+ "location": "New York",
519
+ "climate_zone": "4A",
520
+ "building_type": "Office",
521
+ "floor_area": 1000.0,
522
+ "num_floors": 2,
523
+ "floor_height": 3.0,
524
+ "orientation": "NORTH",
525
+ "occupancy": 50,
526
+ "operating_hours": "8:00-18:00",
527
+ "design_conditions": {
528
+ "summer_outdoor_db": 35.0,
529
+ "summer_outdoor_wb": 25.0,
530
+ "summer_indoor_db": 24.0,
531
+ "summer_indoor_rh": 50.0,
532
+ "winter_outdoor_db": -5.0,
533
+ "winter_outdoor_rh": 80.0,
534
+ "winter_indoor_db": 21.0,
535
+ "winter_indoor_rh": 40.0
536
+ }
537
+ }
538
+
539
+ # Display project management interface
540
+ data_persistence.display_project_management(st.session_state)
app/data_validation.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data validation module for HVAC Load Calculator.
3
+ This module provides validation functions for user inputs.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple, Callable
10
+ import json
11
+ import os
12
+
13
+
14
+ class DataValidation:
15
+ """Class for data validation functionality."""
16
+
17
+ @staticmethod
18
+ def validate_building_info(building_info: Dict[str, Any]) -> Tuple[bool, List[str]]:
19
+ """
20
+ Validate building information inputs.
21
+
22
+ Args:
23
+ building_info: Dictionary with building information
24
+
25
+ Returns:
26
+ Tuple containing validation result (True if valid) and list of validation messages
27
+ """
28
+ is_valid = True
29
+ messages = []
30
+
31
+ # Check required fields
32
+ required_fields = [
33
+ ("project_name", "Project Name"),
34
+ ("building_name", "Building Name"),
35
+ ("location", "Location"),
36
+ ("climate_zone", "Climate Zone"),
37
+ ("building_type", "Building Type")
38
+ ]
39
+
40
+ for field, display_name in required_fields:
41
+ if field not in building_info or not building_info[field]:
42
+ is_valid = False
43
+ messages.append(f"{display_name} is required.")
44
+
45
+ # Check numeric fields
46
+ numeric_fields = [
47
+ ("floor_area", "Floor Area", 0, None),
48
+ ("num_floors", "Number of Floors", 1, None),
49
+ ("floor_height", "Floor Height", 2.0, 10.0),
50
+ ("occupancy", "Occupancy", 0, None)
51
+ ]
52
+
53
+ for field, display_name, min_val, max_val in numeric_fields:
54
+ if field in building_info:
55
+ try:
56
+ value = float(building_info[field])
57
+ if min_val is not None and value < min_val:
58
+ is_valid = False
59
+ messages.append(f"{display_name} must be at least {min_val}.")
60
+ if max_val is not None and value > max_val:
61
+ is_valid = False
62
+ messages.append(f"{display_name} must be at most {max_val}.")
63
+ except (ValueError, TypeError):
64
+ is_valid = False
65
+ messages.append(f"{display_name} must be a number.")
66
+
67
+ # Check design conditions
68
+ if "design_conditions" in building_info:
69
+ design_conditions = building_info["design_conditions"]
70
+
71
+ # Check summer conditions
72
+ summer_fields = [
73
+ ("summer_outdoor_db", "Summer Outdoor Dry-Bulb", -10.0, 50.0),
74
+ ("summer_outdoor_wb", "Summer Outdoor Wet-Bulb", -10.0, 40.0),
75
+ ("summer_indoor_db", "Summer Indoor Dry-Bulb", 18.0, 30.0),
76
+ ("summer_indoor_rh", "Summer Indoor RH", 30.0, 70.0)
77
+ ]
78
+
79
+ for field, display_name, min_val, max_val in summer_fields:
80
+ if field in design_conditions:
81
+ try:
82
+ value = float(design_conditions[field])
83
+ if min_val is not None and value < min_val:
84
+ is_valid = False
85
+ messages.append(f"{display_name} must be at least {min_val}.")
86
+ if max_val is not None and value > max_val:
87
+ is_valid = False
88
+ messages.append(f"{display_name} must be at most {max_val}.")
89
+ except (ValueError, TypeError):
90
+ is_valid = False
91
+ messages.append(f"{display_name} must be a number.")
92
+
93
+ # Check winter conditions
94
+ winter_fields = [
95
+ ("winter_outdoor_db", "Winter Outdoor Dry-Bulb", -40.0, 20.0),
96
+ ("winter_outdoor_rh", "Winter Outdoor RH", 0.0, 100.0),
97
+ ("winter_indoor_db", "Winter Indoor Dry-Bulb", 18.0, 25.0),
98
+ ("winter_indoor_rh", "Winter Indoor RH", 20.0, 60.0)
99
+ ]
100
+
101
+ for field, display_name, min_val, max_val in winter_fields:
102
+ if field in design_conditions:
103
+ try:
104
+ value = float(design_conditions[field])
105
+ if min_val is not None and value < min_val:
106
+ is_valid = False
107
+ messages.append(f"{display_name} must be at least {min_val}.")
108
+ if max_val is not None and value > max_val:
109
+ is_valid = False
110
+ messages.append(f"{display_name} must be at most {max_val}.")
111
+ except (ValueError, TypeError):
112
+ is_valid = False
113
+ messages.append(f"{display_name} must be a number.")
114
+
115
+ # Check that wet-bulb is less than dry-bulb
116
+ if "summer_outdoor_db" in design_conditions and "summer_outdoor_wb" in design_conditions:
117
+ try:
118
+ db = float(design_conditions["summer_outdoor_db"])
119
+ wb = float(design_conditions["summer_outdoor_wb"])
120
+ if wb > db:
121
+ is_valid = False
122
+ messages.append("Summer Outdoor Wet-Bulb temperature must be less than or equal to Dry-Bulb temperature.")
123
+ except (ValueError, TypeError):
124
+ pass # Already handled above
125
+
126
+ return is_valid, messages
127
+
128
+ @staticmethod
129
+ def validate_components(components: Dict[str, List[Any]]) -> Tuple[bool, List[str]]:
130
+ """
131
+ Validate building components.
132
+
133
+ Args:
134
+ components: Dictionary with building components
135
+
136
+ Returns:
137
+ Tuple containing validation result (True if valid) and list of validation messages
138
+ """
139
+ is_valid = True
140
+ messages = []
141
+
142
+ # Check if any components exist
143
+ if not any(components.values()):
144
+ is_valid = False
145
+ messages.append("At least one building component (wall, roof, floor, window, or door) is required.")
146
+
147
+ # Check wall components
148
+ for i, wall in enumerate(components.get("walls", [])):
149
+ # Check required fields
150
+ if not wall.name:
151
+ is_valid = False
152
+ messages.append(f"Wall #{i+1}: Name is required.")
153
+
154
+ # Check numeric fields
155
+ if wall.area <= 0:
156
+ is_valid = False
157
+ messages.append(f"Wall #{i+1}: Area must be greater than zero.")
158
+
159
+ if wall.u_value <= 0:
160
+ is_valid = False
161
+ messages.append(f"Wall #{i+1}: U-value must be greater than zero.")
162
+
163
+ # Check roof components
164
+ for i, roof in enumerate(components.get("roofs", [])):
165
+ # Check required fields
166
+ if not roof.name:
167
+ is_valid = False
168
+ messages.append(f"Roof #{i+1}: Name is required.")
169
+
170
+ # Check numeric fields
171
+ if roof.area <= 0:
172
+ is_valid = False
173
+ messages.append(f"Roof #{i+1}: Area must be greater than zero.")
174
+
175
+ if roof.u_value <= 0:
176
+ is_valid = False
177
+ messages.append(f"Roof #{i+1}: U-value must be greater than zero.")
178
+
179
+ # Check floor components
180
+ for i, floor in enumerate(components.get("floors", [])):
181
+ # Check required fields
182
+ if not floor.name:
183
+ is_valid = False
184
+ messages.append(f"Floor #{i+1}: Name is required.")
185
+
186
+ # Check numeric fields
187
+ if floor.area <= 0:
188
+ is_valid = False
189
+ messages.append(f"Floor #{i+1}: Area must be greater than zero.")
190
+
191
+ if floor.u_value <= 0:
192
+ is_valid = False
193
+ messages.append(f"Floor #{i+1}: U-value must be greater than zero.")
194
+
195
+ # Check window components
196
+ for i, window in enumerate(components.get("windows", [])):
197
+ # Check required fields
198
+ if not window.name:
199
+ is_valid = False
200
+ messages.append(f"Window #{i+1}: Name is required.")
201
+
202
+ # Check numeric fields
203
+ if window.area <= 0:
204
+ is_valid = False
205
+ messages.append(f"Window #{i+1}: Area must be greater than zero.")
206
+
207
+ if window.u_value <= 0:
208
+ is_valid = False
209
+ messages.append(f"Window #{i+1}: U-value must be greater than zero.")
210
+
211
+ if window.shgc <= 0 or window.shgc > 1:
212
+ is_valid = False
213
+ messages.append(f"Window #{i+1}: SHGC must be between 0 and 1.")
214
+
215
+ # Check door components
216
+ for i, door in enumerate(components.get("doors", [])):
217
+ # Check required fields
218
+ if not door.name:
219
+ is_valid = False
220
+ messages.append(f"Door #{i+1}: Name is required.")
221
+
222
+ # Check numeric fields
223
+ if door.area <= 0:
224
+ is_valid = False
225
+ messages.append(f"Door #{i+1}: Area must be greater than zero.")
226
+
227
+ if door.u_value <= 0:
228
+ is_valid = False
229
+ messages.append(f"Door #{i+1}: U-value must be greater than zero.")
230
+
231
+ # Check for minimum requirements
232
+ if not components.get("walls", []):
233
+ messages.append("Warning: No walls defined. At least one wall is recommended.")
234
+
235
+ if not components.get("roofs", []):
236
+ messages.append("Warning: No roofs defined. At least one roof is recommended.")
237
+
238
+ if not components.get("floors", []):
239
+ messages.append("Warning: No floors defined. At least one floor is recommended.")
240
+
241
+ return is_valid, messages
242
+
243
+ @staticmethod
244
+ def validate_internal_loads(internal_loads: Dict[str, Any]) -> Tuple[bool, List[str]]:
245
+ """
246
+ Validate internal loads inputs.
247
+
248
+ Args:
249
+ internal_loads: Dictionary with internal loads information
250
+
251
+ Returns:
252
+ Tuple containing validation result (True if valid) and list of validation messages
253
+ """
254
+ is_valid = True
255
+ messages = []
256
+
257
+ # Check people loads
258
+ people = internal_loads.get("people", [])
259
+ for i, person in enumerate(people):
260
+ # Check required fields
261
+ if not person.get("name"):
262
+ is_valid = False
263
+ messages.append(f"People Load #{i+1}: Name is required.")
264
+
265
+ # Check numeric fields
266
+ if person.get("num_people", 0) < 0:
267
+ is_valid = False
268
+ messages.append(f"People Load #{i+1}: Number of people must be non-negative.")
269
+
270
+ if person.get("hours_in_operation", 0) <= 0:
271
+ is_valid = False
272
+ messages.append(f"People Load #{i+1}: Hours in operation must be positive.")
273
+
274
+ # Check lighting loads
275
+ lighting = internal_loads.get("lighting", [])
276
+ for i, light in enumerate(lighting):
277
+ # Check required fields
278
+ if not light.get("name"):
279
+ is_valid = False
280
+ messages.append(f"Lighting Load #{i+1}: Name is required.")
281
+
282
+ # Check numeric fields
283
+ if light.get("power", 0) < 0:
284
+ is_valid = False
285
+ messages.append(f"Lighting Load #{i+1}: Power must be non-negative.")
286
+
287
+ if light.get("usage_factor", 0) < 0 or light.get("usage_factor", 0) > 1:
288
+ is_valid = False
289
+ messages.append(f"Lighting Load #{i+1}: Usage factor must be between 0 and 1.")
290
+
291
+ if light.get("hours_in_operation", 0) <= 0:
292
+ is_valid = False
293
+ messages.append(f"Lighting Load #{i+1}: Hours in operation must be positive.")
294
+
295
+ # Check equipment loads
296
+ equipment = internal_loads.get("equipment", [])
297
+ for i, equip in enumerate(equipment):
298
+ # Check required fields
299
+ if not equip.get("name"):
300
+ is_valid = False
301
+ messages.append(f"Equipment Load #{i+1}: Name is required.")
302
+
303
+ # Check numeric fields
304
+ if equip.get("power", 0) < 0:
305
+ is_valid = False
306
+ messages.append(f"Equipment Load #{i+1}: Power must be non-negative.")
307
+
308
+ if equip.get("usage_factor", 0) < 0 or equip.get("usage_factor", 0) > 1:
309
+ is_valid = False
310
+ messages.append(f"Equipment Load #{i+1}: Usage factor must be between 0 and 1.")
311
+
312
+ if equip.get("radiation_fraction", 0) < 0 or equip.get("radiation_fraction", 0) > 1:
313
+ is_valid = False
314
+ messages.append(f"Equipment Load #{i+1}: Radiation fraction must be between 0 and 1.")
315
+
316
+ if equip.get("hours_in_operation", 0) <= 0:
317
+ is_valid = False
318
+ messages.append(f"Equipment Load #{i+1}: Hours in operation must be positive.")
319
+
320
+ return is_valid, messages
321
+
322
+ @staticmethod
323
+ def validate_calculation_settings(settings: Dict[str, Any]) -> Tuple[bool, List[str]]:
324
+ """
325
+ Validate calculation settings.
326
+
327
+ Args:
328
+ settings: Dictionary with calculation settings
329
+
330
+ Returns:
331
+ Tuple containing validation result (True if valid) and list of validation messages
332
+ """
333
+ is_valid = True
334
+ messages = []
335
+
336
+ # Check infiltration rate
337
+ if "infiltration_rate" in settings:
338
+ try:
339
+ infiltration_rate = float(settings["infiltration_rate"])
340
+ if infiltration_rate < 0:
341
+ is_valid = False
342
+ messages.append("Infiltration rate must be non-negative.")
343
+ except (ValueError, TypeError):
344
+ is_valid = False
345
+ messages.append("Infiltration rate must be a number.")
346
+
347
+ # Check ventilation rate
348
+ if "ventilation_rate" in settings:
349
+ try:
350
+ ventilation_rate = float(settings["ventilation_rate"])
351
+ if ventilation_rate < 0:
352
+ is_valid = False
353
+ messages.append("Ventilation rate must be non-negative.")
354
+ except (ValueError, TypeError):
355
+ is_valid = False
356
+ messages.append("Ventilation rate must be a number.")
357
+
358
+ # Check safety factors
359
+ safety_factors = ["cooling_safety_factor", "heating_safety_factor"]
360
+ for factor in safety_factors:
361
+ if factor in settings:
362
+ try:
363
+ value = float(settings[factor])
364
+ if value < 0:
365
+ is_valid = False
366
+ messages.append(f"{factor.replace('_', ' ').title()} must be non-negative.")
367
+ except (ValueError, TypeError):
368
+ is_valid = False
369
+ messages.append(f"{factor.replace('_', ' ').title()} must be a number.")
370
+
371
+ return is_valid, messages
372
+
373
+ @staticmethod
374
+ def display_validation_messages(messages: List[str], container=None) -> None:
375
+ """
376
+ Display validation messages in Streamlit.
377
+
378
+ Args:
379
+ messages: List of validation messages
380
+ container: Optional Streamlit container to display messages in
381
+ """
382
+ if not messages:
383
+ return
384
+
385
+ # Separate errors and warnings
386
+ errors = [msg for msg in messages if not msg.startswith("Warning:")]
387
+ warnings = [msg for msg in messages if msg.startswith("Warning:")]
388
+
389
+ # Use provided container or st directly
390
+ display = container if container is not None else st
391
+
392
+ # Display errors
393
+ if errors:
394
+ error_msg = "Please fix the following errors:\n" + "\n".join([f"- {msg}" for msg in errors])
395
+ display.error(error_msg)
396
+
397
+ # Display warnings
398
+ if warnings:
399
+ warning_msg = "Warnings:\n" + "\n".join([f"- {msg[8:]}" for msg in warnings])
400
+ display.warning(warning_msg)
401
+
402
+ @staticmethod
403
+ def validate_and_proceed(
404
+ session_state: Dict[str, Any],
405
+ validation_function: Callable[[Dict[str, Any]], Tuple[bool, List[str]]],
406
+ data_key: str,
407
+ success_message: str = "Validation successful!",
408
+ proceed_callback: Optional[Callable] = None
409
+ ) -> bool:
410
+ """
411
+ Validate data and proceed if valid.
412
+
413
+ Args:
414
+ session_state: Streamlit session state
415
+ validation_function: Function to validate data
416
+ data_key: Key for data in session state
417
+ success_message: Message to display on success
418
+ proceed_callback: Optional callback function to execute if validation succeeds
419
+
420
+ Returns:
421
+ Boolean indicating whether validation succeeded
422
+ """
423
+ if data_key not in session_state:
424
+ st.error(f"No {data_key.replace('_', ' ').title()} data found.")
425
+ return False
426
+
427
+ # Validate data
428
+ is_valid, messages = validation_function(session_state[data_key])
429
+
430
+ # Display validation messages
431
+ DataValidation.display_validation_messages(messages)
432
+
433
+ # Proceed if valid
434
+ if is_valid:
435
+ st.success(success_message)
436
+
437
+ # Execute callback if provided
438
+ if proceed_callback is not None:
439
+ proceed_callback()
440
+
441
+ return True
442
+
443
+ return False
444
+
445
+
446
+ # Create a singleton instance
447
+ data_validation = DataValidation()
448
+
449
+ # Example usage
450
+ if __name__ == "__main__":
451
+ import streamlit as st
452
+
453
+ # Initialize session state with dummy data for testing
454
+ if "building_info" not in st.session_state:
455
+ st.session_state["building_info"] = {
456
+ "project_name": "Test Project",
457
+ "building_name": "Test Building",
458
+ "location": "New York",
459
+ "climate_zone": "4A",
460
+ "building_type": "Office",
461
+ "floor_area": 1000.0,
462
+ "num_floors": 2,
463
+ "floor_height": 3.0,
464
+ "orientation": "NORTH",
465
+ "occupancy": 50,
466
+ "operating_hours": "8:00-18:00",
467
+ "design_conditions": {
468
+ "summer_outdoor_db": 35.0,
469
+ "summer_outdoor_wb": 25.0,
470
+ "summer_indoor_db": 24.0,
471
+ "summer_indoor_rh": 50.0,
472
+ "winter_outdoor_db": -5.0,
473
+ "winter_outdoor_rh": 80.0,
474
+ "winter_indoor_db": 21.0,
475
+ "winter_indoor_rh": 40.0
476
+ }
477
+ }
478
+
479
+ # Test validation
480
+ st.header("Test Building Information Validation")
481
+
482
+ # Add some invalid data for testing
483
+ if st.button("Make Data Invalid"):
484
+ st.session_state["building_info"]["floor_area"] = -100.0
485
+ st.session_state["building_info"]["design_conditions"]["summer_outdoor_wb"] = 40.0
486
+
487
+ # Validate building info
488
+ if st.button("Validate Building Info"):
489
+ data_validation.validate_and_proceed(
490
+ st.session_state,
491
+ data_validation.validate_building_info,
492
+ "building_info",
493
+ "Building information is valid!"
494
+ )
app/main.py ADDED
@@ -0,0 +1,1205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Calculator Code Documentation
3
+ Updated 2025-04-27: Enhanced climate ID generation, input validation, debug mode, and error handling.
4
+ Updated 2025-04-28: Added activity-level-based internal gains, ground temperature validation, ASHRAE 62.1 ventilation rates, negative load prevention, and improved usability.
5
+ """
6
+
7
+ import streamlit as st
8
+ import pandas as pd
9
+ import numpy as np
10
+ import plotly.express as px
11
+ import json
12
+ import pycountry
13
+ import os
14
+ import sys
15
+ from typing import Dict, List, Any, Optional, Tuple
16
+
17
+ # Import application modules
18
+ from app.building_info_form import BuildingInfoForm
19
+ from app.component_selection import ComponentSelectionInterface, Orientation, ComponentType, Wall, Roof, Floor, Window, Door
20
+ from app.results_display import ResultsDisplay
21
+ from app.data_validation import DataValidation
22
+ from app.data_persistence import DataPersistence
23
+ from app.data_export import DataExport
24
+
25
+ # Import data modules
26
+ from data.reference_data import ReferenceData
27
+ from data.climate_data import ClimateData, ClimateLocation
28
+ from data.ashrae_tables import ASHRAETables
29
+ from data.building_components import Wall as WallModel, Roof as RoofModel
30
+
31
+ # Import utility modules
32
+ from utils.u_value_calculator import UValueCalculator
33
+ from utils.shading_system import ShadingSystem
34
+ from utils.area_calculation_system import AreaCalculationSystem
35
+ from utils.psychrometrics import Psychrometrics
36
+ from utils.heat_transfer import HeatTransferCalculations
37
+ from utils.cooling_load import CoolingLoadCalculator
38
+ from utils.heating_load import HeatingLoadCalculator
39
+ from utils.component_visualization import ComponentVisualization
40
+ from utils.scenario_comparison import ScenarioComparisonVisualization
41
+ from utils.psychrometric_visualization import PsychrometricVisualization
42
+ from utils.time_based_visualization import TimeBasedVisualization
43
+
44
+ # NEW: ASHRAE 62.1 Ventilation Rates (Table 6.1)
45
+ VENTILATION_RATES = {
46
+ "Office": {"people_rate": 2.5, "area_rate": 0.3}, # L/s/person, L/s/m²
47
+ "Classroom": {"people_rate": 5.0, "area_rate": 0.9},
48
+ "Retail": {"people_rate": 3.8, "area_rate": 0.9},
49
+ "Restaurant": {"people_rate": 5.0, "area_rate": 1.8},
50
+ "Custom": {"people_rate": 0.0, "area_rate": 0.0}
51
+ }
52
+
53
+ class HVACCalculator:
54
+ def __init__(self):
55
+ st.set_page_config(
56
+ page_title="HVAC Load Calculator",
57
+ page_icon="🌡️",
58
+ layout="wide",
59
+ initial_sidebar_state="expanded"
60
+ )
61
+
62
+ # Initialize session state
63
+ if 'page' not in st.session_state:
64
+ st.session_state.page = 'Building Information'
65
+
66
+ if 'building_info' not in st.session_state:
67
+ st.session_state.building_info = {"project_name": ""}
68
+
69
+ if 'components' not in st.session_state:
70
+ st.session_state.components = {
71
+ 'walls': [],
72
+ 'roofs': [],
73
+ 'floors': [],
74
+ 'windows': [],
75
+ 'doors': []
76
+ }
77
+
78
+ if 'internal_loads' not in st.session_state:
79
+ st.session_state.internal_loads = {
80
+ 'people': [],
81
+ 'lighting': [],
82
+ 'equipment': []
83
+ }
84
+
85
+ if 'calculation_results' not in st.session_state:
86
+ st.session_state.calculation_results = {
87
+ 'cooling': {},
88
+ 'heating': {}
89
+ }
90
+
91
+ if 'saved_scenarios' not in st.session_state:
92
+ st.session_state.saved_scenarios = {}
93
+
94
+ if 'climate_data' not in st.session_state:
95
+ st.session_state.climate_data = {}
96
+
97
+ if 'debug_mode' not in st.session_state:
98
+ st.session_state.debug_mode = False
99
+
100
+ # Initialize modules
101
+ self.building_info_form = BuildingInfoForm()
102
+ self.component_selection = ComponentSelectionInterface()
103
+ self.results_display = ResultsDisplay()
104
+ self.data_validation = DataValidation()
105
+ self.data_persistence = DataPersistence()
106
+ self.data_export = DataExport()
107
+ self.cooling_calculator = CoolingLoadCalculator()
108
+ self.heating_calculator = HeatingLoadCalculator()
109
+
110
+ # Persist ClimateData in session_state
111
+ if 'climate_data_obj' not in st.session_state:
112
+ st.session_state.climate_data_obj = ClimateData()
113
+ self.climate_data = st.session_state.climate_data_obj
114
+
115
+ # Load default climate data if locations are empty
116
+ try:
117
+ if not self.climate_data.locations:
118
+ self.climate_data = ClimateData.from_json("/home/user/app/climate_data.json")
119
+ st.session_state.climate_data_obj = self.climate_data
120
+ except FileNotFoundError:
121
+ st.warning("Default climate data file not found. Please enter climate data manually.")
122
+
123
+ self.setup_layout()
124
+
125
+ def setup_layout(self):
126
+ st.sidebar.title("HVAC Load Calculator")
127
+ st.sidebar.markdown("---")
128
+
129
+ st.sidebar.subheader("Navigation")
130
+ pages = [
131
+ "Building Information",
132
+ "Climate Data",
133
+ "Building Components",
134
+ "Internal Loads",
135
+ "Calculation Results",
136
+ "Export Data"
137
+ ]
138
+
139
+ selected_page = st.sidebar.radio("Go to", pages, index=pages.index(st.session_state.page))
140
+
141
+ if selected_page != st.session_state.page:
142
+ st.session_state.page = selected_page
143
+
144
+ self.display_page(st.session_state.page)
145
+
146
+ st.sidebar.markdown("---")
147
+ st.sidebar.info(
148
+ "HVAC Load Calculator v1.0.1\n\n"
149
+ "Based on ASHRAE steady-state calculation methods\n\n"
150
+ "Developed by: Dr Majed Abuseif\n\n"
151
+ "School of Architecture and Built Environment\n\n"
152
+ "Deakin University\n\n"
153
+ "© 2025"
154
+ )
155
+
156
+ def display_page(self, page: str):
157
+ if page == "Building Information":
158
+ self.building_info_form.display_building_info_form(st.session_state)
159
+ elif page == "Climate Data":
160
+ self.climate_data.display_climate_input(st.session_state)
161
+ elif page == "Building Components":
162
+ self.component_selection.display_component_selection(st.session_state)
163
+ elif page == "Internal Loads":
164
+ self.display_internal_loads()
165
+ elif page == "Calculation Results":
166
+ self.display_calculation_results()
167
+ elif page == "Export Data":
168
+ self.data_export.display()
169
+
170
+ def generate_climate_id(self, country: str, city: str) -> str:
171
+ """Generate a climate ID from country and city names."""
172
+ try:
173
+ country = country.strip().title()
174
+ city = city.strip().title()
175
+ if len(country) < 2 or len(city) < 3:
176
+ raise ValueError("Country and city names must be at least 2 and 3 characters long, respectively.")
177
+ return f"{country[:2].upper()}-{city[:3].upper()}"
178
+ except Exception as e:
179
+ raise ValueError(f"Invalid country or city name: {str(e)}")
180
+
181
+ def validate_calculation_inputs(self) -> Tuple[bool, str]:
182
+ """Validate inputs for cooling and heating calculations."""
183
+ building_info = st.session_state.get('building_info', {})
184
+ components = st.session_state.get('components', {})
185
+ climate_data = st.session_state.get('climate_data', {})
186
+
187
+ # Check building info
188
+ if not building_info.get('floor_area', 0) > 0:
189
+ return False, "Floor area must be positive."
190
+ if not any(components.get(key, []) for key in ['walls', 'roofs', 'windows']):
191
+ return False, "At least one wall, roof, or window must be defined."
192
+
193
+ # NEW: Validate climate data using climate_data.py
194
+ if not climate_data:
195
+ return False, "Climate data is missing."
196
+ if not self.climate_data.validate_climate_data(climate_data):
197
+ return False, "Invalid climate data format or values."
198
+
199
+ # Validate components
200
+ for component_type in ['walls', 'roofs', 'windows', 'doors', 'floors']:
201
+ for comp in components.get(component_type, []):
202
+ if comp.area <= 0:
203
+ return False, f"Invalid area for {component_type}: {comp.name}"
204
+ if comp.u_value <= 0:
205
+ return False, f"Invalid U-value for {component_type}: {comp.name}"
206
+ # NEW: Validate ground temperature for floors
207
+ if component_type == 'floors' and getattr(comp, 'ground_contact', False):
208
+ if not -10 <= comp.ground_temperature_c <= 40:
209
+ return False, f"Ground temperature for {comp.name} must be between -10°C and 40°C"
210
+ # NEW: Validate perimeter
211
+ if getattr(comp, 'perimeter', 0) < 0:
212
+ return False, f"Perimeter for {comp.name} cannot be negative"
213
+
214
+ # NEW: Validate ventilation rate
215
+ if building_info.get('ventilation_rate', 0) < 0:
216
+ return False, "Ventilation rate cannot be negative"
217
+ if building_info.get('zone_type', '') == 'Custom' and building_info.get('ventilation_rate', 0) == 0:
218
+ return False, "Custom ventilation rate must be specified"
219
+
220
+ return True, "Inputs valid."
221
+
222
+ def validate_internal_load(self, load_type: str, new_load: Dict) -> Tuple[bool, str]:
223
+ """Validate if a new internal load is unique and within limits."""
224
+ loads = st.session_state.internal_loads.get(load_type, [])
225
+ max_loads = 50
226
+
227
+ if len(loads) >= max_loads:
228
+ return False, f"Maximum of {max_loads} {load_type} loads reached."
229
+
230
+ # Check for duplicates based on key attributes
231
+ for existing_load in loads:
232
+ if load_type == 'people':
233
+ if (existing_load['name'] == new_load['name'] and
234
+ existing_load['num_people'] == new_load['num_people'] and
235
+ existing_load['activity_level'] == new_load['activity_level'] and
236
+ existing_load['zone_type'] == new_load['zone_type'] and
237
+ existing_load['hours_in_operation'] == new_load['hours_in_operation']):
238
+ return False, f"Duplicate people load '{new_load['name']}' already exists."
239
+ elif load_type == 'lighting':
240
+ if (existing_load['name'] == new_load['name'] and
241
+ existing_load['power'] == new_load['power'] and
242
+ existing_load['usage_factor'] == new_load['usage_factor'] and
243
+ existing_load['zone_type'] == new_load['zone_type'] and
244
+ existing_load['hours_in_operation'] == new_load['hours_in_operation']):
245
+ return False, f"Duplicate lighting load '{new_load['name']}' already exists."
246
+ elif load_type == 'equipment':
247
+ if (existing_load['name'] == new_load['name'] and
248
+ existing_load['power'] == new_load['power'] and
249
+ existing_load['usage_factor'] == new_load['usage_factor'] and
250
+ existing_load['radiation_fraction'] == new_load['radiation_fraction'] and
251
+ existing_load['zone_type'] == new_load['zone_type'] and
252
+ existing_load['hours_in_operation'] == new_load['hours_in_operation']):
253
+ return False, f"Duplicate equipment load '{new_load['name']}' already exists."
254
+
255
+ return True, "Valid load."
256
+
257
+ def display_internal_loads(self):
258
+ st.title("Internal Loads")
259
+
260
+ # Reset button for all internal loads
261
+ if st.button("Reset All Internal Loads"):
262
+ st.session_state.internal_loads = {'people': [], 'lighting': [], 'equipment': []}
263
+ st.success("All internal loads reset!")
264
+ st.rerun()
265
+
266
+ tabs = st.tabs(["People", "Lighting", "Equipment", "Ventilation"]) # NEW: Added Ventilation tab
267
+
268
+ with tabs[0]:
269
+ st.subheader("People")
270
+ with st.form("people_form"):
271
+ num_people = st.number_input(
272
+ "Number of People",
273
+ min_value=0,
274
+ value=0,
275
+ step=1,
276
+ help="Total number of occupants in the building"
277
+ )
278
+ activity_level = st.selectbox(
279
+ "Activity Level",
280
+ ["Seated/Resting", "Light Work", "Moderate Work", "Heavy Work"],
281
+ help="Select typical activity level (affects internal heat gains per ASHRAE)"
282
+ )
283
+ zone_type = st.selectbox(
284
+ "Zone Type",
285
+ ["Office", "Classroom", "Retail", "Residential"],
286
+ help="Select zone type for occupancy characteristics"
287
+ )
288
+ hours_in_operation = st.number_input(
289
+ "Hours in Operation",
290
+ min_value=0.0,
291
+ max_value=24.0,
292
+ value=8.0,
293
+ step=0.5,
294
+ help="Daily hours of occupancy"
295
+ )
296
+ people_name = st.text_input("Name", value="Occupants")
297
+
298
+ if st.form_submit_button("Add People Load"):
299
+ people_load = {
300
+ "id": f"people_{len(st.session_state.internal_loads['people'])}",
301
+ "name": people_name,
302
+ "num_people": num_people,
303
+ "activity_level": activity_level,
304
+ "zone_type": zone_type,
305
+ "hours_in_operation": hours_in_operation
306
+ }
307
+ is_valid, message = self.validate_internal_load('people', people_load)
308
+ if is_valid:
309
+ st.session_state.internal_loads['people'].append(people_load)
310
+ st.success("People load added!")
311
+ st.rerun()
312
+ else:
313
+ st.error(message)
314
+
315
+ if st.session_state.internal_loads['people']:
316
+ people_df = pd.DataFrame(st.session_state.internal_loads['people'])
317
+ st.dataframe(people_df, use_container_width=True)
318
+
319
+ selected_people = st.multiselect(
320
+ "Select People Loads to Delete",
321
+ [load['id'] for load in st.session_state.internal_loads['people']]
322
+ )
323
+ if st.button("Delete Selected People Loads"):
324
+ st.session_state.internal_loads['people'] = [
325
+ load for load in st.session_state.internal_loads['people']
326
+ if load['id'] not in selected_people
327
+ ]
328
+ st.success("Selected people loads deleted!")
329
+ st.rerun()
330
+
331
+ with tabs[1]:
332
+ st.subheader("Lighting")
333
+ with st.form("lighting_form"):
334
+ power = st.number_input(
335
+ "Power (W)",
336
+ min_value=0.0,
337
+ value=1000.0,
338
+ step=100.0,
339
+ help="Total lighting power consumption"
340
+ )
341
+ usage_factor = st.number_input(
342
+ "Usage Factor",
343
+ min_value=0.0,
344
+ max_value=1.0,
345
+ value=0.8,
346
+ step=0.1,
347
+ help="Fraction of time lighting is in use (0 to 1)"
348
+ )
349
+ zone_type = st.selectbox(
350
+ "Zone Type",
351
+ ["Office", "Classroom", "Retail", "Residential"],
352
+ help="Select zone type for lighting characteristics"
353
+ )
354
+ hours_in_operation = st.number_input(
355
+ "Hours in Operation",
356
+ min_value=0.0,
357
+ max_value=24.0,
358
+ value=8.0,
359
+ step=0.5,
360
+ help="Daily hours of lighting operation"
361
+ )
362
+ lighting_name = st.text_input("Name", value="General Lighting")
363
+
364
+ if st.form_submit_button("Add Lighting Load"):
365
+ lighting_load = {
366
+ "id": f"lighting_{len(st.session_state.internal_loads['lighting'])}",
367
+ "name": lighting_name,
368
+ "power": power,
369
+ "usage_factor": usage_factor,
370
+ "zone_type": zone_type,
371
+ "hours_in_operation": hours_in_operation
372
+ }
373
+ is_valid, message = self.validate_internal_load('lighting', lighting_load)
374
+ if is_valid:
375
+ st.session_state.internal_loads['lighting'].append(lighting_load)
376
+ st.success("Lighting load added!")
377
+ st.rerun()
378
+ else:
379
+ st.error(message)
380
+
381
+ if st.session_state.internal_loads['lighting']:
382
+ lighting_df = pd.DataFrame(st.session_state.internal_loads['lighting'])
383
+ st.dataframe(lighting_df, use_container_width=True)
384
+
385
+ selected_lighting = st.multiselect(
386
+ "Select Lighting Loads to Delete",
387
+ [load['id'] for load in st.session_state.internal_loads['lighting']]
388
+ )
389
+ if st.button("Delete Selected Lighting Loads"):
390
+ st.session_state.internal_loads['lighting'] = [
391
+ load for load in st.session_state.internal_loads['lighting']
392
+ if load['id'] not in selected_lighting
393
+ ]
394
+ st.success("Selected lighting loads deleted!")
395
+ st.rerun()
396
+
397
+ with tabs[2]:
398
+ st.subheader("Equipment")
399
+ with st.form("equipment_form"):
400
+ power = st.number_input(
401
+ "Power (W)",
402
+ min_value=0.0,
403
+ value=500.0,
404
+ step=100.0,
405
+ help="Total equipment power consumption"
406
+ )
407
+ usage_factor = st.number_input(
408
+ "Usage Factor",
409
+ min_value=0.0,
410
+ max_value=1.0,
411
+ value=0.7,
412
+ step=0.1,
413
+ help="Fraction of time equipment is in use (0 to 1)"
414
+ )
415
+ radiation_fraction = st.number_input(
416
+ "Radiation Fraction",
417
+ min_value=0.0,
418
+ max_value=1.0,
419
+ value=0.3,
420
+ step=0.1,
421
+ help="Fraction of heat gain radiated to surroundings"
422
+ )
423
+ zone_type = st.selectbox(
424
+ "Zone Type",
425
+ ["Office", "Classroom", "Retail", "Residential"],
426
+ help="Select zone type for equipment characteristics"
427
+ )
428
+ hours_in_operation = st.number_input(
429
+ "Hours in Operation",
430
+ min_value=0.0,
431
+ max_value=24.0,
432
+ value=8.0,
433
+ step=0.5,
434
+ help="Daily hours of equipment operation"
435
+ )
436
+ equipment_name = st.text_input("Name", value="Office Equipment")
437
+
438
+ if st.form_submit_button("Add Equipment Load"):
439
+ equipment_load = {
440
+ "id": f"equipment_{len(st.session_state.internal_loads['equipment'])}",
441
+ "name": equipment_name,
442
+ "power": power,
443
+ "usage_factor": usage_factor,
444
+ "radiation_fraction": radiation_fraction,
445
+ "zone_type": zone_type,
446
+ "hours_in_operation": hours_in_operation
447
+ }
448
+ is_valid, message = self.validate_internal_load('equipment', equipment_load)
449
+ if is_valid:
450
+ st.session_state.internal_loads['equipment'].append(equipment_load)
451
+ st.success("Equipment load added!")
452
+ st.rerun()
453
+ else:
454
+ st.error(message)
455
+
456
+ if st.session_state.internal_loads['equipment']:
457
+ equipment_df = pd.DataFrame(st.session_state.internal_loads['equipment'])
458
+ st.dataframe(equipment_df, use_container_width=True)
459
+
460
+ selected_equipment = st.multiselect(
461
+ "Select Equipment Loads to Delete",
462
+ [load['id'] for load in st.session_state.internal_loads['equipment']]
463
+ )
464
+ if st.button("Delete Selected Equipment Loads"):
465
+ st.session_state.internal_loads['equipment'] = [
466
+ load for load in st.session_state.internal_loads['equipment']
467
+ if load['id'] not in selected_equipment
468
+ ]
469
+ st.success("Selected equipment loads deleted!")
470
+ st.rerun()
471
+
472
+ with tabs[3]: # NEW: Ventilation tab
473
+ st.subheader("Ventilation Requirements (ASHRAE 62.1)")
474
+ with st.form("ventilation_form"):
475
+ col1, col2 = st.columns(2)
476
+ with col1:
477
+ zone_type = st.selectbox(
478
+ "Zone Type",
479
+ ["Office", "Classroom", "Retail", "Restaurant", "Custom"],
480
+ help="Select building zone type for ASHRAE 62.1 ventilation rates"
481
+ )
482
+ ventilation_method = st.selectbox(
483
+ "Ventilation Method",
484
+ ["Constant Volume", "Demand-Controlled"],
485
+ help="Constant Volume uses fixed rate; Demand-Controlled adjusts based on occupancy"
486
+ )
487
+ with col2:
488
+ if zone_type == "Custom":
489
+ people_rate = st.number_input(
490
+ "Ventilation Rate per Person (L/s/person)",
491
+ min_value=0.0,
492
+ value=2.5,
493
+ step=0.1,
494
+ help="Custom ventilation rate per person (ASHRAE 62.1)"
495
+ )
496
+ area_rate = st.number_input(
497
+ "Ventilation Rate per Area (L/s/m²)",
498
+ min_value=0.0,
499
+ value=0.3,
500
+ step=0.1,
501
+ help="Custom ventilation rate per floor area (ASHRAE 62.1)"
502
+ )
503
+ else:
504
+ people_rate = VENTILATION_RATES[zone_type]["people_rate"]
505
+ area_rate = VENTILATION_RATES[zone_type]["area_rate"]
506
+ st.write(f"People Rate: {people_rate} L/s/person (ASHRAE 62.1)")
507
+ st.write(f"Area Rate: {area_rate} L/s/m² (ASHRAE 62.1)")
508
+
509
+ if st.form_submit_button("Save Ventilation Settings"):
510
+ total_people = sum(load['num_people'] for load in st.session_state.internal_loads.get('people', []))
511
+ floor_area = st.session_state.building_info.get('floor_area', 100.0)
512
+ ventilation_rate = (
513
+ (total_people * people_rate + floor_area * area_rate) / 1000 # Convert L/s to m³/s
514
+ )
515
+ if ventilation_method == 'Demand-Controlled':
516
+ ventilation_rate *= 0.75 # Reduce by 25% for DCV
517
+ st.session_state.building_info.update({
518
+ 'zone_type': zone_type,
519
+ 'ventilation_method': ventilation_method,
520
+ 'ventilation_rate': ventilation_rate
521
+ })
522
+ st.success(f"Ventilation settings saved! Total rate: {ventilation_rate:.3f} m³/s")
523
+
524
+ col1, col2 = st.columns(2)
525
+ with col1:
526
+ st.button(
527
+ "Back to Building Components",
528
+ on_click=lambda: setattr(st.session_state, "page", "Building Components")
529
+ )
530
+ with col2:
531
+ st.button(
532
+ "Continue to Calculation Results",
533
+ on_click=lambda: setattr(st.session_state, "page", "Calculation Results")
534
+ )
535
+
536
+ def calculate_cooling(self) -> Tuple[bool, str, Dict]:
537
+ """
538
+ Calculate cooling loads using CoolingLoadCalculator.
539
+ Returns: (success, message, results)
540
+ """
541
+ try:
542
+ # Validate inputs
543
+ valid, message = self.validate_calculation_inputs()
544
+ if not valid:
545
+ return False, message, {}
546
+
547
+ # Gather inputs
548
+ building_components = st.session_state.get('components', {})
549
+ internal_loads = st.session_state.get('internal_loads', {})
550
+ building_info = st.session_state.get('building_info', {})
551
+
552
+ # Check climate data
553
+ if "climate_data" not in st.session_state or not st.session_state["climate_data"]:
554
+ return False, "Please enter climate data in the 'Climate Data' page.", {}
555
+
556
+ # Extract climate data
557
+ country = building_info.get('country', '').strip().title()
558
+ city = building_info.get('city', '').strip().title()
559
+ if not country or not city:
560
+ return False, "Country and city must be set in Building Information.", {}
561
+ climate_id = self.generate_climate_id(country, city)
562
+ location = self.climate_data.get_location_by_id(climate_id, st.session_state)
563
+ if not location:
564
+ available_locations = list(self.climate_data.locations.keys())[:5]
565
+ return False, f"No climate data for {climate_id}. Available locations: {', '.join(available_locations)}...", {}
566
+
567
+ # Validate climate data
568
+ if not all(k in location for k in ['summer_design_temp_db', 'summer_design_temp_wb', 'monthly_temps', 'latitude']):
569
+ return False, f"Invalid climate data for {climate_id}. Missing required fields.", {}
570
+
571
+ # Format conditions
572
+ outdoor_conditions = {
573
+ 'temperature': location['summer_design_temp_db'],
574
+ 'relative_humidity': location['monthly_humidity'].get('Jul', 50.0),
575
+ 'ground_temperature': location['monthly_temps'].get('Jul', 20.0),
576
+ 'month': 'Jul',
577
+ 'latitude': location['latitude'], # Pass raw latitude value, validation will happen in cooling_load.py
578
+ 'wind_speed': building_info.get('wind_speed', 4.0),
579
+ 'day_of_year': 204 # Approx. July 23
580
+ }
581
+ indoor_conditions = {
582
+ 'temperature': building_info.get('indoor_temp', 24.0),
583
+ 'relative_humidity': building_info.get('indoor_rh', 50.0)
584
+ }
585
+
586
+ if st.session_state.get('debug_mode', False):
587
+ st.write("Debug: Cooling Input State", {
588
+ 'climate_id': climate_id,
589
+ 'outdoor_conditions': outdoor_conditions,
590
+ 'indoor_conditions': indoor_conditions,
591
+ 'components': {k: len(v) for k, v in building_components.items()},
592
+ 'internal_loads': {
593
+ 'people': len(internal_loads.get('people', [])),
594
+ 'lighting': len(internal_loads.get('lighting', [])),
595
+ 'equipment': len(internal_loads.get('equipment', []))
596
+ },
597
+ 'building_info': building_info
598
+ })
599
+
600
+ # Format internal loads
601
+ formatted_internal_loads = {
602
+ 'people': {
603
+ 'number': sum(load['num_people'] for load in internal_loads.get('people', [])),
604
+ 'activity_level': internal_loads.get('people', [{}])[0].get('activity_level', 'Seated/Resting'),
605
+ 'operating_hours': f"{internal_loads.get('people', [{}])[0].get('hours_in_operation', 8)}:00-{internal_loads.get('people', [{}])[0].get('hours_in_operation', 8)+10}:00"
606
+ },
607
+ 'lights': {
608
+ 'power': sum(load['power'] for load in internal_loads.get('lighting', [])),
609
+ 'use_factor': internal_loads.get('lighting', [{}])[0].get('usage_factor', 0.8),
610
+ 'special_allowance': 0.1,
611
+ 'hours_operation': f"{internal_loads.get('lighting', [{}])[0].get('hours_in_operation', 8)}h"
612
+ },
613
+ 'equipment': {
614
+ 'power': sum(load['power'] for load in internal_loads.get('equipment', [])),
615
+ 'use_factor': internal_loads.get('equipment', [{}])[0].get('usage_factor', 0.7),
616
+ 'radiation_factor': internal_loads.get('equipment', [{}])[0].get('radiation_fraction', 0.3),
617
+ 'hours_operation': f"{internal_loads.get('equipment', [{}])[0].get('hours_in_operation', 8)}h"
618
+ },
619
+ 'infiltration': {
620
+ 'flow_rate': building_info.get('infiltration_rate', 0.05),
621
+ 'height': building_info.get('building_height', 3.0),
622
+ 'crack_length': building_info.get('crack_length', 10.0)
623
+ },
624
+ 'ventilation': {
625
+ 'flow_rate': building_info.get('ventilation_rate', 0.1)
626
+ },
627
+ 'operating_hours': building_info.get('operating_hours', '8:00-18:00')
628
+ }
629
+
630
+ # Calculate hourly loads
631
+ hourly_loads = self.cooling_calculator.calculate_hourly_cooling_loads(
632
+ building_components=building_components,
633
+ outdoor_conditions=outdoor_conditions,
634
+ indoor_conditions=indoor_conditions,
635
+ internal_loads=formatted_internal_loads,
636
+ building_volume=building_info.get('floor_area', 100.0) * building_info.get('building_height', 3.0)
637
+ )
638
+ if not hourly_loads:
639
+ return False, "Cooling hourly loads calculation failed. Check input data.", {}
640
+
641
+ # Get design loads
642
+ design_loads = self.cooling_calculator.calculate_design_cooling_load(hourly_loads)
643
+ if not design_loads:
644
+ return False, "Cooling design loads calculation failed. Check input data.", {}
645
+
646
+ # Get summary
647
+ summary = self.cooling_calculator.calculate_cooling_load_summary(design_loads)
648
+ if not summary:
649
+ return False, "Cooling load summary calculation failed. Check input data.", {}
650
+
651
+ # Ensure summary has all required keys
652
+ if 'total' not in summary:
653
+ # Calculate total if missing
654
+ if 'total_sensible' in summary and 'total_latent' in summary:
655
+ summary['total'] = summary['total_sensible'] + summary['total_latent']
656
+ else:
657
+ # Fallback to sum of design loads if needed
658
+ total_load = sum(value for key, value in design_loads.items() if key != 'design_hour')
659
+ summary = {
660
+ 'total_sensible': total_load * 0.7, # Approximate sensible ratio
661
+ 'total_latent': total_load * 0.3, # Approximate latent ratio
662
+ 'total': total_load
663
+ }
664
+
665
+ # Format results for results_display.py
666
+ floor_area = building_info.get('floor_area', 100.0) or 100.0
667
+ results = {
668
+ 'total_load': summary['total'] / 1000, # kW
669
+ 'sensible_load': summary['total_sensible'] / 1000, # kW
670
+ 'latent_load': summary['total_latent'] / 1000, # kW
671
+ 'load_per_area': summary['total'] / floor_area, # W/m²
672
+ 'component_loads': {
673
+ 'walls': design_loads['walls'] / 1000,
674
+ 'roof': design_loads['roofs'] / 1000,
675
+ 'windows': (design_loads['windows_conduction'] + design_loads['windows_solar']) / 1000,
676
+ 'doors': design_loads['doors'] / 1000,
677
+ 'people': (design_loads['people_sensible'] + design_loads['people_latent']) / 1000,
678
+ 'lighting': design_loads['lights'] / 1000,
679
+ 'equipment': (design_loads['equipment_sensible'] + design_loads['equipment_latent']) / 1000,
680
+ 'infiltration': (design_loads['infiltration_sensible'] + design_loads['infiltration_latent']) / 1000,
681
+ 'ventilation': (design_loads['ventilation_sensible'] + design_loads['ventilation_latent']) / 1000
682
+ },
683
+ 'detailed_loads': {
684
+ 'walls': [],
685
+ 'roofs': [],
686
+ 'windows': [],
687
+ 'doors': [],
688
+ 'internal': [],
689
+ 'infiltration': {
690
+ 'air_flow': formatted_internal_loads['infiltration']['flow_rate'],
691
+ 'sensible_load': design_loads['infiltration_sensible'] / 1000,
692
+ 'latent_load': design_loads['infiltration_latent'] / 1000,
693
+ 'total_load': (design_loads['infiltration_sensible'] + design_loads['infiltration_latent']) / 1000
694
+ },
695
+ 'ventilation': {
696
+ 'air_flow': formatted_internal_loads['ventilation']['flow_rate'],
697
+ 'sensible_load': design_loads['ventilation_sensible'] / 1000,
698
+ 'latent_load': design_loads['infiltration_latent'] / 1000,
699
+ 'total_load': (design_loads['ventilation_sensible'] + design_loads['ventilation_latent']) / 1000
700
+ }
701
+ },
702
+ 'building_info': building_info
703
+ }
704
+
705
+ # Populate detailed loads
706
+ for wall in building_components.get('walls', []):
707
+ load = self.cooling_calculator.calculate_wall_cooling_load(
708
+ wall=wall,
709
+ outdoor_temp=outdoor_conditions['temperature'],
710
+ indoor_temp=indoor_conditions['temperature'],
711
+ month=outdoor_conditions['month'],
712
+ hour=design_loads['design_hour'],
713
+ latitude=outdoor_conditions['latitude']
714
+ )
715
+ results['detailed_loads']['walls'].append({
716
+ 'name': wall.name,
717
+ 'orientation': wall.orientation.value,
718
+ 'area': wall.area,
719
+ 'u_value': wall.u_value,
720
+ 'cltd': self.cooling_calculator.ashrae_tables.calculate_corrected_cltd_wall(
721
+ wall_group=wall.wall_group,
722
+ orientation=wall.orientation.value,
723
+ hour=design_loads['design_hour'],
724
+ color='Dark',
725
+ month=outdoor_conditions['month'],
726
+ latitude=outdoor_conditions['latitude'],
727
+ indoor_temp=indoor_conditions['temperature'],
728
+ outdoor_temp=outdoor_conditions['temperature']
729
+ ),
730
+ 'load': load / 1000
731
+ })
732
+
733
+ for roof in building_components.get('roofs', []):
734
+ load = self.cooling_calculator.calculate_roof_cooling_load(
735
+ roof=roof,
736
+ outdoor_temp=outdoor_conditions['temperature'],
737
+ indoor_temp=indoor_conditions['temperature'],
738
+ month=outdoor_conditions['month'],
739
+ hour=design_loads['design_hour'],
740
+ latitude=outdoor_conditions['latitude']
741
+ )
742
+ results['detailed_loads']['roofs'].append({
743
+ 'name': roof.name,
744
+ 'orientation': roof.orientation.value,
745
+ 'area': roof.area,
746
+ 'u_value': roof.u_value,
747
+ 'cltd': self.cooling_calculator.ashrae_tables.calculate_corrected_cltd_roof(
748
+ roof_group=roof.roof_group,
749
+ hour=design_loads['design_hour'],
750
+ color='Dark',
751
+ month=outdoor_conditions['month'],
752
+ latitude=outdoor_conditions['latitude'],
753
+ indoor_temp=indoor_conditions['temperature'],
754
+ outdoor_temp=outdoor_conditions['temperature']
755
+ ),
756
+ 'load': load / 1000
757
+ })
758
+
759
+ for window in building_components.get('windows', []):
760
+ load_dict = self.cooling_calculator.calculate_window_cooling_load(
761
+ window=window,
762
+ outdoor_temp=outdoor_conditions['temperature'],
763
+ indoor_temp=indoor_conditions['temperature'],
764
+ month=outdoor_conditions['month'],
765
+ hour=design_loads['design_hour'],
766
+ latitude=outdoor_conditions['latitude'],
767
+ shading_coefficient=window.shading_coefficient
768
+ )
769
+ # Ensure load_dict has a 'total' key
770
+ if 'total' not in load_dict:
771
+ if 'conduction' in load_dict and 'solar' in load_dict:
772
+ load_dict['total'] = load_dict['conduction'] + load_dict['solar']
773
+ else:
774
+ load_dict['total'] = window.u_value * window.area * (outdoor_conditions['temperature'] - indoor_conditions['temperature'])
775
+
776
+ # Pass latitude directly to get_scl method which has its own validation
777
+ results['detailed_loads']['windows'].append({
778
+ 'name': window.name,
779
+ 'orientation': window.orientation.value,
780
+ 'area': window.area,
781
+ 'u_value': window.u_value,
782
+ 'shgc': window.shgc,
783
+ 'shading_device': window.shading_device,
784
+ 'shading_coefficient': window.shading_coefficient,
785
+ 'scl': self.cooling_calculator.ashrae_tables.get_scl(
786
+ latitude=outdoor_conditions['latitude'],
787
+ month=outdoor_conditions['month'].title(),
788
+ orientation=window.orientation.value,
789
+ hour=design_loads['design_hour']
790
+ ),
791
+ 'load': load_dict['total'] / 1000
792
+ })
793
+
794
+ for door in building_components.get('doors', []):
795
+ load = self.cooling_calculator.calculate_door_cooling_load(
796
+ door=door,
797
+ outdoor_temp=outdoor_conditions['temperature'],
798
+ indoor_temp=indoor_conditions['temperature']
799
+ )
800
+ results['detailed_loads']['doors'].append({
801
+ 'name': door.name,
802
+ 'orientation': door.orientation.value,
803
+ 'area': door.area,
804
+ 'u_value': door.u_value,
805
+ 'cltd': outdoor_conditions['temperature'] - indoor_conditions['temperature'],
806
+ 'load': load / 1000
807
+ })
808
+
809
+ for load_type, key in [('people', 'people'), ('lighting', 'lights'), ('equipment', 'equipment')]:
810
+ for load in internal_loads.get(key, []):
811
+ if load_type == 'people':
812
+ load_dict = self.cooling_calculator.calculate_people_cooling_load(
813
+ num_people=load['num_people'],
814
+ activity_level=load['activity_level'],
815
+ hour=design_loads['design_hour']
816
+ )
817
+ # Ensure load_dict has a 'total' key
818
+ if 'total' not in load_dict and ('sensible' in load_dict or 'latent' in load_dict):
819
+ load_dict['total'] = load_dict.get('sensible', 0) + load_dict.get('latent', 0)
820
+ elif load_type == 'lighting':
821
+ light_load = self.cooling_calculator.calculate_lights_cooling_load(
822
+ power=load['power'],
823
+ use_factor=load['usage_factor'],
824
+ special_allowance=0.1,
825
+ hour=design_loads['design_hour']
826
+ )
827
+ load_dict = {'total': light_load if light_load is not None else 0}
828
+ else:
829
+ load_dict = self.cooling_calculator.calculate_equipment_cooling_load(
830
+ power=load['power'],
831
+ use_factor=load['usage_factor'],
832
+ radiation_factor=load['radiation_fraction'],
833
+ hour=design_loads['design_hour']
834
+ )
835
+ # Ensure load_dict has a 'total' key
836
+ if 'total' not in load_dict and ('sensible' in load_dict or 'latent' in load_dict):
837
+ load_dict['total'] = load_dict.get('sensible', 0) + load_dict.get('latent', 0)
838
+ results['detailed_loads']['internal'].append({
839
+ 'type': load_type.capitalize(),
840
+ 'name': load['name'],
841
+ 'quantity': load.get('num_people', load.get('power', 1)),
842
+ 'heat_gain': load_dict.get('sensible', load_dict.get('total', 0)),
843
+ 'clf': self.cooling_calculator.ashrae_tables.get_clf_people(
844
+ zone_type='A',
845
+ hours_occupied='6h', # Using valid '6h' instead of dynamic value that might not exist
846
+ hour=design_loads['design_hour']
847
+ ) if load_type == 'people' else 1.0,
848
+ 'load': load_dict.get('total', 0) / 1000
849
+ })
850
+
851
+ if st.session_state.get('debug_mode', False):
852
+ st.write("Debug: Cooling Results", {
853
+ 'total_load': results.get('total_load', 'N/A'),
854
+ 'component_loads': results.get('component_loads', 'N/A'),
855
+ 'detailed_loads': {k: len(v) if isinstance(v, list) else v for k, v in results.get('detailed_loads', {}).items()}
856
+ })
857
+
858
+ return True, "Cooling calculation completed.", results
859
+
860
+ except ValueError as ve:
861
+ st.error(f"Input error in cooling calculation: {str(ve)}")
862
+ return False, f"Input error: {str(ve)}", {}
863
+ except KeyError as ke:
864
+ st.error(f"Missing data in cooling calculation: {str(ke)}")
865
+ return False, f"Missing data: {str(ke)}", {}
866
+ except Exception as e:
867
+ st.error(f"Unexpected error in cooling calculation: {str(e)}")
868
+ return False, f"Unexpected error: {str(e)}", {}
869
+
870
+ def calculate_heating(self) -> Tuple[bool, str, Dict]:
871
+ """
872
+ Calculate heating loads using HeatingLoadCalculator.
873
+ Returns: (success, message, results)
874
+ """
875
+ try:
876
+ # Validate inputs
877
+ valid, message = self.validate_calculation_inputs()
878
+ if not valid:
879
+ return False, message, {}
880
+
881
+ # Gather inputs
882
+ building_components = st.session_state.get('components', {})
883
+ internal_loads = st.session_state.get('internal_loads', {})
884
+ building_info = st.session_state.get('building_info', {})
885
+
886
+ # Check climate data
887
+ if "climate_data" not in st.session_state or not st.session_state["climate_data"]:
888
+ return False, "Please enter climate data in the 'Climate Data' page.", {}
889
+
890
+ # Extract climate data
891
+ country = building_info.get('country', '').strip().title()
892
+ city = building_info.get('city', '').strip().title()
893
+ if not country or not city:
894
+ return False, "Country and city must be set in Building Information.", {}
895
+ climate_id = self.generate_climate_id(country, city)
896
+ location = self.climate_data.get_location_by_id(climate_id, st.session_state)
897
+ if not location:
898
+ available_locations = list(self.climate_data.locations.keys())[:5]
899
+ return False, f"No climate data for {climate_id}. Available locations: {', '.join(available_locations)}...", {}
900
+
901
+ # Validate climate data
902
+ if not all(k in location for k in ['winter_design_temp', 'monthly_temps', 'monthly_humidity']):
903
+ return False, f"Invalid climate data for {climate_id}. Missing required fields.", {}
904
+
905
+ # NEW: Calculate ground temperature from floors or fallback to climate data
906
+ ground_contact_floors = [f for f in building_components.get('floors', []) if getattr(f, 'ground_contact', False)]
907
+ ground_temperature = (
908
+ sum(f.ground_temperature_c for f in ground_contact_floors) / len(ground_contact_floors)
909
+ if ground_contact_floors else
910
+ location['monthly_temps'].get('Jan', 10.0)
911
+ )
912
+ if not -10 <= ground_temperature <= 40:
913
+ return False, f"Invalid ground temperature: {ground_temperature}°C", {}
914
+
915
+ # NEW: Skip heating calculation if outdoor temp exceeds indoor temp
916
+ indoor_temp = building_info.get('indoor_temp', 21.0)
917
+ outdoor_temp = location['winter_design_temp']
918
+ if outdoor_temp >= indoor_temp:
919
+ results = {
920
+ 'total_load': 0.0,
921
+ 'load_per_area': 0.0,
922
+ 'design_heat_loss': 0.0,
923
+ 'safety_factor': 115.0,
924
+ 'component_loads': {
925
+ 'walls': 0.0,
926
+ 'roof': 0.0,
927
+ 'floor': 0.0,
928
+ 'windows': 0.0,
929
+ 'doors': 0.0,
930
+ 'infiltration': 0.0,
931
+ 'ventilation': 0.0
932
+ },
933
+ 'detailed_loads': {
934
+ 'walls': [],
935
+ 'roofs': [],
936
+ 'floors': [],
937
+ 'windows': [],
938
+ 'doors': [],
939
+ 'infiltration': {'air_flow': 0.0, 'delta_t': 0.0, 'load': 0.0},
940
+ 'ventilation': {'air_flow': 0.0, 'delta_t': 0.0, 'load': 0.0}
941
+ },
942
+ 'building_info': building_info
943
+ }
944
+ return True, "No heating required (outdoor temp exceeds indoor temp).", results
945
+
946
+ # Format conditions
947
+ outdoor_conditions = {
948
+ 'design_temperature': location['winter_design_temp'],
949
+ 'design_relative_humidity': location['monthly_humidity'].get('Jan', 80.0),
950
+ 'ground_temperature': ground_temperature,
951
+ 'wind_speed': building_info.get('wind_speed', 4.0)
952
+ }
953
+ indoor_conditions = {
954
+ 'temperature': indoor_temp,
955
+ 'relative_humidity': building_info.get('indoor_rh', 40.0)
956
+ }
957
+
958
+ if st.session_state.get('debug_mode', False):
959
+ st.write("Debug: Heating Input State", {
960
+ 'climate_id': climate_id,
961
+ 'outdoor_conditions': outdoor_conditions,
962
+ 'indoor_conditions': indoor_conditions,
963
+ 'components': {k: len(v) for k, v in building_components.items()},
964
+ 'internal_loads': {
965
+ 'people': len(internal_loads.get('people', [])),
966
+ 'lighting': len(internal_loads.get('lighting', [])),
967
+ 'equipment': len(internal_loads.get('equipment', []))
968
+ },
969
+ 'building_info': building_info
970
+ })
971
+
972
+ # NEW: Activity-level-based sensible gains
973
+ ACTIVITY_GAINS = {
974
+ 'Seated/Resting': 70.0, # W/person
975
+ 'Light Work': 85.0,
976
+ 'Moderate Work': 100.0,
977
+ 'Heavy Work': 150.0
978
+ }
979
+
980
+ # Format internal loads
981
+ formatted_internal_loads = {
982
+ 'people': {
983
+ 'number': sum(load['num_people'] for load in internal_loads.get('people', [])),
984
+ 'sensible_gain': ACTIVITY_GAINS.get(
985
+ internal_loads.get('people', [{}])[0].get('activity_level', 'Seated/Resting'),
986
+ 70.0
987
+ ),
988
+ 'operating_hours': f"{internal_loads.get('people', [{}])[0].get('hours_in_operation', 8)}:00-{internal_loads.get('people', [{}])[0].get('hours_in_operation', 8)+10}:00"
989
+ },
990
+ 'lights': {
991
+ 'power': sum(load['power'] for load in internal_loads.get('lighting', [])),
992
+ 'use_factor': internal_loads.get('lighting', [{}])[0].get('usage_factor', 0.8),
993
+ 'hours_operation': f"{internal_loads.get('lighting', [{}])[0].get('hours_in_operation', 8)}h"
994
+ },
995
+ 'equipment': {
996
+ 'power': sum(load['power'] for load in internal_loads.get('equipment', [])),
997
+ 'use_factor': internal_loads.get('equipment', [{}])[0].get('usage_factor', 0.7),
998
+ 'hours_operation': f"{internal_loads.get('equipment', [{}])[0].get('hours_in_operation', 8)}h"
999
+ },
1000
+ 'infiltration': {
1001
+ 'flow_rate': building_info.get('infiltration_rate', 0.05),
1002
+ 'height': building_info.get('building_height', 3.0),
1003
+ 'crack_length': building_info.get('crack_length', 10.0)
1004
+ },
1005
+ 'ventilation': {
1006
+ 'flow_rate': building_info.get('ventilation_rate', 0.1)
1007
+ },
1008
+ 'usage_factor': 0.7,
1009
+ 'operating_hours': building_info.get('operating_hours', '8:00-18:00')
1010
+ }
1011
+
1012
+ # Calculate design loads
1013
+ design_loads = self.heating_calculator.calculate_design_heating_load(
1014
+ building_components=building_components,
1015
+ outdoor_conditions=outdoor_conditions,
1016
+ indoor_conditions=indoor_conditions,
1017
+ internal_loads=formatted_internal_loads
1018
+ )
1019
+ if not design_loads:
1020
+ return False, "Heating design loads calculation failed. Check input data.", {}
1021
+
1022
+ # Get summary
1023
+ summary = self.heating_calculator.calculate_heating_load_summary(design_loads)
1024
+ if not summary:
1025
+ return False, "Heating load summary calculation failed. Check input data.", {}
1026
+
1027
+ # Format results
1028
+ floor_area = building_info.get('floor_area', 100.0) or 100.0
1029
+ results = {
1030
+ 'total_load': summary['total'] / 1000, # kW
1031
+ 'load_per_area': summary['total'] / floor_area, # W/m²
1032
+ 'design_heat_loss': summary['subtotal'] / 1000, # kW
1033
+ 'safety_factor': summary['safety_factor'] * 100, # %
1034
+ 'component_loads': {
1035
+ 'walls': design_loads['walls'] / 1000,
1036
+ 'roof': design_loads['roofs'] / 1000,
1037
+ 'floor': design_loads['floors'] / 1000,
1038
+ 'windows': design_loads['windows'] / 1000,
1039
+ 'doors': design_loads['doors'] / 1000,
1040
+ 'infiltration': (design_loads['infiltration_sensible'] + design_loads['infiltration_latent']) / 1000,
1041
+ 'ventilation': (design_loads['ventilation_sensible'] + design_loads['ventilation_latent']) / 1000
1042
+ },
1043
+ 'detailed_loads': {
1044
+ 'walls': [],
1045
+ 'roofs': [],
1046
+ 'floors': [],
1047
+ 'windows': [],
1048
+ 'doors': [],
1049
+ 'infiltration': {
1050
+ 'air_flow': formatted_internal_loads['infiltration']['flow_rate'],
1051
+ 'delta_t': indoor_conditions['temperature'] - outdoor_conditions['design_temperature'],
1052
+ 'load': (design_loads['infiltration_sensible'] + design_loads['infiltration_latent']) / 1000
1053
+ },
1054
+ 'ventilation': {
1055
+ 'air_flow': formatted_internal_loads['ventilation']['flow_rate'],
1056
+ 'delta_t': indoor_conditions['temperature'] - outdoor_conditions['design_temperature'],
1057
+ 'load': (design_loads['ventilation_sensible'] + design_loads['ventilation_latent']) / 1000
1058
+ }
1059
+ },
1060
+ 'building_info': building_info
1061
+ }
1062
+
1063
+ # Populate detailed loads
1064
+ delta_t = indoor_conditions['temperature'] - outdoor_conditions['design_temperature']
1065
+ for wall in building_components.get('walls', []):
1066
+ load = self.heating_calculator.calculate_wall_heating_load(
1067
+ wall=wall,
1068
+ outdoor_temp=outdoor_conditions['design_temperature'],
1069
+ indoor_temp=indoor_conditions['temperature']
1070
+ )
1071
+ results['detailed_loads']['walls'].append({
1072
+ 'name': wall.name,
1073
+ 'orientation': wall.orientation.value,
1074
+ 'area': wall.area,
1075
+ 'u_value': wall.u_value,
1076
+ 'delta_t': delta_t,
1077
+ 'load': load / 1000
1078
+ })
1079
+
1080
+ for roof in building_components.get('roofs', []):
1081
+ load = self.heating_calculator.calculate_roof_heating_load(
1082
+ roof=roof,
1083
+ outdoor_temp=outdoor_conditions['design_temperature'],
1084
+ indoor_temp=indoor_conditions['temperature']
1085
+ )
1086
+ results['detailed_loads']['roofs'].append({
1087
+ 'name': roof.name,
1088
+ 'orientation': roof.orientation.value,
1089
+ 'area': roof.area,
1090
+ 'u_value': roof.u_value,
1091
+ 'delta_t': delta_t,
1092
+ 'load': load / 1000
1093
+ })
1094
+
1095
+ for floor in building_components.get('floors', []):
1096
+ load = self.heating_calculator.calculate_floor_heating_load(
1097
+ floor=floor,
1098
+ ground_temp=outdoor_conditions['ground_temperature'],
1099
+ indoor_temp=indoor_conditions['temperature']
1100
+ )
1101
+ results['detailed_loads']['floors'].append({
1102
+ 'name': floor.name,
1103
+ 'area': floor.area,
1104
+ 'u_value': floor.u_value,
1105
+ 'delta_t': indoor_conditions['temperature'] - outdoor_conditions['ground_temperature'],
1106
+ 'load': load / 1000
1107
+ })
1108
+
1109
+ for window in building_components.get('windows', []):
1110
+ load = self.heating_calculator.calculate_window_heating_load(
1111
+ window=window,
1112
+ outdoor_temp=outdoor_conditions['design_temperature'],
1113
+ indoor_temp=indoor_conditions['temperature']
1114
+ )
1115
+ results['detailed_loads']['windows'].append({
1116
+ 'name': window.name,
1117
+ 'orientation': window.orientation.value,
1118
+ 'area': window.area,
1119
+ 'u_value': window.u_value,
1120
+ 'delta_t': delta_t,
1121
+ 'load': load / 1000
1122
+ })
1123
+
1124
+ for door in building_components.get('doors', []):
1125
+ load = self.heating_calculator.calculate_door_heating_load(
1126
+ door=door,
1127
+ outdoor_temp=outdoor_conditions['design_temperature'],
1128
+ indoor_temp=indoor_conditions['temperature']
1129
+ )
1130
+ results['detailed_loads']['doors'].append({
1131
+ 'name': door.name,
1132
+ 'orientation': door.orientation.value,
1133
+ 'area': door.area,
1134
+ 'u_value': door.u_value,
1135
+ 'delta_t': delta_t,
1136
+ 'load': load / 1000
1137
+ })
1138
+
1139
+ if st.session_state.get('debug_mode', False):
1140
+ st.write("Debug: Heating Results", {
1141
+ 'total_load': results.get('total_load', 'N/A'),
1142
+ 'component_loads': results.get('component_loads', 'N/A'),
1143
+ 'detailed_loads': {k: len(v) if isinstance(v, list) else v for k, v in results.get('detailed_loads', {}).items()}
1144
+ })
1145
+
1146
+ return True, "Heating calculation completed.", results
1147
+
1148
+ except ValueError as ve:
1149
+ st.error(f"Input error in heating calculation: {str(ve)}")
1150
+ return False, f"Input error: {str(ve)}", {}
1151
+ except KeyError as ke:
1152
+ st.error(f"Missing data in heating calculation: {str(ke)}")
1153
+ return False, f"Missing data: {str(ke)}", {}
1154
+ except Exception as e:
1155
+ st.error(f"Unexpected error in heating calculation: {str(e)}")
1156
+ return False, f"Unexpected error: {str(e)}", {}
1157
+
1158
+ def display_calculation_results(self):
1159
+ st.title("Calculation Results")
1160
+
1161
+ col1, col2 = st.columns(2)
1162
+ with col1:
1163
+ calculate_button = st.button("Calculate Loads")
1164
+ with col2:
1165
+ st.session_state.debug_mode = st.checkbox("Debug Mode", value=st.session_state.get('debug_mode', False))
1166
+
1167
+ if calculate_button:
1168
+ # Reset results
1169
+ st.session_state.calculation_results = {'cooling': {}, 'heating': {}}
1170
+
1171
+ with st.spinner("Calculating loads..."):
1172
+ # Calculate cooling load
1173
+ cooling_success, cooling_message, cooling_results = self.calculate_cooling()
1174
+ if cooling_success:
1175
+ st.session_state.calculation_results['cooling'] = cooling_results
1176
+ st.success(cooling_message)
1177
+ else:
1178
+ st.error(cooling_message)
1179
+
1180
+ # Calculate heating load
1181
+ heating_success, heating_message, heating_results = self.calculate_heating()
1182
+ if heating_success:
1183
+ st.session_state.calculation_results['heating'] = heating_results
1184
+ st.success(heating_message)
1185
+ else:
1186
+ st.error(heating_message)
1187
+
1188
+ # Display results
1189
+ self.results_display.display_results(st.session_state)
1190
+
1191
+ # Navigation
1192
+ col1, col2 = st.columns(2)
1193
+ with col1:
1194
+ st.button(
1195
+ "Back to Internal Loads",
1196
+ on_click=lambda: setattr(st.session_state, "page", "Internal Loads")
1197
+ )
1198
+ with col2:
1199
+ st.button(
1200
+ "Continue to Export Data",
1201
+ on_click=lambda: setattr(st.session_state, "page", "Export Data")
1202
+ )
1203
+
1204
+ if __name__ == "__main__":
1205
+ app = HVACCalculator()
app/results_display.py ADDED
@@ -0,0 +1,674 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Results display module for HVAC Load Calculator.
3
+ This module provides the UI components for displaying calculation results.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Dict, List, Any, Optional, Tuple
10
+ import json
11
+ import os
12
+ import plotly.graph_objects as go
13
+ import plotly.express as px
14
+ from datetime import datetime
15
+
16
+ # Import visualization modules
17
+ from utils.component_visualization import ComponentVisualization
18
+ from utils.scenario_comparison import ScenarioComparisonVisualization
19
+ from utils.psychrometric_visualization import PsychrometricVisualization
20
+ from utils.time_based_visualization import TimeBasedVisualization
21
+
22
+
23
+ class ResultsDisplay:
24
+ """Class for results display interface."""
25
+
26
+ def __init__(self):
27
+ """Initialize results display interface."""
28
+ self.component_visualization = ComponentVisualization()
29
+ self.scenario_comparison = ScenarioComparisonVisualization()
30
+ self.psychrometric_visualization = PsychrometricVisualization()
31
+ self.time_based_visualization = TimeBasedVisualization()
32
+
33
+ def display_results(self, session_state: Dict[str, Any]) -> None:
34
+ """
35
+ Display calculation results in Streamlit.
36
+
37
+ Args:
38
+ session_state: Streamlit session state containing calculation results
39
+ """
40
+ st.header("Calculation Results")
41
+
42
+ # Check if calculations have been performed
43
+ if "calculation_results" not in session_state or not session_state["calculation_results"]:
44
+ st.warning("No calculation results available. Please run calculations first.")
45
+ return
46
+
47
+ # Create tabs for different result views
48
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
49
+ "Summary",
50
+ "Component Breakdown",
51
+ "Psychrometric Analysis",
52
+ "Time Analysis",
53
+ "Scenario Comparison"
54
+ ])
55
+
56
+ with tab1:
57
+ self._display_summary_results(session_state)
58
+
59
+ with tab2:
60
+ self._display_component_breakdown(session_state)
61
+
62
+ with tab3:
63
+ self._display_psychrometric_analysis(session_state)
64
+
65
+ with tab4:
66
+ self._display_time_analysis(session_state)
67
+
68
+ with tab5:
69
+ self._display_scenario_comparison(session_state)
70
+
71
+ def _display_summary_results(self, session_state: Dict[str, Any]) -> None:
72
+ """
73
+ Display summary of calculation results.
74
+
75
+ Args:
76
+ session_state: Streamlit session state containing calculation results
77
+ """
78
+ st.subheader("Summary Results")
79
+
80
+ results = session_state["calculation_results"]
81
+
82
+ # Display project information
83
+ if "building_info" in session_state:
84
+ st.write(f"**Project:** {session_state['building_info']['project_name']}")
85
+ st.write(f"**Building:** {session_state['building_info']['building_name']}")
86
+ location = f"{session_state['building_info']['city']}, {session_state['building_info']['country']}"
87
+ st.write(f"**Location:** {location}")
88
+ st.write(f"**Climate Zone:** {session_state['building_info'].get('climate_zone', 'N/A')}")
89
+ st.write(f"**Floor Area:** {session_state['building_info']['floor_area']} m²")
90
+
91
+ # Create columns for cooling and heating loads
92
+ col1, col2 = st.columns(2)
93
+
94
+ with col1:
95
+ st.write("### Cooling Load Results")
96
+
97
+ # Check if cooling results are available
98
+ if not results.get("cooling") or "total_load" not in results["cooling"]:
99
+ st.warning("Cooling load results are not available. Please check calculation inputs and try again.")
100
+ else:
101
+ # Display cooling load metrics
102
+ cooling_metrics = [
103
+ {"name": "Total Cooling Load", "value": results["cooling"]["total_load"], "unit": "kW"},
104
+ {"name": "Sensible Cooling Load", "value": results["cooling"]["sensible_load"], "unit": "kW"},
105
+ {"name": "Latent Cooling Load", "value": results["cooling"]["latent_load"], "unit": "kW"},
106
+ {"name": "Cooling Load per Area", "value": results["cooling"]["load_per_area"], "unit": "W/m²"}
107
+ ]
108
+
109
+ for metric in cooling_metrics:
110
+ st.metric(
111
+ label=metric["name"],
112
+ value=f"{metric['value']:.2f} {metric['unit']}"
113
+ )
114
+
115
+ # Display cooling load pie chart
116
+ cooling_breakdown = {
117
+ "Walls": results["cooling"]["component_loads"]["walls"],
118
+ "Roof": results["cooling"]["component_loads"]["roof"],
119
+ "Windows": results["cooling"]["component_loads"]["windows"],
120
+ "Doors": results["cooling"]["component_loads"]["doors"],
121
+ "People": results["cooling"]["component_loads"]["people"],
122
+ "Lighting": results["cooling"]["component_loads"]["lighting"],
123
+ "Equipment": results["cooling"]["component_loads"]["equipment"],
124
+ "Infiltration": results["cooling"]["component_loads"]["infiltration"],
125
+ "Ventilation": results["cooling"]["component_loads"]["ventilation"]
126
+ }
127
+
128
+ fig = px.pie(
129
+ values=list(cooling_breakdown.values()),
130
+ names=list(cooling_breakdown.keys()),
131
+ title="Cooling Load Breakdown",
132
+ color_discrete_sequence=px.colors.qualitative.Pastel
133
+ )
134
+
135
+ fig.update_traces(textposition='inside', textinfo='percent+label')
136
+ fig.update_layout(uniformtext_minsize=12, uniformtext_mode='hide')
137
+
138
+ st.plotly_chart(fig, use_container_width=True)
139
+
140
+ with col2:
141
+ st.write("### Heating Load Results")
142
+
143
+ # Check if heating results are available
144
+ if not results.get("heating") or "total_load" not in results["heating"]:
145
+ st.warning("Heating load results are not available. Please check calculation inputs and try again.")
146
+ else:
147
+ # Display heating load metrics
148
+ heating_metrics = [
149
+ {"name": "Total Heating Load", "value": results["heating"]["total_load"], "unit": "kW"},
150
+ {"name": "Heating Load per Area", "value": results["heating"]["load_per_area"], "unit": "W/m²"},
151
+ {"name": "Design Heat Loss", "value": results["heating"]["design_heat_loss"], "unit": "kW"},
152
+ {"name": "Safety Factor", "value": results["heating"]["safety_factor"], "unit": "%"}
153
+ ]
154
+
155
+ for metric in heating_metrics:
156
+ st.metric(
157
+ label=metric["name"],
158
+ value=f"{metric['value']:.2f} {metric['unit']}"
159
+ )
160
+
161
+ # Display heating load pie chart
162
+ heating_breakdown = {
163
+ "Walls": results["heating"]["component_loads"]["walls"],
164
+ "Roof": results["heating"]["component_loads"]["roof"],
165
+ "Floor": results["heating"]["component_loads"]["floor"],
166
+ "Windows": results["heating"]["component_loads"]["windows"],
167
+ "Doors": results["heating"]["component_loads"]["doors"],
168
+ "Infiltration": results["heating"]["component_loads"]["infiltration"],
169
+ "Ventilation": results["heating"]["component_loads"]["ventilation"]
170
+ }
171
+
172
+ fig = px.pie(
173
+ values=list(heating_breakdown.values()),
174
+ names=list(heating_breakdown.keys()),
175
+ title="Heating Load Breakdown",
176
+ color_discrete_sequence=px.colors.qualitative.Pastel
177
+ )
178
+
179
+ fig.update_traces(textposition='inside', textinfo='percent+label')
180
+ fig.update_layout(uniformtext_minsize=12, uniformtext_mode='hide')
181
+
182
+ st.plotly_chart(fig, use_container_width=True)
183
+
184
+ # Display tabular results
185
+ st.subheader("Detailed Results")
186
+
187
+ # Create tabs for cooling and heating tables
188
+ tab1, tab2 = st.tabs(["Cooling Load Details", "Heating Load Details"])
189
+
190
+ with tab1:
191
+ if not results.get("cooling") or "detailed_loads" not in results["cooling"]:
192
+ st.warning("Cooling load details are not available.")
193
+ else:
194
+ # Create cooling load details table
195
+ cooling_details = []
196
+
197
+ # Add envelope components
198
+ for wall in results["cooling"]["detailed_loads"]["walls"]:
199
+ cooling_details.append({
200
+ "Component Type": "Wall",
201
+ "Name": wall["name"],
202
+ "Orientation": wall["orientation"],
203
+ "Area (m²)": wall["area"],
204
+ "U-Value (W/m²·K)": wall["u_value"],
205
+ "CLTD (°C)": wall["cltd"],
206
+ "Load (kW)": wall["load"]
207
+ })
208
+
209
+ for roof in results["cooling"]["detailed_loads"]["roofs"]:
210
+ cooling_details.append({
211
+ "Component Type": "Roof",
212
+ "Name": roof["name"],
213
+ "Orientation": roof["orientation"],
214
+ "Area (m²)": roof["area"],
215
+ "U-Value (W/m²·K)": roof["u_value"],
216
+ "CLTD (°C)": roof["cltd"],
217
+ "Load (kW)": roof["load"]
218
+ })
219
+
220
+ for window in results["cooling"]["detailed_loads"]["windows"]:
221
+ cooling_details.append({
222
+ "Component Type": "Window",
223
+ "Name": window["name"],
224
+ "Orientation": window["orientation"],
225
+ "Area (m²)": window["area"],
226
+ "U-Value (W/m²·K)": window["u_value"],
227
+ "SHGC": window["shgc"],
228
+ "SCL (W/m²)": window["scl"],
229
+ "Load (kW)": window["load"]
230
+ })
231
+
232
+ for door in results["cooling"]["detailed_loads"]["doors"]:
233
+ cooling_details.append({
234
+ "Component Type": "Door",
235
+ "Name": door["name"],
236
+ "Orientation": door["orientation"],
237
+ "Area (m²)": door["area"],
238
+ "U-Value (W/m²·K)": door["u_value"],
239
+ "CLTD (°C)": door["cltd"],
240
+ "Load (kW)": door["load"]
241
+ })
242
+
243
+ # Add internal loads
244
+ for internal_load in results["cooling"]["detailed_loads"]["internal"]:
245
+ cooling_details.append({
246
+ "Component Type": internal_load["type"],
247
+ "Name": internal_load["name"],
248
+ "Quantity": internal_load["quantity"],
249
+ "Heat Gain (W)": internal_load["heat_gain"],
250
+ "CLF": internal_load["clf"],
251
+ "Load (kW)": internal_load["load"]
252
+ })
253
+
254
+ # Add infiltration and ventilation
255
+ cooling_details.append({
256
+ "Component Type": "Infiltration",
257
+ "Name": "Air Infiltration",
258
+ "Air Flow (m³/s)": results["cooling"]["detailed_loads"]["infiltration"]["air_flow"],
259
+ "Sensible Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["sensible_load"],
260
+ "Latent Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["latent_load"],
261
+ "Load (kW)": results["cooling"]["detailed_loads"]["infiltration"]["total_load"]
262
+ })
263
+
264
+ cooling_details.append({
265
+ "Component Type": "Ventilation",
266
+ "Name": "Fresh Air",
267
+ "Air Flow (m³/s)": results["cooling"]["detailed_loads"]["ventilation"]["air_flow"],
268
+ "Sensible Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["sensible_load"],
269
+ "Latent Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["latent_load"],
270
+ "Load (kW)": results["cooling"]["detailed_loads"]["ventilation"]["total_load"]
271
+ })
272
+
273
+ # Display cooling details table
274
+ cooling_df = pd.DataFrame(cooling_details)
275
+ st.dataframe(cooling_df, use_container_width=True)
276
+
277
+ with tab2:
278
+ if not results.get("heating") or "detailed_loads" not in results["heating"]:
279
+ st.warning("Heating load details are not available.")
280
+ else:
281
+ # Create heating load details table
282
+ heating_details = []
283
+
284
+ # Add envelope components
285
+ for wall in results["heating"]["detailed_loads"]["walls"]:
286
+ heating_details.append({
287
+ "Component Type": "Wall",
288
+ "Name": wall["name"],
289
+ "Orientation": wall["orientation"],
290
+ "Area (m²)": wall["area"],
291
+ "U-Value (W/m²·K)": wall["u_value"],
292
+ "Temperature Difference (°C)": wall["delta_t"],
293
+ "Load (kW)": wall["load"]
294
+ })
295
+
296
+ for roof in results["heating"]["detailed_loads"]["roofs"]:
297
+ heating_details.append({
298
+ "Component Type": "Roof",
299
+ "Name": roof["name"],
300
+ "Orientation": roof["orientation"],
301
+ "Area (m²)": roof["area"],
302
+ "U-Value (W/m²·K)": wall["u_value"],
303
+ "Temperature Difference (°C)": roof["delta_t"],
304
+ "Load (kW)": roof["load"]
305
+ })
306
+
307
+ for floor in results["heating"]["detailed_loads"]["floors"]:
308
+ heating_details.append({
309
+ "Component Type": "Floor",
310
+ "Name": floor["name"],
311
+ "Area (m²)": floor["area"],
312
+ "U-Value (W/m²·K)": floor["u_value"],
313
+ "Temperature Difference (°C)": floor["delta_t"],
314
+ "Load (kW)": floor["load"]
315
+ })
316
+
317
+ for window in results["heating"]["detailed_loads"]["windows"]:
318
+ heating_details.append({
319
+ "Component Type": "Window",
320
+ "Name": window["name"],
321
+ "Orientation": window["orientation"],
322
+ "Area (m²)": window["area"],
323
+ "U-Value (W/m²·K)": window["u_value"],
324
+ "Temperature Difference (°C)": window["delta_t"],
325
+ "Load (kW)": window["load"]
326
+ })
327
+
328
+ for door in results["heating"]["detailed_loads"]["doors"]:
329
+ heating_details.append({
330
+ "Component Type": "Door",
331
+ "Name": door["name"],
332
+ "Orientation": door["orientation"],
333
+ "Area (m²)": door["area"],
334
+ "U-Value (W/m²·K)": door["u_value"],
335
+ "Temperature Difference (°C)": door["delta_t"],
336
+ "Load (kW)": door["load"]
337
+ })
338
+
339
+ # Add infiltration and ventilation
340
+ heating_details.append({
341
+ "Component Type": "Infiltration",
342
+ "Name": "Air Infiltration",
343
+ "Air Flow (m³/s)": results["heating"]["detailed_loads"]["infiltration"]["air_flow"],
344
+ "Temperature Difference (°C)": results["heating"]["detailed_loads"]["infiltration"]["delta_t"],
345
+ "Load (kW)": results["heating"]["detailed_loads"]["infiltration"]["load"]
346
+ })
347
+
348
+ heating_details.append({
349
+ "Component Type": "Ventilation",
350
+ "Name": "Fresh Air",
351
+ "Air Flow (m³/s)": results["heating"]["detailed_loads"]["ventilation"]["air_flow"],
352
+ "Temperature Difference (°C)": results["heating"]["detailed_loads"]["ventilation"]["delta_t"],
353
+ "Load (kW)": results["heating"]["detailed_loads"]["ventilation"]["load"]
354
+ })
355
+
356
+ # Display heating details table
357
+ heating_df = pd.DataFrame(heating_details)
358
+ st.dataframe(heating_df, use_container_width=True)
359
+
360
+ # Add download buttons for results
361
+ st.subheader("Download Results")
362
+
363
+ col1, col2 = st.columns(2)
364
+
365
+ with col1:
366
+ if results.get("cooling") and "detailed_loads" in results["cooling"]:
367
+ if st.button("Download Cooling Load Results (CSV)"):
368
+ cooling_csv = cooling_df.to_csv(index=False)
369
+ st.download_button(
370
+ label="Download CSV",
371
+ data=cooling_csv,
372
+ file_name=f"cooling_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
373
+ mime="text/csv"
374
+ )
375
+
376
+ with col2:
377
+ if results.get("heating") and "detailed_loads" in results["heating"]:
378
+ if st.button("Download Heating Load Results (CSV)"):
379
+ heating_csv = heating_df.to_csv(index=False)
380
+ st.download_button(
381
+ label="Download CSV",
382
+ data=heating_csv,
383
+ file_name=f"heating_load_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
384
+ mime="text/csv"
385
+ )
386
+
387
+ # Add button to download full report
388
+ if st.button("Generate Full Report (Excel)"):
389
+ st.info("Excel report generation will be implemented in the Export module.")
390
+
391
+ def _display_component_breakdown(self, session_state: Dict[str, Any]) -> None:
392
+ """
393
+ Display component breakdown visualization.
394
+
395
+ Args:
396
+ session_state: Streamlit session state containing calculation results
397
+ """
398
+ st.subheader("Component Breakdown")
399
+
400
+ if not session_state["calculation_results"].get("cooling") and not session_state["calculation_results"].get("heating"):
401
+ st.warning("No component breakdown data available.")
402
+ return
403
+
404
+ # Try to use component visualization module
405
+ try:
406
+ self.component_visualization.display_component_breakdown(
407
+ session_state["calculation_results"],
408
+ session_state["components"]
409
+ )
410
+ except AttributeError:
411
+ # Fallback visualization if display_component_breakdown is not available
412
+ st.info("Component visualization module not fully implemented. Displaying default breakdown.")
413
+
414
+ results = session_state["calculation_results"]
415
+
416
+ # Cooling load bar chart
417
+ if results.get("cooling"):
418
+ cooling_breakdown = {
419
+ "Walls": results["cooling"]["component_loads"]["walls"],
420
+ "Roof": results["cooling"]["component_loads"]["roof"],
421
+ "Windows": results["cooling"]["component_loads"]["windows"],
422
+ "Doors": results["cooling"]["component_loads"]["doors"],
423
+ "People": results["cooling"]["component_loads"]["people"],
424
+ "Lighting": results["cooling"]["component_loads"]["lighting"],
425
+ "Equipment": results["cooling"]["component_loads"]["equipment"],
426
+ "Infiltration": results["cooling"]["component_loads"]["infiltration"],
427
+ "Ventilation": results["cooling"]["component_loads"]["ventilation"]
428
+ }
429
+
430
+ fig_cooling = px.bar(
431
+ x=list(cooling_breakdown.keys()),
432
+ y=list(cooling_breakdown.values()),
433
+ title="Cooling Load by Component",
434
+ labels={"x": "Component", "y": "Load (kW)"},
435
+ color_discrete_sequence=px.colors.qualitative.Pastel
436
+ )
437
+
438
+ fig_cooling.update_layout(showlegend=False)
439
+ st.plotly_chart(fig_cooling, use_container_width=True)
440
+
441
+ # Heating load bar chart
442
+ if results.get("heating"):
443
+ heating_breakdown = {
444
+ "Walls": results["heating"]["component_loads"]["walls"],
445
+ "Roof": results["heating"]["component_loads"]["roof"],
446
+ "Floor": results["heating"]["component_loads"]["floor"],
447
+ "Windows": results["heating"]["component_loads"]["windows"],
448
+ "Doors": results["heating"]["component_loads"]["doors"],
449
+ "Infiltration": results["heating"]["component_loads"]["infiltration"],
450
+ "Ventilation": results["heating"]["component_loads"]["ventilation"]
451
+ }
452
+
453
+ fig_heating = px.bar(
454
+ x=list(heating_breakdown.keys()),
455
+ y=list(heating_breakdown.values()),
456
+ title="Heating Load by Component",
457
+ labels={"x": "Component", "y": "Load (kW)"},
458
+ color_discrete_sequence=px.colors.qualitative.Pastel
459
+ )
460
+
461
+ fig_heating.update_layout(showlegend=False)
462
+ st.plotly_chart(fig_heating, use_container_width=True)
463
+
464
+ def _display_psychrometric_analysis(self, session_state: Dict[str, Any]) -> None:
465
+ """
466
+ Display psychrometric analysis visualization.
467
+
468
+ Args:
469
+ session_state: Streamlit session state containing calculation results
470
+ """
471
+ st.subheader("Psychrometric Analysis")
472
+
473
+ if not session_state["calculation_results"].get("cooling"):
474
+ st.warning("Psychrometric analysis requires cooling load results.")
475
+ return
476
+
477
+ # Use psychrometric visualization module
478
+ self.psychrometric_visualization.display_psychrometric_chart(
479
+ session_state["calculation_results"],
480
+ session_state["building_info"]
481
+ )
482
+
483
+ def _display_time_analysis(self, session_state: Dict[str, Any]) -> None:
484
+ """
485
+ Display time-based analysis visualization.
486
+
487
+ Args:
488
+ session_state: Streamlit session state containing calculation results
489
+ """
490
+ st.subheader("Time Analysis")
491
+
492
+ if not session_state["calculation_results"].get("cooling"):
493
+ st.warning("Time analysis requires cooling load results.")
494
+ return
495
+
496
+ # Use time-based visualization module
497
+ self.time_based_visualization.display_time_analysis(
498
+ session_state["calculation_results"]
499
+ )
500
+
501
+ def _display_scenario_comparison(self, session_state: Dict[str, Any]) -> None:
502
+ """
503
+ Display scenario comparison visualization.
504
+
505
+ Args:
506
+ session_state: Streamlit session state containing calculation results
507
+ """
508
+ st.subheader("Scenario Comparison")
509
+
510
+ # Check if there are saved scenarios
511
+ if "saved_scenarios" not in session_state or not session_state["saved_scenarios"]:
512
+ st.info("No saved scenarios available for comparison. Save the current results as a scenario to enable comparison.")
513
+
514
+ # Add button to save current results as a scenario
515
+ scenario_name = st.text_input("Scenario Name", value="Baseline")
516
+
517
+ if st.button("Save Current Results as Scenario"):
518
+ if "saved_scenarios" not in session_state:
519
+ session_state["saved_scenarios"] = {}
520
+
521
+ # Save current results as a scenario
522
+ session_state["saved_scenarios"][scenario_name] = {
523
+ "results": session_state["calculation_results"],
524
+ "building_info": session_state["building_info"],
525
+ "components": session_state["components"],
526
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
527
+ }
528
+
529
+ st.success(f"Scenario '{scenario_name}' saved successfully!")
530
+ st.rerun()
531
+ else:
532
+ # Use scenario comparison module
533
+ self.scenario_comparison.display_scenario_comparison(
534
+ session_state["calculation_results"],
535
+ session_state["saved_scenarios"]
536
+ )
537
+
538
+ # Add button to save current results as a new scenario
539
+ st.write("### Save Current Results as New Scenario")
540
+
541
+ scenario_name = st.text_input("Scenario Name", value="Scenario " + str(len(session_state["saved_scenarios"]) + 1))
542
+
543
+ if st.button("Save Current Results as Scenario"):
544
+ # Save current results as a scenario
545
+ session_state["saved_scenarios"][scenario_name] = {
546
+ "results": session_state["calculation_results"],
547
+ "building_info": session_state["building_info"],
548
+ "components": session_state["components"],
549
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
550
+ }
551
+
552
+ st.success(f"Scenario '{scenario_name}' saved successfully!")
553
+ st.rerun()
554
+
555
+ # Add button to delete a scenario
556
+ st.write("### Delete Scenario")
557
+
558
+ scenario_to_delete = st.selectbox(
559
+ "Select Scenario to Delete",
560
+ options=list(session_state["saved_scenarios"].keys())
561
+ )
562
+
563
+ if st.button("Delete Selected Scenario"):
564
+ # Delete selected scenario
565
+ del session_state["saved_scenarios"][scenario_to_delete]
566
+
567
+ st.success(f"Scenario '{scenario_to_delete}' deleted successfully!")
568
+ st.rerun()
569
+
570
+
571
+ # Create a singleton instance
572
+ results_display = ResultsDisplay()
573
+
574
+ # Example usage
575
+ if __name__ == "__main__":
576
+ import streamlit as st
577
+
578
+ # Initialize session state with dummy data for testing
579
+ if "calculation_results" not in st.session_state:
580
+ st.session_state["calculation_results"] = {
581
+ "cooling": {
582
+ "total_load": 25.5,
583
+ "sensible_load": 20.0,
584
+ "latent_load": 5.5,
585
+ "load_per_area": 85.0,
586
+ "component_loads": {
587
+ "walls": 5.0,
588
+ "roof": 3.0,
589
+ "windows": 8.0,
590
+ "doors": 1.0,
591
+ "people": 2.5,
592
+ "lighting": 2.0,
593
+ "equipment": 1.5,
594
+ "infiltration": 1.0,
595
+ "ventilation": 1.5
596
+ },
597
+ "detailed_loads": {
598
+ "walls": [
599
+ {"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "cltd": 10.0, "load": 1.0}
600
+ ],
601
+ "roofs": [
602
+ {"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "cltd": 15.0, "load": 3.0}
603
+ ],
604
+ "windows": [
605
+ {"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "shgc": 0.7, "scl": 800.0, "load": 8.0}
606
+ ],
607
+ "doors": [
608
+ {"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "cltd": 10.0, "load": 1.0}
609
+ ],
610
+ "internal": [
611
+ {"type": "People", "name": "Occupants", "quantity": 10, "heat_gain": 250, "clf": 1.0, "load": 2.5},
612
+ {"type": "Lighting", "name": "General Lighting", "quantity": 1000, "heat_gain": 2000, "clf": 1.0, "load": 2.0},
613
+ {"type": "Equipment", "name": "Office Equipment", "quantity": 5, "heat_gain": 300, "clf": 1.0, "load": 1.5}
614
+ ],
615
+ "infiltration": {
616
+ "air_flow": 0.05,
617
+ "sensible_load": 0.8,
618
+ "latent_load": 0.2,
619
+ "total_load": 1.0
620
+ },
621
+ "ventilation": {
622
+ "air_flow": 0.1,
623
+ "sensible_load": 1.0,
624
+ "latent_load": 0.5,
625
+ "total_load": 1.5
626
+ }
627
+ }
628
+ },
629
+ "heating": {
630
+ "total_load": 30.0,
631
+ "load_per_area": 100.0,
632
+ "design_heat_loss": 27.0,
633
+ "safety_factor": 10.0,
634
+ "component_loads": {
635
+ "walls": 8.0,
636
+ "roof": 5.0,
637
+ "floor": 4.0,
638
+ "windows": 7.0,
639
+ "doors": 1.0,
640
+ "infiltration": 2.0,
641
+ "ventilation": 3.0
642
+ },
643
+ "detailed_loads": {
644
+ "walls": [
645
+ {"name": "North Wall", "orientation": "NORTH", "area": 20.0, "u_value": 0.5, "delta_t": 25.0, "load": 8.0}
646
+ ],
647
+ "roofs": [
648
+ {"name": "Main Roof", "orientation": "HORIZONTAL", "area": 100.0, "u_value": 0.3, "delta_t": 25.0, "load": 5.0}
649
+ ],
650
+ "floors": [
651
+ {"name": "Ground Floor", "area": 100.0, "u_value": 0.4, "delta_t": 10.0, "load": 4.0}
652
+ ],
653
+ "windows": [
654
+ {"name": "South Window", "orientation": "SOUTH", "area": 10.0, "u_value": 2.8, "delta_t": 25.0, "load": 7.0}
655
+ ],
656
+ "doors": [
657
+ {"name": "Main Door", "orientation": "NORTH", "area": 2.0, "u_value": 2.0, "delta_t": 25.0, "load": 1.0}
658
+ ],
659
+ "infiltration": {
660
+ "air_flow": 0.05,
661
+ "delta_t": 25.0,
662
+ "load": 2.0
663
+ },
664
+ "ventilation": {
665
+ "air_flow": 0.1,
666
+ "delta_t": 25.0,
667
+ "load": 3.0
668
+ }
669
+ }
670
+ }
671
+ }
672
+
673
+ # Display results
674
+ results_display.display_results(st.session_state)
data/ashrae_tables.py ADDED
@@ -0,0 +1,744 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ASHRAE tables module for HVAC Load Calculator.
3
+ Integrates CLTD, SCL, CLF tables, cooling load calculations, climatic corrections, and visualization.
4
+ Combines data from original ashrae_tables.py and enhanced versions with ashrae_tables (3).py.
5
+ """
6
+
7
+ from typing import Dict, List, Any, Optional, Tuple
8
+ import pandas as pd
9
+ import numpy as np
10
+ import os
11
+ import matplotlib.pyplot as plt
12
+ from enum import Enum
13
+
14
+ # Define paths
15
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
16
+
17
+ class WallGroup(Enum):
18
+ """Enumeration for ASHRAE wall groups."""
19
+ A = "A" # Light construction
20
+ B = "B"
21
+ C = "C"
22
+ D = "D"
23
+ E = "E"
24
+ F = "F"
25
+ G = "G"
26
+ H = "H" # Heavy construction
27
+
28
+ class RoofGroup(Enum):
29
+ """Enumeration for ASHRAE roof groups."""
30
+ A = "A" # Light construction
31
+ B = "B"
32
+ C = "C"
33
+ D = "D"
34
+ E = "E"
35
+ F = "F"
36
+ G = "G" # Heavy construction
37
+
38
+ class Orientation(Enum):
39
+ """Enumeration for building component orientations."""
40
+ N = "North"
41
+ NE = "Northeast"
42
+ E = "East"
43
+ SE = "Southeast"
44
+ S = "South"
45
+ SW = "Southwest"
46
+ W = "West"
47
+ NW = "Northwest"
48
+ HOR = "Horizontal" # For roofs and floors
49
+
50
+ class ASHRAETables:
51
+ """Class for managing ASHRAE tables for load calculations."""
52
+
53
+ def __init__(self):
54
+ """Initialize ASHRAE tables."""
55
+ # Load tables
56
+ self.cltd_wall = self._load_cltd_wall_table()
57
+ self.cltd_roof = self._load_cltd_roof_table()
58
+ self.scl = self._load_scl_table()
59
+ self.clf_lights = self._load_clf_lights_table()
60
+ self.clf_people = self._load_clf_people_table()
61
+ self.clf_equipment = self._load_clf_equipment_table()
62
+ self.heat_gain = self._load_heat_gain_table()
63
+
64
+ # Load correction factors
65
+ self.latitude_correction = self._load_latitude_correction()
66
+ self.color_correction = self._load_color_correction()
67
+ self.month_correction = self._load_month_correction()
68
+
69
+ # Load thermal properties and roof classifications
70
+ self.thermal_properties = self._load_thermal_properties()
71
+ self.roof_classifications = self._load_roof_classifications()
72
+
73
+ def _validate_cltd_inputs(self, group: str, orientation: str, hour: int, latitude: str, month: str, color: str, is_wall: bool = True) -> Tuple[bool, str]:
74
+ """Validate inputs for CLTD calculations."""
75
+ valid_groups = [e.value for e in WallGroup] if is_wall else [e.value for e in RoofGroup]
76
+ valid_orientations = [e.value for e in Orientation]
77
+ valid_latitudes = ['24N', '32N', '40N', '48N', '56N']
78
+ valid_months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
79
+ valid_colors = ['Dark', 'Medium', 'Light']
80
+
81
+ if group not in valid_groups:
82
+ return False, f"Invalid {'wall' if is_wall else 'roof'} group: {group}. Valid groups: {valid_groups}"
83
+ if orientation not in valid_orientations:
84
+ return False, f"Invalid orientation: {orientation}. Valid orientations: {valid_orientations}"
85
+ if hour not in range(24):
86
+ return False, "Hour must be between 0 and 23."
87
+
88
+ # Handle numeric latitude values and ensure comprehensive mapping
89
+ if latitude not in valid_latitudes:
90
+ # Try to convert numeric latitude to standard format
91
+ try:
92
+ # First, handle string representations that might contain direction indicators
93
+ if isinstance(latitude, str):
94
+ # Extract numeric part, removing 'N' or 'S'
95
+ lat_str = latitude.upper().strip()
96
+ num_part = ''.join(c for c in lat_str if c.isdigit() or c == '.')
97
+ lat_val = float(num_part)
98
+
99
+ # Adjust for southern hemisphere if needed
100
+ if 'S' in lat_str:
101
+ lat_val = -lat_val
102
+ else:
103
+ # Handle direct numeric input
104
+ lat_val = float(latitude)
105
+
106
+ # Take absolute value for mapping purposes
107
+ abs_lat = abs(lat_val)
108
+
109
+ # Map to the closest standard latitude
110
+ if abs_lat < 28:
111
+ mapped_latitude = '24N'
112
+ elif abs_lat < 36:
113
+ mapped_latitude = '32N'
114
+ elif abs_lat < 44:
115
+ mapped_latitude = '40N'
116
+ elif abs_lat < 52:
117
+ mapped_latitude = '48N'
118
+ else:
119
+ mapped_latitude = '56N'
120
+
121
+ # Use the mapped latitude for validation
122
+ latitude = mapped_latitude
123
+
124
+ except (ValueError, TypeError):
125
+ return False, f"Invalid latitude: {latitude}. Valid latitudes: {valid_latitudes}"
126
+
127
+ if latitude not in valid_latitudes:
128
+ return False, f"Invalid latitude: {latitude}. Valid latitudes: {valid_latitudes}"
129
+
130
+ if month not in valid_months:
131
+ return False, f"Invalid month: {month}. Valid months: {valid_months}"
132
+ if color not in valid_colors:
133
+ return False, f"Invalid color: {color}. Valid colors: {valid_colors}"
134
+ return True, "Valid inputs."
135
+
136
+ def _load_cltd_wall_table(self) -> Dict[str, pd.DataFrame]:
137
+ """
138
+ Load CLTD tables for walls at 24°N (July).
139
+ Returns: Dictionary of DataFrames with CLTD values for each wall group.
140
+ """
141
+ hours = list(range(24))
142
+ # CLTD data for wall types 1-12 mapped to groups A-H
143
+ wall_data = {
144
+ "A": { # Type 1: Lightest construction
145
+ 'N': [1, 0, -1, -2, -3, -2, 5, 13, 17, 18, 19, 22, 26, 28, 30, 32, 34, 34, 27, 17, 11, 7, 5, 3],
146
+ 'NE': [1, 0, -1, -2, -3, 0, 17, 39, 51, 53, 48, 39, 32, 30, 30, 30, 30, 28, 24, 18, 13, 10, 7, 5],
147
+ 'E': [1, 0, -1, -2, -3, 0, 18, 44, 59, 63, 59, 48, 36, 32, 31, 30, 32, 32, 29, 24, 19, 13, 10, 7],
148
+ 'SE': [1, 0, -1, -2, -3, -2, 8, 25, 38, 44, 45, 42, 35, 32, 31, 30, 32, 32, 27, 24, 18, 13, 10, 7],
149
+ 'S': [1, 0, -1, -2, -3, -3, -1, 3, 8, 12, 18, 24, 29, 31, 31, 30, 32, 32, 27, 23, 18, 13, 9, 7],
150
+ 'SW': [1, 0, 1, 2, 3, 3, 1, 3, 8, 13, 17, 22, 27, 42, 59, 73, 30, 32, 27, 23, 18, 20, 12, 8],
151
+ 'W': [2, 0, 2, 2, 3, 1, 3, 8, 13, 17, 22, 27, 42, 59, 73, 30, 32, 27, 23, 18, 20, 12, 8, 5],
152
+ 'NW': [2, 0, 1, 2, 2, 3, 1, 3, 8, 13, 17, 22, 27, 42, 59, 73, 30, 32, 27, 23, 18, 20, 12, 8]
153
+ },
154
+ "B": { # Type 2
155
+ 'N': [2, 1, 0, -1, -2, -1, 6, 14, 18, 19, 20, 23, 27, 29, 31, 33, 35, 35, 28, 18, 12, 8, 6, 4],
156
+ 'NE': [2, 1, 0, -1, -2, 1, 18, 40, 52, 54, 49, 40, 33, 31, 31, 31, 31, 29, 25, 19, 14, 11, 8, 6],
157
+ 'E': [2, 1, 0, -1, -2, 1, 19, 45, 60, 64, 60, 49, 37, 33, 32, 31, 33, 33, 30, 25, 20, 14, 11, 8],
158
+ 'SE': [2, 1, 0, -1, -2, -1, 9, 26, 39, 45, 46, 43, 36, 33, 32, 31, 33, 33, 28, 25, 19, 14, 11, 8],
159
+ 'S': [2, 1, 0, -1, -2, -2, 0, 4, 9, 13, 19, 25, 30, 32, 32, 31, 33, 33, 28, 24, 19, 14, 10, 8],
160
+ 'SW': [2, 1, 2, 3, 4, 4, 2, 4, 9, 14, 18, 23, 28, 43, 60, 74, 31, 33, 28, 24, 19, 21, 13, 9],
161
+ 'W': [3, 1, 3, 3, 4, 2, 4, 9, 14, 18, 23, 28, 43, 60, 74, 31, 33, 28, 24, 19, 21, 13, 9, 6],
162
+ 'NW': [3, 1, 2, 3, 3, 4, 2, 4, 9, 14, 18, 23, 28, 43, 60, 74, 31, 33, 28, 24, 19, 21, 13, 9]
163
+ },
164
+ "C": { # Type 3
165
+ 'N': [3, 2, 1, 0, -1, 0, 7, 15, 19, 20, 21, 24, 28, 30, 32, 34, 36, 36, 29, 19, 13, 9, 7, 5],
166
+ 'NE': [3, 2, 1, 0, -1, 2, 19, 41, 53, 55, 50, 41, 34, 32, 32, 32, 32, 30, 26, 20, 15, 12, 9, 7],
167
+ 'E': [3, 2, 1, 0, -1, 2, 20, 46, 61, 65, 61, 50, 38, 34, 33, 32, 34, 34, 31, 26, 21, 15, 12, 9],
168
+ 'SE': [3, 2, 1, 0, -1, 0, 10, 27, 40, 46, 47, 44, 37, 34, 33, 32, 34, 34, 29, 26, 20, 15, 12, 9],
169
+ 'S': [3, 2, 1, 0, -1, -1, 1, 5, 10, 14, 20, 26, 31, 33, 33, 32, 34, 34, 29, 25, 20, 15, 11, 9],
170
+ 'SW': [3, 2, 3, 4, 5, 5, 3, 5, 10, 15, 19, 24, 29, 44, 61, 75, 32, 34, 29, 25, 20, 22, 14, 10],
171
+ 'W': [4, 2, 4, 4, 5, 3, 5, 10, 15, 19, 24, 29, 44, 61, 75, 32, 34, 29, 25, 20, 22, 14, 10, 7],
172
+ 'NW': [4, 2, 3, 4, 4, 5, 3, 5, 10, 15, 19, 24, 29, 44, 61, 75, 32, 34, 29, 25, 20, 22, 14, 10]
173
+ },
174
+ "D": { # Type 4
175
+ 'N': [4, 3, 2, 1, 0, 1, 8, 16, 20, 21, 22, 25, 29, 31, 33, 35, 37, 37, 30, 20, 14, 10, 8, 6],
176
+ 'NE': [4, 3, 2, 1, 0, 3, 20, 42, 54, 56, 51, 42, 35, 33, 33, 33, 33, 31, 27, 21, 16, 13, 10, 8],
177
+ 'E': [4, 3, 2, 1, 0, 3, 21, 47, 62, 66, 62, 51, 39, 35, 34, 33, 35, 35, 32, 27, 22, 16, 13, 10],
178
+ 'SE': [4, 3, 2, 1, 0, 1, 11, 28, 41, 47, 48, 45, 38, 35, 34, 33, 35, 35, 30, 27, 21, 16, 13, 10],
179
+ 'S': [4, 3, 2, 1, 0, 0, 2, 6, 11, 15, 21, 27, 32, 34, 34, 33, 35, 35, 30, 26, 21, 16, 12, 10],
180
+ 'SW': [4, 3, 4, 5, 6, 6, 4, 6, 11, 16, 20, 25, 30, 45, 62, 76, 33, 35, 30, 26, 21, 23, 15, 11],
181
+ 'W': [5, 3, 5, 5, 6, 4, 6, 11, 16, 20, 25, 30, 45, 62, 76, 33, 35, 30, 26, 21, 23, 15, 11, 8],
182
+ 'NW': [5, 3, 4, 5, 5, 6, 4, 6, 11, 16, 20, 25, 30, 45, 62, 76, 33, 35, 30, 26, 21, 23, 15, 11]
183
+ },
184
+ "E": { # Type 5
185
+ 'N': [13, 11, 9, 7, 5, 3, 2, 3, 5, 7, 10, 12, 14, 16, 19, 21, 23, 25, 27, 27, 25, 22, 20, 16],
186
+ 'NE': [13, 11, 8, 7, 5, 3, 3, 6, 12, 20, 26, 31, 33, 33, 32, 32, 32, 33, 31, 29, 27, 24, 21, 18],
187
+ 'E': [14, 11, 9, 7, 5, 4, 3, 6, 13, 22, 31, 36, 39, 39, 39, 39, 39, 31, 31, 29, 26, 22, 19, 18],
188
+ 'SE': [13, 10, 8, 6, 5, 3, 2, 4, 8, 14, 20, 25, 28, 30, 30, 30, 30, 30, 28, 26, 24, 21, 18, 16],
189
+ 'S': [11, 9, 7, 6, 4, 3, 2, 1, 1, 3, 5, 7, 11, 14, 16, 20, 22, 23, 23, 23, 20, 18, 16, 14],
190
+ 'SW': [18, 15, 12, 9, 7, 5, 3, 3, 3, 4, 5, 8, 11, 14, 16, 20, 26, 32, 33, 31, 41, 40, 36, 31],
191
+ 'W': [23, 19, 15, 12, 9, 7, 5, 4, 4, 4, 6, 8, 11, 14, 16, 20, 28, 37, 35, 31, 51, 41, 41, 41],
192
+ 'NW': [21, 17, 14, 11, 8, 6, 4, 3, 3, 4, 6, 8, 11, 14, 16, 20, 28, 37, 35, 31, 41, 41, 41, 41]
193
+ },
194
+ "F": { # Type 6
195
+ 'N': [10, 8, 6, 4, 2, 1, 1, 2, 4, 6, 9, 11, 13, 15, 18, 20, 22, 24, 26, 26, 24, 21, 19, 15],
196
+ 'NE': [10, 8, 6, 4, 2, 2, 2, 5, 11, 19, 25, 30, 32, 32, 31, 31, 31, 32, 30, 28, 26, 23, 20, 17],
197
+ 'E': [11, 8, 6, 4, 2, 3, 2, 5, 12, 21, 30, 35, 38, 38, 38, 38, 38, 30, 30, 28, 25, 21, 18, 17],
198
+ 'SE': [10, 7, 5, 3, 2, 2, 1, 3, 7, 13, 19, 24, 27, 29, 29, 29, 29, 29, 27, 25, 23, 20, 17, 15],
199
+ 'S': [8, 6, 4, 3, 1, 2, 1, 0, 0, 2, 4, 6, 10, 13, 15, 19, 21, 22, 22, 22, 19, 17, 15, 13],
200
+ 'SW': [15, 12, 9, 6, 4, 3, 2, 2, 2, 3, 4, 7, 10, 13, 15, 19, 25, 31, 32, 30, 40, 39, 35, 30],
201
+ 'W': [20, 16, 12, 9, 6, 4, 3, 3, 3, 3, 5, 7, 10, 13, 15, 19, 27, 36, 34, 30, 50, 40, 40, 40],
202
+ 'NW': [18, 14, 11, 8, 5, 4, 3, 2, 2, 3, 5, 7, 10, 13, 15, 19, 27, 36, 34, 30, 40, 40, 40, 40]
203
+ },
204
+ "G": { # Type 7
205
+ 'N': [7, 5, 3, 1, -1, 0, 0, 1, 3, 5, 8, 10, 12, 14, 17, 19, 21, 23, 25, 25, 23, 20, 18, 14],
206
+ 'NE': [7, 5, 3, 1, -1, 1, 1, 4, 10, 18, 24, 29, 31, 31, 30, 30, 30, 31, 29, 27, 25, 22, 19, 16],
207
+ 'E': [8, 5, 3, 1, -1, 2, 1, 4, 11, 20, 29, 34, 37, 37, 37, 37, 37, 29, 29, 27, 24, 20, 17, 16],
208
+ 'SE': [7, 4, 2, 0, -1, 1, 0, 2, 6, 12, 18, 23, 26, 28, 28, 28, 28, 28, 26, 24, 22, 19, 16, 14],
209
+ 'S': [5, 3, 1, 0, -2, 1, 0, -1, -1, 1, 3, 5, 9, 12, 14, 18, 20, 21, 21, 21, 18, 16, 14, 12],
210
+ 'SW': [12, 9, 6, 3, 1, 2, 1, 1, 1, 2, 3, 6, 9, 12, 14, 18, 24, 30, 31, 29, 39, 38, 34, 29],
211
+ 'W': [17, 13, 9, 6, 3, 2, 2, 2, 2, 2, 4, 6, 9, 12, 14, 18, 26, 35, 33, 29, 49, 39, 39, 39],
212
+ 'NW': [15, 11, 8, 5, 2, 3, 2, 1, 1, 2, 4, 6, 9, 12, 14, 18, 26, 35, 33, 29, 39, 39, 39, 39]
213
+ },
214
+ "H": { # Interpolated from types 8-12: Heaviest construction
215
+ 'N': [4, 2, 0, -2, -4, -1, -1, 0, 2, 4, 7, 9, 11, 13, 16, 18, 20, 22, 24, 24, 22, 19, 17, 13],
216
+ 'NE': [4, 2, 0, -2, -4, 0, 0, 3, 9, 17, 23, 28, 30, 30, 29, 29, 29, 30, 28, 26, 24, 21, 18, 15],
217
+ 'E': [5, 2, 0, -2, -4, 1, 0, 3, 10, 19, 28, 33, 36, 36, 36, 36, 36, 28, 28, 26, 23, 19, 16, 15],
218
+ 'SE': [4, 1, -1, -3, -4, 0, -1, 1, 5, 11, 17, 22, 25, 27, 27, 27, 27, 27, 25, 23, 21, 18, 15, 13],
219
+ 'S': [2, 0, -2, -3, -5, 0, -1, -2, -2, 0, 2, 4, 8, 11, 13, 17, 19, 20, 20, 20, 17, 15, 13, 11],
220
+ 'SW': [9, 6, 3, 0, -2, 1, 0, 0, 0, 1, 2, 5, 8, 11, 13, 17, 23, 29, 30, 28, 38, 37, 33, 28],
221
+ 'W': [14, 10, 6, 3, 0, 1, 1, 1, 1, 1, 3, 5, 8, 11, 13, 17, 25, 34, 32, 28, 48, 38, 38, 38],
222
+ 'NW': [12, 8, 5, 2, -1, 2, 1, 0, 0, 1, 3, 5, 8, 11, 13, 17, 25, 34, 32, 28, 38, 38, 38, 38]
223
+ }
224
+ }
225
+ wall_groups = {group: pd.DataFrame(data, index=hours) for group, data in wall_data.items()}
226
+ return wall_groups
227
+
228
+ def _load_cltd_roof_table(self) -> Dict[str, pd.DataFrame]:
229
+ """
230
+ Load CLTD tables for roofs at 24°N, 36°N, 48°N (July).
231
+ Returns: Dictionary of DataFrames with CLTD values for each roof group and latitude.
232
+ """
233
+ hours = list(range(24))
234
+ # CLTD data for roof types mapped to groups A-G across latitudes
235
+ roof_data = {
236
+ "24N": {
237
+ "A": [0, 4, 5, 6, 6, 3, 9, 16, 44, 62, 76, 87, 92, 92, 86, 74, 58, 39, 23, 14, 8, 4, 2, 0], # Type 1
238
+ "B": [12, 8, 5, 2, 0, -2, -2, 3, 11, 22, 35, 47, 59, 68, 74, 77, 74, 68, 58, 47, 37, 29, 22, 16], # Type 3
239
+ "C": [21, 16, 12, 8, 5, 3, 1, 1, 1, 10, 19, 20, 22, 23, 49, 49, 54, 58, 58, 56, 52, 47, 42, 37], # Type 5
240
+ "D": [31, 25, 20, 16, 12, 9, 6, 4, 3, 5, 10, 17, 26, 36, 46, 54, 61, 65, 66, 63, 58, 51, 44, 47], # Type 9
241
+ "E": [34, 31, 28, 25, 22, 20, 17, 16, 15, 19, 23, 28, 29, 32, 38, 38, 43, 43, 49, 49, 49, 46, 43, 40], # Type 13
242
+ "F": [35, 32, 30, 28, 25, 23, 21, 19, 20, 22, 23, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 44, 42], # Type 14
243
+ "G": [36, 33, 31, 29, 27, 25, 23, 21, 20, 22, 24, 25, 26, 27, 40, 41, 42, 42, 42, 47, 48, 48, 45, 43] # Interpolated
244
+ },
245
+ "36N": {
246
+ "A": [0, 2, 4, 5, 6, 6, 12, 28, 45, 61, 75, 84, 90, 90, 84, 79, 71, 62, 66, 59, 50, 42, 47, 0], # Type 1
247
+ "B": [12, 8, 5, 2, 0, -2, -1, 14, 13, 24, 25, 26, 27, 28, 38, 39, 40, 40, 43, 45, 46, 46, 43, 40], # Type 3
248
+ "C": [21, 16, 12, 8, 5, 3, 1, 12, 15, 12, 21, 22, 23, 32, 39, 40, 40, 40, 40, 45, 46, 46, 43, 40], # Type 5
249
+ "D": [32, 26, 21, 16, 13, 10, 8, 14, 17, 19, 20, 22, 23, 24, 39, 40, 40, 40, 40, 45, 46, 46, 43, 40], # Type 9
250
+ "E": [34, 31, 28, 25, 23, 20, 18, 16, 16, 20, 22, 22, 23, 24, 39, 39, 40, 40, 40, 45, 46, 46, 44, 42], # Type 13
251
+ "F": [35, 32, 30, 28, 25, 23, 21, 19, 20, 22, 23, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 44, 42], # Type 14
252
+ "G": [36, 33, 31, 29, 27, 25, 23, 21, 20, 22, 24, 25, 26, 27, 40, 41, 42, 42, 42, 47, 48, 48, 45, 43] # Interpolated
253
+ },
254
+ "48N": {
255
+ "A": [0, 2, 4, 5, 6, 5, 3, 15, 29, 44, 58, 69, 78, 83, 83, 79, 71, 59, 44, 49, 49, 49, 5, 2], # Type 1
256
+ "B": [12, 8, 5, 2, 0, -1, 1, 16, 16, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 43, 40], # Type 3
257
+ "C": [21, 16, 12, 8, 5, 3, 2, 16, 19, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 43, 40], # Type 5
258
+ "D": [31, 26, 21, 16, 12, 9, 6, 5, 5, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 45, 46, 46, 43, 40], # Type 9
259
+ "E": [33, 30, 27, 25, 22, 20, 17, 16, 16, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 47, 48, 47, 45, 40], # Type 13
260
+ "F": [34, 32, 29, 27, 25, 23, 21, 20, 19, 20, 22, 23, 24, 25, 39, 39, 40, 40, 40, 48, 48, 48, 43, 40], # Type 14
261
+ "G": [35, 33, 31, 29, 27, 25, 23, 21, 20, 22, 24, 25, 26, 27, 40, 41, 42, 42, 42, 48, 49, 49, 45, 43] # Interpolated
262
+ }
263
+ }
264
+ roof_groups = {}
265
+ for lat, groups in roof_data.items():
266
+ for group, data in groups.items():
267
+ roof_groups[f"{group}_{lat}"] = pd.DataFrame({"HOR": data}, index=hours)
268
+ return roof_groups
269
+
270
+ def _load_scl_table(self) -> Dict[str, pd.DataFrame]:
271
+ """
272
+ Load SCL (Solar Cooling Load) tables for windows.
273
+ Returns: Dictionary of DataFrames with SCL values for each latitude/month.
274
+ """
275
+ hours = list(range(24))
276
+ # Base SCL data for 40°N (July)
277
+ scl_40n_jul = {
278
+ "N": [11, 8, 6, 6, 6, 9, 13, 16, 19, 21, 22, 23, 23, 22, 20, 17, 14, 11, 11, 11, 11, 11, 11, 11],
279
+ "NE": [11, 8, 6, 6, 6, 19, 75, 113, 121, 103, 75, 40, 31, 27, 23, 19, 14, 11, 11, 11, 11, 11, 11, 11],
280
+ "E": [11, 8, 6, 6, 6, 13, 55, 159, 232, 251, 222, 157, 82, 43, 32, 24, 17, 11, 11, 11, 11, 11, 11, 11],
281
+ "SE": [11, 8, 6, 6, 6, 10, 33, 98, 187, 251, 276, 264, 214, 139, 74, 37, 21, 11, 11, 11, 11, 11, 11, 11],
282
+ "S": [11, 8, 6, 6, 6, 8, 14, 27, 66, 139, 209, 254, 268, 251, 203, 139, 66, 27, 14, 11, 11, 11, 11, 11],
283
+ "SW": [11, 8, 6, 6, 6, 8, 14, 19, 24, 37, 74, 139, 214, 264, 276, 251, 187, 98, 33, 14, 11, 11, 11, 11],
284
+ "W": [11, 8, 6, 6, 6, 8, 14, 19, 24, 32, 43, 82, 157, 222, 251, 232, 159, 55, 13, 11, 11, 11, 11, 11],
285
+ "NW": [11, 8, 6, 6, 6, 8, 14, 19, 24, 27, 31, 40, 75, 103, 121, 113, 75, 19, 11, 11, 11, 11, 11, 11],
286
+ "HOR": [11, 8, 6, 6, 6, 19, 69, 135, 201, 254, 290, 308, 308, 290, 254, 201, 135, 69, 19, 11, 11, 11, 11, 11]
287
+ }
288
+ scl_tables = {"40N_Jul": pd.DataFrame(scl_40n_jul, index=hours)}
289
+ latitudes = ["24N", "32N", "40N", "48N", "56N"]
290
+ months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
291
+ for lat in latitudes:
292
+ for month in months:
293
+ key = f"{lat}_{month}"
294
+ if key == "40N_Jul":
295
+ continue
296
+ lat_factor = (40 - float(lat[:-1])) / 40
297
+ month_idx = months.index(month)
298
+ month_factor = 1 + (month_idx - 6) / 24
299
+ scl_data = {}
300
+ for orient in scl_40n_jul:
301
+ base_scl = scl_40n_jul[orient]
302
+ scl_data[orient] = [max(6, round(v * (1 - lat_factor * 0.2) * month_factor)) for v in base_scl]
303
+ scl_tables[key] = pd.DataFrame(scl_data, index=hours)
304
+ return scl_tables
305
+
306
+ def _load_clf_lights_table(self) -> pd.DataFrame:
307
+ """
308
+ Load CLF (Cooling Load Factor) table for lights.
309
+ Returns: DataFrame with CLF values for lights by zone type and hours.
310
+ """
311
+ hours = list(range(24))
312
+ clf_lights_data = {
313
+ "A_8h": [0.85, 0.92, 0.95, 0.95, 0.97, 0.97, 0.98, 0.13, 0.06, 0.04, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
314
+ "A_10h": [0.85, 0.93, 0.95, 0.97, 0.97, 0.98, 0.98, 0.98, 0.98, 0.98, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
315
+ "A_12h": [0.86, 0.93, 0.96, 0.97, 0.97, 0.98, 0.98, 0.98, 0.98, 0.98, 0.98, 0.98, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
316
+ "B_8h": [0.75, 0.85, 0.90, 0.93, 0.94, 0.95, 0.95, 0.95, 0.12, 0.08, 0.05, 0.04, 0.04, 0.03, 0.03, 0.03, 0.03, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
317
+ "B_10h": [0.75, 0.86, 0.91, 0.93, 0.94, 0.95, 0.95, 0.95, 0.96, 0.97, 0.24, 0.13, 0.08, 0.06, 0.05, 0.04, 0.04, 0.03, 0.03, 0.03, 0.03, 0.03, 0.02, 0.02],
318
+ "B_12h": [0.76, 0.86, 0.91, 0.93, 0.95, 0.95, 0.95, 0.95, 0.97, 0.97, 0.97, 0.97, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03, 0.03],
319
+ "C_8h": [0.70, 0.80, 0.85, 0.88, 0.90, 0.92, 0.93, 0.94, 0.10, 0.07, 0.04, 0.03, 0.03, 0.02, 0.02, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
320
+ "C_10h": [0.70, 0.81, 0.86, 0.89, 0.91, 0.92, 0.93, 0.94, 0.95, 0.96, 0.20, 0.11, 0.07, 0.05, 0.04, 0.03, 0.03, 0.02, 0.02, 0.02, 0.02, 0.02, 0.01, 0.01],
321
+ "C_12h": [0.71, 0.82, 0.87, 0.90, 0.92, 0.93, 0.94, 0.95, 0.96, 0.96, 0.96, 0.96, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
322
+ "D_8h": [0.65, 0.75, 0.80, 0.83, 0.85, 0.87, 0.88, 0.89, 0.08, 0.06, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
323
+ "D_10h": [0.65, 0.76, 0.81, 0.84, 0.86, 0.88, 0.89, 0.90, 0.91, 0.92, 0.16, 0.09, 0.06, 0.04, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
324
+ "D_12h": [0.66, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.92, 0.92, 0.92, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]
325
+ }
326
+ return pd.DataFrame(clf_lights_data, index=hours)
327
+
328
+ def _load_clf_people_table(self) -> pd.DataFrame:
329
+ """
330
+ Load CLF (Cooling Load Factor) table for people.
331
+ Returns: DataFrame with CLF values for people by zone type and hours.
332
+ """
333
+ hours = list(range(24))
334
+ clf_people_data = {
335
+ "A_2h": [0.75, 0.88, 0.18, 0.08, 0.04, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
336
+ "A_4h": [0.75, 0.88, 0.93, 0.95, 0.97, 0.10, 0.05, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
337
+ "A_6h": [0.75, 0.88, 0.93, 0.95, 0.97, 0.97, 0.33, 0.11, 0.06, 0.04, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00],
338
+ "B_2h": [0.65, 0.75, 0.81, 0.85, 0.89, 0.91, 0.93, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02],
339
+ "B_4h": [0.65, 0.75, 0.82, 0.87, 0.90, 0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
340
+ "B_6h": [0.65, 0.75, 0.82, 0.87, 0.90, 0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
341
+ "C_2h": [0.60, 0.70, 0.76, 0.80, 0.84, 0.86, 0.88, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01],
342
+ "C_4h": [0.60, 0.70, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
343
+ "C_6h": [0.60, 0.70, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
344
+ "D_2h": [0.55, 0.65, 0.71, 0.75, 0.79, 0.81, 0.83, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00],
345
+ "D_4h": [0.55, 0.65, 0.72, 0.77, 0.80, 0.82, 0.84, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
346
+ "D_6h": [0.55, 0.65, 0.72, 0.77, 0.80, 0.82, 0.84, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]
347
+ }
348
+ return pd.DataFrame(clf_people_data, index=hours)
349
+
350
+ def _load_clf_equipment_table(self) -> pd.DataFrame:
351
+ """
352
+ Load CLF (Cooling Load Factor) table for equipment.
353
+ Returns: DataFrame with CLF values for equipment by zone type and hours.
354
+ """
355
+ hours = list(range(24))
356
+ clf_equipment_data = {
357
+ "A_2h": [0.54, 0.83, 0.26, 0.11, 0.05, 0.03, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
358
+ "A_4h": [0.64, 0.83, 0.90, 0.93, 0.31, 0.14, 0.07, 0.04, 0.03, 0.03, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
359
+ "A_6h": [0.64, 0.83, 0.90, 0.93, 0.95, 0.95, 0.33, 0.11, 0.06, 0.04, 0.03, 0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00],
360
+ "B_2h": [0.50, 0.75, 0.81, 0.85, 0.89, 0.91, 0.93, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02],
361
+ "B_4h": [0.50, 0.75, 0.82, 0.87, 0.90, 0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
362
+ "B_6h": [0.50, 0.75, 0.82, 0.87, 0.90, 0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.98, 0.99, 0.99, 0.99, 0.99, 0.99, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02, 0.02],
363
+ "C_2h": [0.46, 0.70, 0.76, 0.80, 0.84, 0.86, 0.88, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01],
364
+ "C_4h": [0.46, 0.70, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
365
+ "C_6h": [0.46, 0.70, 0.77, 0.82, 0.85, 0.87, 0.89, 0.90, 0.91, 0.92, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.94, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01],
366
+ "D_2h": [0.42, 0.65, 0.71, 0.75, 0.79, 0.81, 0.83, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00],
367
+ "D_4h": [0.42, 0.65, 0.72, 0.77, 0.80, 0.82, 0.84, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
368
+ "D_6h": [0.42, 0.65, 0.72, 0.77, 0.80, 0.82, 0.84, 0.85, 0.86, 0.87, 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00]
369
+ }
370
+ return pd.DataFrame(clf_equipment_data, index=hours)
371
+
372
+ def _load_heat_gain_table(self) -> pd.DataFrame:
373
+ """
374
+ Load heat gain table for internal sources.
375
+ Returns: DataFrame with heat gain values (Btu/h or Btu/h-ft²).
376
+ """
377
+ data = {
378
+ "source": ["people_sensible", "people_latent", "lights", "equipment"],
379
+ "gain": [250, 200, 3.4, 500]
380
+ }
381
+ return pd.DataFrame(data)
382
+
383
+ def _load_thermal_properties(self) -> pd.DataFrame:
384
+ """
385
+ Load thermal properties for building materials.
386
+ Returns: DataFrame with U-values, R-values, and density.
387
+ """
388
+ data = {
389
+ "material": [
390
+ "Brick_4in", "Brick_8in", "Concrete_6in", "Concrete_12in",
391
+ "Wood_1in", "Wood_2in", "Insulation_1in", "Insulation_2in",
392
+ "Gypsum_0.5in", "Steel_1in"
393
+ ],
394
+ "U_value": [0.45, 0.32, 0.51, 0.48, 0.12, 0.08, 0.03, 0.015, 0.32, 0.65], # Btu/h-ft²-°F
395
+ "R_value": [2.22, 3.13, 1.96, 2.08, 8.33, 12.5, 33.33, 66.67, 3.13, 1.54], # ft²-°F-h/Btu
396
+ "density": [120, 120, 140, 140, 35, 35, 1.5, 1.5, 40, 490] # lb/ft³
397
+ }
398
+ return pd.DataFrame(data)
399
+
400
+ def _load_roof_classifications(self) -> pd.DataFrame:
401
+ """
402
+ Load roof classification data.
403
+ Returns: DataFrame with roof type descriptions and properties.
404
+ """
405
+ data = {
406
+ "type": [1, 2, 3, 4, 5, 8, 9, 10, 13, 14],
407
+ "description": [
408
+ "Light roof, no insulation", "Light roof, minimal insulation",
409
+ "Medium roof, R-10 insulation", "Medium roof, R-15 insulation",
410
+ "Heavy roof, R-20 insulation", "Heavy roof, R-25 insulation",
411
+ "Concrete slab, R-15 insulation", "Concrete slab, R-20 insulation",
412
+ "Metal deck, R-30 insulation", "Metal deck, R-35 insulation"
413
+ ],
414
+ "U_value": [0.5, 0.4, 0.3, 0.25, 0.2, 0.15, 0.18, 0.14, 0.1, 0.08],
415
+ "mass": [10, 15, 50, 60, 100, 120, 150, 160, 80, 90] # lb/ft²
416
+ }
417
+ return pd.DataFrame(data)
418
+
419
+ def _load_latitude_correction(self) -> Dict[str, Dict[str, float]]:
420
+ """
421
+ Load latitude correction factors for CLTD/SCL values.
422
+ Returns: Dictionary of correction factors for different latitudes and months.
423
+ """
424
+ return {
425
+ "24N": {"Jan": -5.0, "Feb": -3.5, "Mar": -1.0, "Apr": 2.0, "May": 4.0, "Jun": 5.0, "Jul": 4.5, "Aug": 3.0, "Sep": 1.0, "Oct": -1.5, "Nov": -4.0, "Dec": -5.5},
426
+ "32N": {"Jan": -4.0, "Feb": -2.5, "Mar": 0.0, "Apr": 2.5, "May": 4.5, "Jun": 5.5, "Jul": 5.0, "Aug": 3.5, "Sep": 1.5, "Oct": -0.5, "Nov": -3.0, "Dec": -4.5},
427
+ "40N": {"Jan": -3.0, "Feb": -1.5, "Mar": 1.0, "Apr": 3.0, "May": 5.0, "Jun": 6.0, "Jul": 5.5, "Aug": 4.0, "Sep": 2.0, "Oct": 0.0, "Nov": -2.0, "Dec": -3.5},
428
+ "48N": {"Jan": -2.0, "Feb": -0.5, "Mar": 2.0, "Apr": 4.0, "May": 6.0, "Jun": 7.0, "Jul": 6.5, "Aug": 5.0, "Sep": 3.0, "Oct": 1.0, "Nov": -1.0, "Dec": -2.5},
429
+ "56N": {"Jan": -1.0, "Feb": 0.5, "Mar": 3.0, "Apr": 5.0, "May": 7.0, "Jun": 8.0, "Jul": 7.5, "Aug": 6.0, "Sep": 4.0, "Oct": 2.0, "Nov": 0.0, "Dec": -1.5}
430
+ }
431
+
432
+ def _load_color_correction(self) -> Dict[str, float]:
433
+ """
434
+ Load color correction factors for CLTD values.
435
+ Returns: Dictionary of correction factors for different colors.
436
+ """
437
+ return {"Dark": 0.0, "Medium": -1.0, "Light": -2.0}
438
+
439
+ def _load_month_correction(self) -> Dict[str, float]:
440
+ """
441
+ Load month correction factors for CLTD values.
442
+ Returns: Dictionary of correction factors for different months.
443
+ """
444
+ return {
445
+ "Jan": -6.0, "Feb": -5.0, "Mar": -3.0, "Apr": -1.0, "May": 1.0,
446
+ "Jun": 2.0, "Jul": 2.0, "Aug": 2.0, "Sep": 1.0, "Oct": -1.0,
447
+ "Nov": -3.0, "Dec": -5.0
448
+ }
449
+
450
+ def _apply_climatic_corrections(self, cltd: float, latitude: str, month: str, color: str, outdoor_temp: float, indoor_temp: float) -> float:
451
+ """
452
+ Apply climatic corrections to CLTD values based on latitude, month, color, and temperature.
453
+
454
+ Args:
455
+ cltd (float): Base CLTD value.
456
+ latitude (str): Latitude (e.g., '32N').
457
+ month (str): Month (e.g., 'Jul').
458
+ color (str): Surface color ('Dark', 'Medium', 'Light').
459
+ outdoor_temp (float): Outdoor design temperature (°C).
460
+ indoor_temp (float): Indoor design temperature (°C).
461
+
462
+ Returns:
463
+ float: Corrected CLTD value (°C).
464
+ """
465
+ try:
466
+ # Convert temperatures to °F for ASHRAE corrections
467
+ outdoor_temp_f = outdoor_temp * 9/5 + 32
468
+ indoor_temp_f = indoor_temp * 9/5 + 32
469
+
470
+ # Get correction factors
471
+ lat_corr = self.latitude_correction.get(latitude, {}).get(month, 0.0)
472
+ month_corr = self.month_correction.get(month, 0.0)
473
+ color_corr = self.color_correction.get(color, 0.0)
474
+
475
+ # Apply temperature difference correction (ASHRAE CLTD correction formula)
476
+ temp_diff = outdoor_temp_f - indoor_temp_f
477
+ design_temp_diff = 85 - 78 # ASHRAE base conditions: 85°F outdoor, 78°F indoor
478
+ temp_corr = (temp_diff - design_temp_diff) * 0.5556 # Convert °F to °C
479
+
480
+ # Total correction
481
+ corrected_cltd = cltd + lat_corr + month_corr + color_corr + temp_corr
482
+
483
+ # Ensure non-negative CLTD
484
+ return max(0.0, corrected_cltd)
485
+ except Exception as e:
486
+ raise ValueError(f"Error applying climatic corrections: {str(e)}")
487
+
488
+ def get_cltd_wall(self, wall_group: str, orientation: str, hour: int) -> float:
489
+ """Get CLTD value for a wall."""
490
+ if wall_group not in self.cltd_wall:
491
+ raise ValueError(f"Invalid wall group: {wall_group}")
492
+ orientation_map = {e.value: e.name for e in Orientation}
493
+ orientation_abbr = orientation_map.get(orientation, orientation)
494
+ if orientation_abbr not in self.cltd_wall[wall_group].columns:
495
+ raise ValueError(f"Invalid orientation: {orientation}")
496
+ if hour not in self.cltd_wall[wall_group].index:
497
+ raise ValueError(f"Invalid hour: {hour}")
498
+ return float(self.cltd_wall[wall_group].loc[hour, orientation_abbr])
499
+
500
+ def get_cltd_roof(self, roof_group: str, latitude: str, hour: int) -> float:
501
+ """Get CLTD value for a roof."""
502
+ # Map latitude to standard format before forming the key
503
+ valid_latitudes = ['24N', '36N', '48N']
504
+
505
+ # Handle numeric or non-standard latitude values
506
+ if latitude not in valid_latitudes:
507
+ # Try to convert to standard format
508
+ try:
509
+ # First, handle string representations that might contain direction indicators
510
+ if isinstance(latitude, str):
511
+ # Extract numeric part, removing 'N' or 'S'
512
+ lat_str = latitude.upper().strip()
513
+ num_part = ''.join(c for c in lat_str if c.isdigit() or c == '.')
514
+ lat_val = float(num_part)
515
+
516
+ # Adjust for southern hemisphere if needed
517
+ if 'S' in lat_str:
518
+ lat_val = -lat_val
519
+ else:
520
+ # Handle direct numeric input
521
+ lat_val = float(latitude)
522
+
523
+ # Take absolute value for mapping purposes
524
+ abs_lat = abs(lat_val)
525
+
526
+ # Map to the closest standard latitude for roof data
527
+ if abs_lat < 30:
528
+ latitude = '24N'
529
+ elif abs_lat < 42:
530
+ latitude = '36N'
531
+ else:
532
+ latitude = '48N'
533
+
534
+ except (ValueError, TypeError):
535
+ raise ValueError(f"Invalid latitude format: {latitude}")
536
+
537
+ key = f"{roof_group}_{latitude}"
538
+ if key not in self.cltd_roof:
539
+ raise ValueError(f"Invalid roof group or latitude: {key}")
540
+ if hour not in self.cltd_roof[key].index:
541
+ raise ValueError(f"Invalid hour: {hour}")
542
+ return float(self.cltd_roof[key].loc[hour, "HOR"])
543
+
544
+ def get_scl(self, latitude: str, month: str, orientation: str, hour: int) -> float:
545
+ """Get SCL value for a window."""
546
+ # Map latitude to standard format before forming the key
547
+ valid_latitudes = ['24N', '32N', '40N', '48N', '56N']
548
+
549
+ # Handle numeric or non-standard latitude values
550
+ if latitude not in valid_latitudes:
551
+ # Try to convert to standard format
552
+ try:
553
+ # First, handle string representations that might contain direction indicators
554
+ if isinstance(latitude, str):
555
+ # Extract numeric part, removing 'N' or 'S'
556
+ lat_str = latitude.upper().strip()
557
+ num_part = ''.join(c for c in lat_str if c.isdigit() or c == '.')
558
+ lat_val = float(num_part)
559
+
560
+ # Adjust for southern hemisphere if needed
561
+ if 'S' in lat_str:
562
+ lat_val = -lat_val
563
+ else:
564
+ # Handle direct numeric input
565
+ lat_val = float(latitude)
566
+
567
+ # Take absolute value for mapping purposes
568
+ abs_lat = abs(lat_val)
569
+
570
+ # Map to the closest standard latitude for SCL data
571
+ if abs_lat < 28:
572
+ latitude = '24N'
573
+ elif abs_lat < 36:
574
+ latitude = '32N'
575
+ elif abs_lat < 44:
576
+ latitude = '40N'
577
+ elif abs_lat < 52:
578
+ latitude = '48N'
579
+ else:
580
+ latitude = '56N'
581
+
582
+ except (ValueError, TypeError):
583
+ raise ValueError(f"Invalid latitude format: {latitude}")
584
+
585
+ key = f"{latitude}_{month}"
586
+ if key not in self.scl:
587
+ raise ValueError(f"Invalid latitude or month: {key}")
588
+ orientation_map = {e.value: e.name for e in Orientation}
589
+ orientation_abbr = orientation_map.get(orientation, orientation)
590
+ if orientation_abbr not in self.scl[key].columns:
591
+ raise ValueError(f"Invalid orientation: {orientation}")
592
+ if hour not in self.scl[key].index:
593
+ raise ValueError(f"Invalid hour: {hour}")
594
+ return float(self.scl[key].loc[hour, orientation_abbr])
595
+
596
+ def get_clf_lights(self, zone_type: str, hours_on: str, hour: int) -> float:
597
+ """Get CLF value for lights."""
598
+ key = f"{zone_type}_{hours_on}"
599
+ if key not in self.clf_lights.columns:
600
+ raise ValueError(f"Invalid zone type or hours: {key}")
601
+ if hour not in self.clf_lights.index:
602
+ raise ValueError(f"Invalid hour: {hour}")
603
+ return float(self.clf_lights.loc[hour, key])
604
+
605
+ def get_clf_people(self, zone_type: str, hours_occupied: str, hour: int) -> float:
606
+ """Get CLF value for people."""
607
+ key = f"{zone_type}_{hours_occupied}"
608
+ if key not in self.clf_people.columns:
609
+ raise ValueError(f"Invalid zone type or hours: {key}")
610
+ if hour not in self.clf_people.index:
611
+ raise ValueError(f"Invalid hour: {hour}")
612
+ return float(self.clf_people.loc[hour, key])
613
+
614
+ def get_clf_equipment(self, zone_type: str, hours_operated: str, hour: int) -> float:
615
+ """Get CLF value for equipment."""
616
+ key = f"{zone_type}_{hours_operated}"
617
+ if key not in self.clf_equipment.columns:
618
+ raise ValueError(f"Invalid zone type or hours: {key}")
619
+ if hour not in self.clf_equipment.index:
620
+ raise ValueError(f"Invalid hour: {hour}")
621
+ return float(self.clf_equipment.loc[hour, key])
622
+
623
+ def get_thermal_property(self, material: str, property_type: str) -> float:
624
+ """
625
+ Get thermal property for a material.
626
+
627
+ Args:
628
+ material (str): Material name (e.g., 'Brick_4in').
629
+ property_type (str): Property to retrieve ('U_value', 'R_value', 'density').
630
+
631
+ Returns:
632
+ float: Value of the specified thermal property.
633
+
634
+ Raises:
635
+ ValueError: If material or property_type is invalid.
636
+ """
637
+ if material not in self.thermal_properties['material'].values:
638
+ raise ValueError(f"Invalid material: {material}")
639
+ if property_type not in ['U_value', 'R_value', 'density']:
640
+ raise ValueError(f"Invalid property type: {property_type}")
641
+ return float(self.thermal_properties.loc[self.thermal_properties['material'] == material, property_type].iloc[0])
642
+
643
+ def get_heat_gain(self, source: str) -> float:
644
+ """
645
+ Get heat gain value for an internal source.
646
+
647
+ Args:
648
+ source (str): Source type ('people_sensible', 'people_latent', 'lights', 'equipment').
649
+
650
+ Returns:
651
+ float: Heat gain value (Btu/h or Btu/h-ft²).
652
+
653
+ Raises:
654
+ ValueError: If source is invalid.
655
+ """
656
+ if source not in self.heat_gain['source'].values:
657
+ raise ValueError(f"Invalid source: {source}")
658
+ return float(self.heat_gain.loc[self.heat_gain['source'] == source, 'gain'].iloc[0])
659
+
660
+ def plot_cooling_load(self, cooling_loads: List[float], title: str = "Cooling Load Profile", filename: str = "cooling_load.png") -> None:
661
+ """
662
+ Plot the cooling load profile over 24 hours.
663
+
664
+ Args:
665
+ cooling_loads (List[float]): List of cooling load values for each hour.
666
+ title (str): Plot title.
667
+ filename (str): Output filename for the plot.
668
+ """
669
+ if len(cooling_loads) != 24:
670
+ raise ValueError("Cooling loads must contain 24 hourly values")
671
+
672
+ plt.figure(figsize=(10, 6))
673
+ hours = list(range(24))
674
+ plt.plot(hours, cooling_loads, marker='o', linestyle='-', color='b')
675
+ plt.title(title)
676
+ plt.xlabel("Hour of Day")
677
+ plt.ylabel("Cooling Load (Btu/h)")
678
+ plt.grid(True)
679
+ plt.xticks(hours)
680
+ plt.savefig(filename)
681
+ plt.close()
682
+
683
+ def calculate_corrected_cltd_wall(self, wall_group: str, orientation: str, hour: int, latitude: str, month: str, color: str, outdoor_temp: float, indoor_temp: float) -> float:
684
+ """
685
+ Calculate corrected CLTD for a wall with climatic corrections.
686
+
687
+ Args:
688
+ wall_group (str): Wall group (e.g., 'A', 'B', ..., 'H').
689
+ orientation (str): Wall orientation (e.g., 'North', 'East', etc.).
690
+ hour (int): Hour of the day (0-23).
691
+ latitude (str): Latitude (e.g., '32N').
692
+ month (str): Month (e.g., 'Jul').
693
+ color (str): Surface color ('Dark', 'Medium', 'Light').
694
+ outdoor_temp (float): Outdoor design temperature (°C).
695
+ indoor_temp (float): Indoor design temperature (°C).
696
+
697
+ Returns:
698
+ float: Corrected CLTD value (°C).
699
+
700
+ Raises:
701
+ ValueError: If inputs are invalid or correction fails.
702
+ """
703
+ valid, message = self._validate_cltd_inputs(wall_group, orientation, hour, latitude, month, color, is_wall=True)
704
+ if not valid:
705
+ raise ValueError(message)
706
+ try:
707
+ # Get base CLTD
708
+ base_cltd = self.get_cltd_wall(wall_group, orientation, hour)
709
+ # Apply climatic corrections
710
+ corrected_cltd = self._apply_climatic_corrections(base_cltd, latitude, month, color, outdoor_temp, indoor_temp)
711
+ return corrected_cltd
712
+ except Exception as e:
713
+ raise ValueError(f"Error calculating corrected CLTD for wall: {str(e)}")
714
+
715
+ def calculate_corrected_cltd_roof(self, roof_group: str, latitude: str, hour: int, month: str, color: str, outdoor_temp: float, indoor_temp: float) -> float:
716
+ """
717
+ Calculate corrected CLTD for a roof with climatic corrections.
718
+
719
+ Args:
720
+ roof_group (str): Roof group (e.g., 'A', 'B', ..., 'G').
721
+ latitude (str): Latitude (e.g., '24N', '36N', '48N').
722
+ hour (int): Hour of the day (0-23).
723
+ month (str): Month (e.g., 'Jul').
724
+ color (str): Surface color ('Dark', 'Medium', 'Light').
725
+ outdoor_temp (float): Outdoor design temperature (°C).
726
+ indoor_temp (float): Indoor design temperature (°C).
727
+
728
+ Returns:
729
+ float: Corrected CLTD value (°C).
730
+
731
+ Raises:
732
+ ValueError: If inputs are invalid or correction fails.
733
+ """
734
+ valid, message = self._validate_cltd_inputs(roof_group, 'Horizontal', hour, latitude, month, color, is_wall=False)
735
+ if not valid:
736
+ raise ValueError(message)
737
+ try:
738
+ # Get base CLTD
739
+ base_cltd = self.get_cltd_roof(roof_group, latitude, hour)
740
+ # Apply climatic corrections
741
+ corrected_cltd = self._apply_climatic_corrections(base_cltd, latitude, month, color, outdoor_temp, indoor_temp)
742
+ return corrected_cltd
743
+ except Exception as e:
744
+ raise ValueError(f"Error calculating corrected CLTD for roof: {str(e)}")
data/building_components.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Building component data models for HVAC Load Calculator.
3
+ This module defines the data structures for walls, roofs, floors, windows, doors, and other building components.
4
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.2.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from typing import List, Dict, Optional, Union
10
+ import numpy as np
11
+ from data.drapery import Drapery
12
+
13
+
14
+ class Orientation(Enum):
15
+ """Enumeration for building component orientations."""
16
+ NORTH = "NORTH"
17
+ NORTHEAST = "NORTHEAST"
18
+ EAST = "EAST"
19
+ SOUTHEAST = "SOUTHEAST"
20
+ SOUTH = "SOUTH"
21
+ SOUTHWEST = "SOUTHWEST"
22
+ WEST = "WEST"
23
+ NORTHWEST = "NORTHWEST"
24
+ HORIZONTAL = "HORIZONTAL" # For roofs and floors
25
+ NOT_APPLICABLE = "N/A" # For components without orientation
26
+
27
+
28
+ class ComponentType(Enum):
29
+ """Enumeration for building component types."""
30
+ WALL = "WALL"
31
+ ROOF = "ROOF"
32
+ FLOOR = "FLOOR"
33
+ WINDOW = "WINDOW"
34
+ DOOR = "DOOR"
35
+ SKYLIGHT = "SKYLIGHT"
36
+
37
+
38
+ class MaterialLayer:
39
+ """Class representing a single material layer in a building component."""
40
+
41
+ def __init__(self, name: str, thickness: float, conductivity: float,
42
+ density: float = None, specific_heat: float = None):
43
+ """
44
+ Initialize a material layer.
45
+
46
+ Args:
47
+ name: Name of the material
48
+ thickness: Thickness of the layer in meters
49
+ conductivity: Thermal conductivity in W/(m·K)
50
+ density: Density in kg/m³ (optional)
51
+ specific_heat: Specific heat capacity in J/(kg·K) (optional)
52
+ """
53
+ self.name = name
54
+ self.thickness = thickness # m
55
+ self.conductivity = conductivity # W/(m·K)
56
+ self.density = density # kg/m³
57
+ self.specific_heat = specific_heat # J/(kg·K)
58
+
59
+ @property
60
+ def r_value(self) -> float:
61
+ """Calculate the thermal resistance (R-value) of the layer in m²·K/W."""
62
+ if self.conductivity == 0:
63
+ return float('inf') # Avoid division by zero
64
+ return self.thickness / self.conductivity
65
+
66
+ @property
67
+ def thermal_mass(self) -> Optional[float]:
68
+ """Calculate the thermal mass of the layer in J/(m²·K)."""
69
+ if self.density is None or self.specific_heat is None:
70
+ return None
71
+ return self.thickness * self.density * self.specific_heat
72
+
73
+ def to_dict(self) -> Dict:
74
+ """Convert the material layer to a dictionary."""
75
+ return {
76
+ "name": self.name,
77
+ "thickness": self.thickness,
78
+ "conductivity": self.conductivity,
79
+ "density": self.density,
80
+ "specific_heat": self.specific_heat,
81
+ "r_value": self.r_value,
82
+ "thermal_mass": self.thermal_mass
83
+ }
84
+
85
+
86
+ @dataclass
87
+ class BuildingComponent:
88
+ """Base class for all building components."""
89
+
90
+ id: str
91
+ name: str
92
+ component_type: ComponentType
93
+ u_value: float # W/(m²·K)
94
+ area: float # m²
95
+ orientation: Orientation = Orientation.NOT_APPLICABLE
96
+ color: str = "Medium" # Light, Medium, Dark
97
+ material_layers: List[MaterialLayer] = field(default_factory=list)
98
+
99
+ def __post_init__(self):
100
+ """Validate component data after initialization."""
101
+ if self.area <= 0:
102
+ raise ValueError("Area must be greater than zero")
103
+ if self.u_value < 0:
104
+ raise ValueError("U-value cannot be negative")
105
+
106
+ @property
107
+ def r_value(self) -> float:
108
+ """Calculate the total thermal resistance (R-value) in m²·K/W."""
109
+ return 1 / self.u_value if self.u_value > 0 else float('inf')
110
+
111
+ @property
112
+ def total_r_value_from_layers(self) -> Optional[float]:
113
+ """Calculate the total R-value from material layers if available."""
114
+ if not self.material_layers:
115
+ return None
116
+
117
+ # Add surface resistances (interior and exterior)
118
+ r_si = 0.13 # m²·K/W (interior surface resistance)
119
+ r_se = 0.04 # m²·K/W (exterior surface resistance)
120
+
121
+ # Sum the R-values of all layers
122
+ r_layers = sum(layer.r_value for layer in self.material_layers)
123
+
124
+ return r_si + r_layers + r_se
125
+
126
+ @property
127
+ def calculated_u_value(self) -> Optional[float]:
128
+ """Calculate U-value from material layers if available."""
129
+ total_r = self.total_r_value_from_layers
130
+ if total_r is None or total_r == 0:
131
+ return None
132
+ return 1 / total_r
133
+
134
+ def heat_transfer_rate(self, delta_t: float) -> float:
135
+ """
136
+ Calculate heat transfer rate through the component.
137
+
138
+ Args:
139
+ delta_t: Temperature difference across the component in K or °C
140
+
141
+ Returns:
142
+ Heat transfer rate in Watts
143
+ """
144
+ return self.u_value * self.area * delta_t
145
+
146
+ def to_dict(self) -> Dict:
147
+ """Convert the building component to a dictionary."""
148
+ return {
149
+ "id": self.id,
150
+ "name": self.name,
151
+ "component_type": self.component_type.value,
152
+ "u_value": self.u_value,
153
+ "area": self.area,
154
+ "orientation": self.orientation.value,
155
+ "color": self.color,
156
+ "r_value": self.r_value,
157
+ "material_layers": [layer.to_dict() for layer in self.material_layers],
158
+ "calculated_u_value": self.calculated_u_value,
159
+ "total_r_value_from_layers": self.total_r_value_from_layers
160
+ }
161
+
162
+
163
+ @dataclass
164
+ class Wall(BuildingComponent):
165
+ """Class representing a wall component."""
166
+
167
+ VALID_WALL_GROUPS = {"A", "B", "C", "D", "E", "F", "G", "H"} # ASHRAE wall groups for CLTD
168
+
169
+ has_sun_exposure: bool = True
170
+ wall_type: str = "Custom" # Brick, Concrete, Wood Frame, etc.
171
+ wall_group: str = "A" # ASHRAE wall group (A, B, C, D, E, F, G, H)
172
+ gross_area: float = None # m² (before subtracting windows/doors)
173
+ net_area: float = None # m² (after subtracting windows/doors)
174
+ windows: List[str] = field(default_factory=list) # List of window IDs
175
+ doors: List[str] = field(default_factory=list) # List of door IDs
176
+
177
+ def __post_init__(self):
178
+ """Initialize wall-specific attributes."""
179
+ super().__post_init__()
180
+ self.component_type = ComponentType.WALL
181
+
182
+ # Validate wall_group
183
+ if self.wall_group not in self.VALID_WALL_GROUPS:
184
+ raise ValueError(f"Invalid wall_group: {self.wall_group}. Must be one of {self.VALID_WALL_GROUPS}")
185
+
186
+ # Set net area equal to area if not specified
187
+ if self.net_area is None:
188
+ self.net_area = self.area
189
+
190
+ # Set gross area equal to net area if not specified
191
+ if self.gross_area is None:
192
+ self.gross_area = self.net_area
193
+
194
+ def update_net_area(self, window_areas: Dict[str, float], door_areas: Dict[str, float]):
195
+ """
196
+ Update the net wall area by subtracting windows and doors.
197
+
198
+ Args:
199
+ window_areas: Dictionary mapping window IDs to areas
200
+ door_areas: Dictionary mapping door IDs to areas
201
+ """
202
+ total_window_area = sum(window_areas.get(window_id, 0) for window_id in self.windows)
203
+ total_door_area = sum(door_areas.get(door_id, 0) for door_id in self.doors)
204
+
205
+ self.net_area = self.gross_area - total_window_area - total_door_area
206
+ self.area = self.net_area # Update the main area property
207
+
208
+ if self.net_area <= 0:
209
+ raise ValueError("Net wall area cannot be negative or zero")
210
+
211
+ def to_dict(self) -> Dict:
212
+ """Convert the wall to a dictionary."""
213
+ wall_dict = super().to_dict()
214
+ wall_dict.update({
215
+ "has_sun_exposure": self.has_sun_exposure,
216
+ "wall_type": self.wall_type,
217
+ "wall_group": self.wall_group,
218
+ "gross_area": self.gross_area,
219
+ "net_area": self.net_area,
220
+ "windows": self.windows,
221
+ "doors": self.doors
222
+ })
223
+ return wall_dict
224
+
225
+
226
+ @dataclass
227
+ class Roof(BuildingComponent):
228
+ """Class representing a roof component."""
229
+
230
+ VALID_ROOF_GROUPS = {"A", "B", "C", "D", "E", "F", "G"} # ASHRAE roof groups for CLTD
231
+
232
+ roof_type: str = "Custom" # Flat, Pitched, etc.
233
+ roof_group: str = "A" # ASHRAE roof group
234
+ pitch: float = 0.0 # Roof pitch in degrees
235
+ has_suspended_ceiling: bool = False
236
+ ceiling_plenum_height: float = 0.0 # m
237
+
238
+ def __post_init__(self):
239
+ """Initialize roof-specific attributes."""
240
+ super().__post_init__()
241
+ self.component_type = ComponentType.ROOF
242
+ self.orientation = Orientation.HORIZONTAL
243
+
244
+ # Validate roof_group
245
+ if self.roof_group not in self.VALID_ROOF_GROUPS:
246
+ raise ValueError(f"Invalid roof_group: {self.roof_group}. Must be one of {self.VALID_ROOF_GROUPS}")
247
+
248
+ def to_dict(self) -> Dict:
249
+ """Convert the roof to a dictionary."""
250
+ roof_dict = super().to_dict()
251
+ roof_dict.update({
252
+ "roof_type": self.roof_type,
253
+ "roof_group": self.roof_group,
254
+ "pitch": self.pitch,
255
+ "has_suspended_ceiling": self.has_suspended_ceiling,
256
+ "ceiling_plenum_height": self.ceiling_plenum_height
257
+ })
258
+ return roof_dict
259
+
260
+
261
+ @dataclass
262
+ class Floor(BuildingComponent):
263
+ """Class representing a floor component."""
264
+
265
+ floor_type: str = "Custom" # Slab-on-grade, Raised, etc.
266
+ is_ground_contact: bool = False
267
+ perimeter_length: float = 0.0 # m (for slab-on-grade floors)
268
+ insulated: bool = False # Added to indicate insulation status
269
+ ground_temperature_c: float = None # Added for ground temperature in °C
270
+
271
+ def __post_init__(self):
272
+ """Initialize floor-specific attributes."""
273
+ super().__post_init__()
274
+ self.component_type = ComponentType.FLOOR
275
+ self.orientation = Orientation.HORIZONTAL
276
+
277
+ def to_dict(self) -> Dict:
278
+ """Convert the floor to a dictionary."""
279
+ floor_dict = super().to_dict()
280
+ floor_dict.update({
281
+ "floor_type": self.floor_type,
282
+ "is_ground_contact": self.is_ground_contact,
283
+ "perimeter_length": self.perimeter_length,
284
+ "insulated": self.insulated,
285
+ "ground_temperature_c": self.ground_temperature_c
286
+ })
287
+ return floor_dict
288
+
289
+
290
+ @dataclass
291
+ class Fenestration(BuildingComponent):
292
+ """Base class for fenestration components (windows, doors, skylights)."""
293
+
294
+ shgc: float = 0.7 # Solar Heat Gain Coefficient
295
+ vt: float = 0.7 # Visible Transmittance
296
+ frame_type: str = "Aluminum" # Aluminum, Wood, Vinyl, etc.
297
+ frame_width: float = 0.05 # m
298
+ has_shading: bool = False
299
+ shading_type: str = None # Internal, External, Between-glass
300
+ shading_coefficient: float = 1.0 # 0-1 (1 = no shading)
301
+
302
+ def __post_init__(self):
303
+ """Initialize fenestration-specific attributes."""
304
+ super().__post_init__()
305
+
306
+ if self.shgc < 0 or self.shgc > 1:
307
+ raise ValueError("SHGC must be between 0 and 1")
308
+ if self.vt < 0 or self.vt > 1:
309
+ raise ValueError("VT must be between 0 and 1")
310
+ if self.shading_coefficient < 0 or self.shading_coefficient > 1:
311
+ raise ValueError("Shading coefficient must be between 0 and 1")
312
+
313
+ @property
314
+ def effective_shgc(self) -> float:
315
+ """Calculate the effective SHGC considering shading."""
316
+ return self.shgc * self.shading_coefficient
317
+
318
+ def to_dict(self) -> Dict:
319
+ """Convert the fenestration to a dictionary."""
320
+ fenestration_dict = super().to_dict()
321
+ fenestration_dict.update({
322
+ "shgc": self.shgc,
323
+ "vt": self.vt,
324
+ "frame_type": self.frame_type,
325
+ "frame_width": self.frame_width,
326
+ "has_shading": self.has_shading,
327
+ "shading_type": self.shading_type,
328
+ "shading_coefficient": self.shading_coefficient,
329
+ "effective_shgc": self.effective_shgc
330
+ })
331
+ return fenestration_dict
332
+
333
+
334
+ @dataclass
335
+ class Window(Fenestration):
336
+ """Class representing a window component."""
337
+
338
+ window_type: str = "Custom" # Single, Double, Triple glazed, etc.
339
+ glazing_layers: int = 2 # Number of glazing layers
340
+ gas_fill: str = "Air" # Air, Argon, Krypton, etc.
341
+ low_e_coating: bool = False
342
+ width: float = 1.0 # m
343
+ height: float = 1.0 # m
344
+ wall_id: str = None # ID of the wall containing this window
345
+ drapery: Optional[Drapery] = None # Drapery object
346
+
347
+ def __post_init__(self):
348
+ """Initialize window-specific attributes."""
349
+ super().__post_init__()
350
+ self.component_type = ComponentType.WINDOW
351
+
352
+ # Calculate area from width and height if not provided
353
+ if self.area <= 0 and self.width > 0 and self.height > 0:
354
+ self.area = self.width * self.height
355
+
356
+ # Initialize drapery if not provided
357
+ if self.drapery is None:
358
+ self.drapery = Drapery(enabled=False)
359
+
360
+ @classmethod
361
+ def from_classification(cls, id: str, name: str, u_value: float, area: float,
362
+ shgc: float, orientation: Orientation, wall_id: str,
363
+ drapery_classification: str, fullness: float = 1.0, **kwargs) -> 'Window':
364
+ """
365
+ Create window object with drapery from ASHRAE classification.
366
+
367
+ Args:
368
+ id: Unique identifier
369
+ name: Window name
370
+ u_value: Window U-value in W/m²K
371
+ area: Window area in m²
372
+ shgc: Solar Heat Gain Coefficient (0-1)
373
+ orientation: Window orientation
374
+ wall_id: ID of the wall containing this window
375
+ drapery_classification: ASHRAE drapery classification (e.g., ID, IM, IIL)
376
+ fullness: Fullness factor (0-2)
377
+ **kwargs: Additional arguments for Window attributes
378
+
379
+ Returns:
380
+ Window object
381
+ """
382
+ drapery = Drapery.from_classification(drapery_classification, fullness)
383
+ return cls(
384
+ id=id,
385
+ name=name,
386
+ component_type=ComponentType.WINDOW,
387
+ u_value=u_value,
388
+ area=area,
389
+ shgc=shgc,
390
+ orientation=orientation,
391
+ drapery=drapery,
392
+ wall_id=wall_id,
393
+ **kwargs
394
+ )
395
+
396
+ def get_effective_u_value(self) -> float:
397
+ """Get effective U-value with drapery adjustment."""
398
+ if self.drapery and self.drapery.enabled:
399
+ return self.drapery.calculate_u_value_adjustment(self.u_value)
400
+ return self.u_value
401
+
402
+ def get_shading_coefficient(self) -> float:
403
+ """Get shading coefficient with drapery."""
404
+ if self.drapery and self.drapery.enabled:
405
+ return self.drapery.calculate_shading_coefficient(self.shgc)
406
+ return self.shading_coefficient
407
+
408
+ def get_iac(self) -> float:
409
+ """Get Interior Attenuation Coefficient with drapery."""
410
+ if self.drapery and self.drapery.enabled:
411
+ return self.drapery.calculate_iac(self.shgc)
412
+ return 1.0 # No attenuation
413
+
414
+ def to_dict(self) -> Dict:
415
+ """Convert the window to a dictionary."""
416
+ window_dict = super().to_dict()
417
+ window_dict.update({
418
+ "window_type": self.window_type,
419
+ "glazing_layers": self.glazing_layers,
420
+ "gas_fill": self.gas_fill,
421
+ "low_e_coating": self.low_e_coating,
422
+ "width": self.width,
423
+ "height": self.height,
424
+ "wall_id": self.wall_id,
425
+ "drapery": self.drapery.to_dict() if self.drapery else None,
426
+ "drapery_classification": self.drapery.get_classification() if self.drapery and self.drapery.enabled else None
427
+ })
428
+ return window_dict
429
+
430
+
431
+ @dataclass
432
+ class Door(Fenestration):
433
+ """Class representing a door component."""
434
+
435
+ door_type: str = "Custom" # Solid, Partially glazed, etc.
436
+ glazing_percentage: float = 0.0 # Percentage of door area that is glazed (0-100)
437
+ width: float = 0.9 # m
438
+ height: float = 2.1 # m
439
+ wall_id: str = None # ID of the wall containing this door
440
+
441
+ def __post_init__(self):
442
+ """Initialize door-specific attributes."""
443
+ super().__post_init__()
444
+ self.component_type = ComponentType.DOOR
445
+
446
+ # Calculate area from width and height if not provided
447
+ if self.area <= 0 and self.width > 0 and self.height > 0:
448
+ self.area = self.width * self.height
449
+
450
+ if self.glazing_percentage < 0 or self.glazing_percentage > 100:
451
+ raise ValueError("Glazing percentage must be between 0 and 100")
452
+
453
+ @property
454
+ def glazing_area(self) -> float:
455
+ """Calculate the glazed area of the door in m²."""
456
+ return self.area * (self.glazing_percentage / 100)
457
+
458
+ @property
459
+ def opaque_area(self) -> float:
460
+ """Calculate the opaque area of the door in m²."""
461
+ return self.area - self.glazing_area
462
+
463
+ def to_dict(self) -> Dict:
464
+ """Convert the door to a dictionary."""
465
+ door_dict = super().to_dict()
466
+ door_dict.update({
467
+ "door_type": self.door_type,
468
+ "glazing_percentage": self.glazing_percentage,
469
+ "width": self.width,
470
+ "height": self.height,
471
+ "wall_id": self.wall_id,
472
+ "glazing_area": self.glazing_area,
473
+ "opaque_area": self.opaque_area
474
+ })
475
+ return door_dict
476
+
477
+
478
+ @dataclass
479
+ class Skylight(Fenestration):
480
+ """Class representing a skylight component."""
481
+
482
+ skylight_type: str = "Custom" # Flat, Domed, etc.
483
+ glazing_layers: int = 2 # Number of glazing layers
484
+ gas_fill: str = "Air" # Air, Argon, Krypton, etc.
485
+ low_e_coating: bool = False
486
+ width: float = 1.0 # m
487
+ length: float = 1.0 # m
488
+ roof_id: str = None # ID of the roof containing this skylight
489
+
490
+ def __post_init__(self):
491
+ """Initialize skylight-specific attributes."""
492
+ super().__post_init__()
493
+ self.component_type = ComponentType.SKYLIGHT
494
+ self.orientation = Orientation.HORIZONTAL
495
+
496
+ # Calculate area from width and length if not provided
497
+ if self.area <= 0 and self.width > 0 and self.length > 0:
498
+ self.area = self.width * self.length
499
+
500
+ def to_dict(self) -> Dict:
501
+ """Convert the skylight to a dictionary."""
502
+ skylight_dict = super().to_dict()
503
+ skylight_dict.update({
504
+ "skylight_type": self.skylight_type,
505
+ "glazing_layers": self.glazing_layers,
506
+ "gas_fill": self.gas_fill,
507
+ "low_e_coating": self.low_e_coating,
508
+ "width": self.width,
509
+ "length": self.length,
510
+ "roof_id": self.roof_id
511
+ })
512
+ return skylight_dict
513
+
514
+
515
+ class BuildingComponentFactory:
516
+ """Factory class for creating building components."""
517
+
518
+ @staticmethod
519
+ def create_component(component_data: Dict) -> BuildingComponent:
520
+ """
521
+ Create a building component from a dictionary of data.
522
+
523
+ Args:
524
+ component_data: Dictionary containing component data
525
+
526
+ Returns:
527
+ A BuildingComponent object of the appropriate type
528
+ """
529
+ component_type = component_data.get("component_type")
530
+
531
+ # Convert string component_type to ComponentType enum
532
+ if isinstance(component_type, str):
533
+ component_type = ComponentType[component_type]
534
+
535
+ # Handle drapery for Window components
536
+ if component_type == ComponentType.WINDOW:
537
+ drapery_data = component_data.pop("drapery", None)
538
+ drapery_classification = component_data.pop("drapery_classification", None)
539
+ if drapery_classification:
540
+ fullness = drapery_data.get("fullness", 1.0) if drapery_data else 1.0
541
+ component_data["drapery"] = Drapery.from_classification(drapery_classification, fullness)
542
+ elif drapery_data:
543
+ component_data["drapery"] = Drapery.from_dict(drapery_data)
544
+
545
+ # Convert orientation to Orientation enum
546
+ if "orientation" in component_data and isinstance(component_data["orientation"], str):
547
+ component_data["orientation"] = Orientation[component_data["orientation"]]
548
+
549
+ # Convert material_layers to MaterialLayer objects
550
+ if "material_layers" in component_data:
551
+ component_data["material_layers"] = [
552
+ MaterialLayer(**layer) for layer in component_data["material_layers"]
553
+ ]
554
+
555
+ if component_type == ComponentType.WALL:
556
+ return Wall(**component_data)
557
+ elif component_type == ComponentType.ROOF:
558
+ return Roof(**component_data)
559
+ elif component_type == ComponentType.FLOOR:
560
+ return Floor(**component_data)
561
+ elif component_type == ComponentType.WINDOW:
562
+ return Window(**component_data)
563
+ elif component_type == ComponentType.DOOR:
564
+ return Door(**component_data)
565
+ elif component_type == ComponentType.SKYLIGHT:
566
+ return Skylight(**component_data)
567
+ else:
568
+ raise ValueError(f"Unknown component type: {component_type}")
data/climate_data.py ADDED
@@ -0,0 +1,599 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ASHRAE 169 climate data module for HVAC Load Calculator.
3
+ This module provides access to climate data for various locations based on ASHRAE 169 standard.
4
+
5
+ Author: Dr Majed Abuseif
6
+ Date: March 2025
7
+ Version: 1.0.0
8
+ """
9
+
10
+ from typing import Dict, List, Any, Optional
11
+ import pandas as pd
12
+ import numpy as np
13
+ import os
14
+ import json
15
+ from dataclasses import dataclass
16
+ import streamlit as st
17
+ import plotly.graph_objects as go
18
+ from io import StringIO
19
+
20
+ # Define paths
21
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
22
+
23
+ @dataclass
24
+ class ClimateLocation:
25
+ """Class representing a climate location with ASHRAE 169 data."""
26
+
27
+ id: str
28
+ country: str
29
+ state_province: str
30
+ city: str
31
+ latitude: float
32
+ longitude: float
33
+ elevation: float # meters
34
+ climate_zone: str
35
+ heating_degree_days: float # base 18°C
36
+ cooling_degree_days: float # base 18°C
37
+ winter_design_temp: float # 99.6% heating design temperature (°C)
38
+ summer_design_temp_db: float # 0.4% cooling design dry-bulb temperature (°C)
39
+ summer_design_temp_wb: float # 0.4% cooling design wet-bulb temperature (°C)
40
+ summer_daily_range: float # Mean daily temperature range in summer (°C)
41
+ monthly_temps: Dict[str, float] # Average monthly temperatures (°C)
42
+ monthly_humidity: Dict[str, float] # Average monthly relative humidity (%)
43
+
44
+ def to_dict(self) -> Dict[str, Any]:
45
+ """Convert the climate location to a dictionary."""
46
+ return {
47
+ "id": self.id,
48
+ "country": self.country,
49
+ "state_province": self.state_province,
50
+ "city": self.city,
51
+ "latitude": self.latitude,
52
+ "longitude": self.longitude,
53
+ "elevation": self.elevation,
54
+ "climate_zone": self.climate_zone,
55
+ "heating_degree_days": self.heating_degree_days,
56
+ "cooling_degree_days": self.cooling_degree_days,
57
+ "winter_design_temp": self.winter_design_temp,
58
+ "summer_design_temp_db": self.summer_design_temp_db,
59
+ "summer_design_temp_wb": self.summer_design_temp_wb,
60
+ "summer_daily_range": self.summer_daily_range,
61
+ "monthly_temps": self.monthly_temps,
62
+ "monthly_humidity": self.monthly_humidity
63
+ }
64
+
65
+ class ClimateData:
66
+ """Class for managing ASHRAE 169 climate data."""
67
+
68
+ def __init__(self):
69
+ """Initialize climate data."""
70
+ self.locations = {}
71
+ self.countries = []
72
+ self.country_states = {}
73
+
74
+ def _group_locations_by_country_state(self) -> Dict[str, Dict[str, List[str]]]:
75
+ """Group locations by country and state/province."""
76
+ result = {}
77
+ for loc in self.locations.values():
78
+ if loc.country not in result:
79
+ result[loc.country] = {}
80
+ if loc.state_province not in result[loc.country]:
81
+ result[loc.country][loc.state_province] = []
82
+ result[loc.country][loc.state_province].append(loc.city)
83
+ for country in result:
84
+ for state in result[country]:
85
+ result[country][state] = sorted(result[country][state])
86
+ return result
87
+
88
+ def add_location(self, location: ClimateLocation):
89
+ """Add a new location to the dictionary."""
90
+ self.locations[location.id] = location
91
+ self.countries = sorted(list(set(loc.country for loc in self.locations.values())))
92
+ self.country_states = self._group_locations_by_country_state()
93
+
94
+ def get_location_by_id(self, location_id: str, session_state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
95
+ """Retrieve climate data by ID from session state or locations."""
96
+ if "climate_data" in session_state and session_state["climate_data"].get("id") == location_id:
97
+ return session_state["climate_data"]
98
+ if location_id in self.locations:
99
+ return self.locations[location_id].to_dict()
100
+ return None
101
+
102
+ @staticmethod
103
+ def validate_climate_data(data: Dict[str, Any]) -> bool:
104
+ """Validate climate data for required fields and ranges."""
105
+ required_fields = [
106
+ "id", "country", "city", "latitude", "longitude", "elevation",
107
+ "climate_zone", "heating_degree_days", "cooling_degree_days",
108
+ "winter_design_temp", "summer_design_temp_db", "summer_design_temp_wb",
109
+ "summer_daily_range", "monthly_temps", "monthly_humidity"
110
+ ]
111
+ month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
112
+
113
+ for field in required_fields:
114
+ if field not in data:
115
+ return False
116
+
117
+ if not (-90 <= data["latitude"] <= 90 and -180 <= data["longitude"] <= 180):
118
+ return False
119
+ if data["elevation"] < 0:
120
+ return False
121
+ if data["climate_zone"] not in ["0A", "0B", "1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"]:
122
+ return False
123
+ if not (data["heating_degree_days"] >= 0 and data["cooling_degree_days"] >= 0):
124
+ return False
125
+ if not (-50 <= data["winter_design_temp"] <= 20):
126
+ return False
127
+ if not (0 <= data["summer_design_temp_db"] <= 50 and 0 <= data["summer_design_temp_wb"] <= 40):
128
+ return False
129
+ if data["summer_daily_range"] < 0:
130
+ return False
131
+
132
+ for month in month_names:
133
+ if month not in data["monthly_temps"] or month not in data["monthly_humidity"]:
134
+ return False
135
+ if not (-50 <= data["monthly_temps"][month] <= 50):
136
+ return False
137
+ if not (0 <= data["monthly_humidity"][month] <= 100):
138
+ return False
139
+
140
+ return True
141
+
142
+ @staticmethod
143
+ def calculate_wet_bulb(dry_bulb: np.ndarray, relative_humidity: np.ndarray) -> np.ndarray:
144
+ """Calculate Wet Bulb Temperature using Stull (2011) approximation."""
145
+ db = np.array(dry_bulb, dtype=float)
146
+ rh = np.array(relative_humidity, dtype=float)
147
+
148
+ term1 = db * np.arctan(0.151977 * (rh + 8.313659)**0.5)
149
+ term2 = np.arctan(db + rh)
150
+ term3 = np.arctan(rh - 1.676331)
151
+ term4 = 0.00391838 * rh**1.5 * np.arctan(0.023101 * rh)
152
+ term5 = -4.686035
153
+
154
+ wet_bulb = term1 + term2 - term3 + term4 + term5
155
+
156
+ invalid_mask = (rh < 5) | (rh > 99) | (db < -20) | (db > 50) | np.isnan(db) | np.isnan(rh)
157
+ wet_bulb[invalid_mask] = np.nan
158
+
159
+ return wet_bulb
160
+
161
+ def display_climate_input(self, session_state: Dict[str, Any]):
162
+ """Display form for manual input or EPW upload in Streamlit."""
163
+ st.title("Climate Data")
164
+
165
+ if not session_state.building_info.get("country") or not session_state.building_info.get("city"):
166
+ st.warning("Please enter country and city in Building Information first.")
167
+ st.button("Go to Building Information", on_click=lambda: setattr(session_state, "page", "Building Information"))
168
+ return
169
+
170
+ st.subheader(f"Location: {session_state.building_info['country']}, {session_state.building_info['city']}")
171
+ tab1, tab2 = st.tabs(["Manual Input", "Upload EPW File"])
172
+
173
+ # Manual Input Tab
174
+ with tab1:
175
+ with st.form("manual_climate_form"):
176
+ col1, col2 = st.columns(2)
177
+ with col1:
178
+ latitude = st.number_input(
179
+ "Latitude",
180
+ min_value=-90.0,
181
+ max_value=90.0,
182
+ value=0.0,
183
+ step=0.1,
184
+ help="Enter the latitude of the location in degrees (e.g., 64.1 for Reykjavik)"
185
+ )
186
+ longitude = st.number_input(
187
+ "Longitude",
188
+ min_value=-180.0,
189
+ max_value=180.0,
190
+ value=0.0,
191
+ step=0.1,
192
+ help="Enter the longitude of the location in degrees (e.g., -21.9 for Reykjavik)"
193
+ )
194
+ elevation = st.number_input(
195
+ "Elevation (m)",
196
+ min_value=0.0,
197
+ value=0.0,
198
+ step=10.0,
199
+ help="Enter the elevation of the location above sea level in meters"
200
+ )
201
+ climate_zone = st.selectbox(
202
+ "Climate Zone",
203
+ ["0A", "0B", "1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"],
204
+ help="Select the ASHRAE climate zone for the location (e.g., 6A for cold, humid climates)"
205
+ )
206
+
207
+ with col2:
208
+ hdd = st.number_input(
209
+ "Heating Degree Days (base 18°C)",
210
+ min_value=0.0,
211
+ value=0.0,
212
+ step=100.0,
213
+ help="Enter the annual heating degree days using an 18°C base temperature"
214
+ )
215
+ cdd = st.number_input(
216
+ "Cooling Degree Days (base 18°C)",
217
+ min_value=0.0,
218
+ value=0.0,
219
+ step=100.0,
220
+ help="Enter the annual cooling degree days using an 18°C base temperature"
221
+ )
222
+ winter_design_temp = st.number_input(
223
+ "Winter Design Temp (99.6%) (°C)",
224
+ min_value=-50.0,
225
+ max_value=20.0,
226
+ value=0.0,
227
+ step=0.5,
228
+ help="Enter the 99.6% winter design temperature in °C (extreme cold condition)"
229
+ )
230
+ summer_design_temp_db = st.number_input(
231
+ "Summer Design Temp DB (0.4%) (°C)",
232
+ min_value=0.0,
233
+ max_value=50.0,
234
+ value=35.0,
235
+ step=0.5,
236
+ help="Enter the 0.4% summer design dry-bulb temperature in °C (extreme hot condition)"
237
+ )
238
+ summer_design_temp_wb = st.number_input(
239
+ "Summer Design Temp WB (0.4%) (°C)",
240
+ min_value=0.0,
241
+ max_value=40.0,
242
+ value=25.0,
243
+ step=0.5,
244
+ help="Enter the 0.4% summer design wet-bulb temperature in °C (for humidity consideration)"
245
+ )
246
+ summer_daily_range = st.number_input(
247
+ "Summer Daily Range (°C)",
248
+ min_value=0.0,
249
+ value=5.0,
250
+ step=0.5,
251
+ help="Enter the average daily temperature range in summer in °C"
252
+ )
253
+
254
+ # Monthly Data with clear titles (no help added here)
255
+ monthly_temps = {}
256
+ monthly_humidity = {}
257
+ month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
258
+
259
+ st.subheader("Monthly Temperatures")
260
+ col1, col2 = st.columns(2)
261
+ with col1:
262
+ for month in month_names[:6]:
263
+ monthly_temps[month] = st.number_input(f"{month} Temp (°C)", min_value=-50.0, max_value=50.0, value=20.0, step=0.5, key=f"temp_{month}")
264
+ with col2:
265
+ for month in month_names[6:]:
266
+ monthly_temps[month] = st.number_input(f"{month} Temp (°C)", min_value=-50.0, max_value=50.0, value=20.0, step=0.5, key=f"temp_{month}")
267
+
268
+ st.subheader("Monthly Humidity")
269
+ col1, col2 = st.columns(2)
270
+ with col1:
271
+ for month in month_names[:6]:
272
+ monthly_humidity[month] = st.number_input(f"{month} Humidity (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key=f"hum_{month}")
273
+ with col2:
274
+ for month in month_names[6:]:
275
+ monthly_humidity[month] = st.number_input(f"{month} Humidity (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key=f"hum_{month}")
276
+
277
+ if st.form_submit_button("Save Climate Data"):
278
+ try:
279
+ # Generate ID internally using country and city from session_state
280
+ generated_id = f"{session_state.building_info['country'][:2].upper()}-{session_state.building_info['city'][:3].upper()}"
281
+ location = ClimateLocation(
282
+ id=generated_id,
283
+ country=session_state.building_info["country"],
284
+ state_province="N/A", # Default since input removed
285
+ city=session_state.building_info["city"],
286
+ latitude=latitude,
287
+ longitude=longitude,
288
+ elevation=elevation,
289
+ climate_zone=climate_zone,
290
+ heating_degree_days=hdd,
291
+ cooling_degree_days=cdd,
292
+ winter_design_temp=winter_design_temp,
293
+ summer_design_temp_db=summer_design_temp_db,
294
+ summer_design_temp_wb=summer_design_temp_wb,
295
+ summer_daily_range=summer_daily_range,
296
+ monthly_temps=monthly_temps,
297
+ monthly_humidity=monthly_humidity
298
+ )
299
+ self.add_location(location)
300
+ climate_data_dict = location.to_dict()
301
+ if not self.validate_climate_data(climate_data_dict):
302
+ raise ValueError("Invalid climate data. Please check all inputs.")
303
+ session_state["climate_data"] = climate_data_dict # Save to session state
304
+ st.success("Climate data saved manually!")
305
+ st.write(f"Debug: Saved climate data for {location.city} (ID: {location.id}): {climate_data_dict}") # Debug
306
+ self.display_design_conditions(location)
307
+ self.visualize_data(location, epw_data=None)
308
+ except Exception as e:
309
+ st.error(f"Error saving climate data: {str(e)}. Please check inputs and try again.")
310
+
311
+ # EPW Upload Tab
312
+ with tab2:
313
+ uploaded_file = st.file_uploader("Upload EPW File", type=["epw"])
314
+ if uploaded_file:
315
+ try:
316
+ epw_content = uploaded_file.read().decode("utf-8")
317
+ epw_lines = epw_content.splitlines()
318
+ header = next(line for line in epw_lines if line.startswith("LOCATION"))
319
+ header_parts = header.split(",")
320
+ latitude = float(header_parts[6])
321
+ longitude = float(header_parts[7])
322
+ elevation = float(header_parts[8])
323
+
324
+ data_start_idx = next(i for i, line in enumerate(epw_lines) if line.startswith("DATA PERIODS")) + 1
325
+ epw_data = pd.read_csv(StringIO("\n".join(epw_lines[data_start_idx:])), header=None, dtype=str)
326
+ if len(epw_data) != 8760:
327
+ raise ValueError(f"EPW file has {len(epw_data)} records, expected 8760.")
328
+
329
+ for col in epw_data.columns:
330
+ epw_data[col] = pd.to_numeric(epw_data[col], errors='coerce')
331
+
332
+ months = epw_data[1].values # Month
333
+ dry_bulb = epw_data[6].values # Dry-bulb temperature (°C)
334
+ humidity = epw_data[8].values # Relative humidity (%)
335
+ pressure = epw_data[9].values # Atmospheric pressure (Pa)
336
+
337
+ wet_bulb = self.calculate_wet_bulb(dry_bulb, humidity)
338
+
339
+ if np.all(np.isnan(dry_bulb)) or np.all(np.isnan(humidity)) or np.all(np.isnan(wet_bulb)):
340
+ raise ValueError("Dry bulb, humidity, or calculated wet bulb data is entirely NaN.")
341
+
342
+ daily_temps = np.nanmean(dry_bulb.reshape(-1, 24), axis=1)
343
+ hdd = round(np.nansum(np.maximum(18 - daily_temps, 0)))
344
+ cdd = round(np.nansum(np.maximum(daily_temps - 18, 0)))
345
+
346
+ winter_design_temp = round(np.nanpercentile(dry_bulb, 0.4), 1)
347
+ summer_design_temp_db = round(np.nanpercentile(dry_bulb, 99.6), 1)
348
+ summer_design_temp_wb = round(np.nanpercentile(wet_bulb, 99.6), 1)
349
+ summer_mask = (months >= 6) & (months <= 8)
350
+ summer_temps = dry_bulb[summer_mask].reshape(-1, 24)
351
+ summer_daily_range = round(np.nanmean(np.nanmax(summer_temps, axis=1) - np.nanmin(summer_temps, axis=1)), 1)
352
+
353
+ monthly_temps = {}
354
+ monthly_humidity = {}
355
+ month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
356
+ for i in range(1, 13):
357
+ month_mask = (months == i)
358
+ monthly_temps[month_names[i-1]] = round(np.nanmean(dry_bulb[month_mask]), 1)
359
+ monthly_humidity[month_names[i-1]] = round(np.nanmean(humidity[month_mask]), 1)
360
+
361
+ avg_humidity = np.nanmean(humidity)
362
+ climate_zone = self.assign_climate_zone(hdd, cdd, avg_humidity)
363
+
364
+ location = ClimateLocation(
365
+ id=f"{session_state.building_info['country'][:2].upper()}-{session_state.building_info['city'][:3].upper()}",
366
+ country=session_state.building_info["country"],
367
+ state_province="N/A",
368
+ city=session_state.building_info["city"],
369
+ latitude=latitude,
370
+ longitude=longitude,
371
+ elevation=elevation,
372
+ climate_zone=climate_zone,
373
+ heating_degree_days=hdd,
374
+ cooling_degree_days=cdd,
375
+ winter_design_temp=winter_design_temp,
376
+ summer_design_temp_db=summer_design_temp_db,
377
+ summer_design_temp_wb=summer_design_temp_wb,
378
+ summer_daily_range=summer_daily_range,
379
+ monthly_temps=monthly_temps,
380
+ monthly_humidity=monthly_humidity
381
+ )
382
+ self.add_location(location)
383
+ climate_data_dict = location.to_dict()
384
+ if not self.validate_climate_data(climate_data_dict):
385
+ raise ValueError("Invalid climate data extracted from EPW file.")
386
+ session_state["climate_data"] = climate_data_dict # Save to session state
387
+ st.success("Climate data extracted from EPW file with calculated Wet Bulb Temperature!")
388
+ st.write(f"Debug: Saved climate data for {location.city} (ID: {location.id}): {climate_data_dict}") # Debug
389
+ self.display_design_conditions(location)
390
+ self.visualize_data(location, epw_data=epw_data)
391
+ except Exception as e:
392
+ st.error(f"Error processing EPW file: {str(e)}. Ensure it has 8760 hourly records and correct format.")
393
+
394
+ col1, col2 = st.columns(2)
395
+ with col1:
396
+ st.button("Back to Building Information", on_click=lambda: setattr(session_state, "page", "Building Information"))
397
+ with col2:
398
+ if self.locations:
399
+ st.button("Continue to Building Components", on_click=lambda: setattr(session_state, "page", "Building Components"))
400
+ else:
401
+ st.button("Continue to Building Components", disabled=True)
402
+
403
+ # Display saved session state data (if any)
404
+ if "climate_data" in session_state and session_state["climate_data"]:
405
+ st.subheader("Saved Climate Data")
406
+ st.json(session_state["climate_data"]) # Display as JSON for clarity
407
+
408
+ def display_design_conditions(self, location: ClimateLocation):
409
+ """Display a table of design conditions including additional parameters for HVAC calculations."""
410
+ st.subheader("Design Conditions for HVAC Calculations")
411
+
412
+ design_data = pd.DataFrame({
413
+ "Parameter": [
414
+ "Latitude",
415
+ "Longitude",
416
+ "Elevation (m)",
417
+ "Climate Zone",
418
+ "Heating Degree Days (base 18°C)",
419
+ "Cooling Degree Days (base 18°C)",
420
+ "Winter Design Temperature (99.6%)",
421
+ "Summer Design Dry-Bulb Temp (0.4%)",
422
+ "Summer Design Wet-Bulb Temp (0.4%)",
423
+ "Summer Daily Temperature Range"
424
+ ],
425
+ "Value": [
426
+ f"{location.latitude}°",
427
+ f"{location.longitude}°",
428
+ f"{location.elevation} m",
429
+ location.climate_zone,
430
+ f"{location.heating_degree_days} HDD",
431
+ f"{location.cooling_degree_days} CDD",
432
+ f"{location.winter_design_temp} °C",
433
+ f"{location.summer_design_temp_db} °C",
434
+ f"{location.summer_design_temp_wb} °C",
435
+ f"{location.summer_daily_range} °C"
436
+ ]
437
+ })
438
+
439
+ month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
440
+ monthly_temp_data = pd.DataFrame({
441
+ "Parameter": [f"{month} Avg Temp" for month in month_names],
442
+ "Value": [f"{location.monthly_temps[month]} °C" for month in month_names]
443
+ })
444
+
445
+ monthly_humidity_data = pd.DataFrame({
446
+ "Parameter": [f"{month} Avg Humidity" for month in month_names],
447
+ "Value": [f"{location.monthly_humidity[month]} %" for month in month_names]
448
+ })
449
+
450
+ full_design_data = pd.concat([design_data, monthly_temp_data, monthly_humidity_data], ignore_index=True)
451
+ st.table(full_design_data)
452
+
453
+ @staticmethod
454
+ def assign_climate_zone(hdd: float, cdd: float, avg_humidity: float) -> str:
455
+ """Assign ASHRAE 169 climate zone based on HDD, CDD, and humidity."""
456
+ if cdd > 10000:
457
+ return "0A" if avg_humidity > 60 else "0B"
458
+ elif cdd > 5000:
459
+ return "1A" if avg_humidity > 60 else "1B"
460
+ elif cdd > 2500:
461
+ return "2A" if avg_humidity > 60 else "2B"
462
+ elif hdd < 2000 and cdd > 1000:
463
+ return "3A" if avg_humidity > 60 else "3B" if avg_humidity < 40 else "3C"
464
+ elif hdd < 3000:
465
+ return "4A" if avg_humidity > 60 else "4B" if avg_humidity < 40 else "4C"
466
+ elif hdd < 4000:
467
+ return "5A" if avg_humidity > 60 else "5B" if avg_humidity < 40 else "5C"
468
+ elif hdd < 5000:
469
+ return "6A" if avg_humidity > 60 else "6B"
470
+ elif hdd < 7000:
471
+ return "7"
472
+ else:
473
+ return "8"
474
+
475
+ @staticmethod
476
+ def visualize_data(location: ClimateLocation, epw_data: Optional[pd.DataFrame] = None):
477
+ """Visualize monthly temperature and humidity data."""
478
+ st.subheader("Monthly Climate Data Visualization")
479
+
480
+ months = list(range(1, 13))
481
+ month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
482
+ temps_avg = [location.monthly_temps[m] for m in month_names]
483
+ humidity_avg = [location.monthly_humidity[m] for m in month_names]
484
+
485
+ fig_temp = go.Figure()
486
+ fig_temp.add_trace(go.Scatter(
487
+ x=months,
488
+ y=temps_avg,
489
+ mode='lines+markers',
490
+ name='Avg Temperature (°C)',
491
+ line=dict(color='red'),
492
+ marker=dict(size=8)
493
+ ))
494
+
495
+ if epw_data is not None:
496
+ dry_bulb = epw_data[6].values
497
+ month_col = epw_data[1].values
498
+ temps_min = []
499
+ temps_max = []
500
+ for i in range(1, 13):
501
+ month_mask = (month_col == i)
502
+ temps_min.append(round(np.nanmin(dry_bulb[month_mask]), 1))
503
+ temps_max.append(round(np.nanmax(dry_bulb[month_mask]), 1))
504
+ fig_temp.add_trace(go.Scatter(
505
+ x=months,
506
+ y=temps_max,
507
+ mode='lines',
508
+ name='Max Temperature (°C)',
509
+ line=dict(color='red', dash='dash'),
510
+ opacity=0.5
511
+ ))
512
+ fig_temp.add_trace(go.Scatter(
513
+ x=months,
514
+ y=temps_min,
515
+ mode='lines',
516
+ name='Min Temperature (°C)',
517
+ line=dict(color='red', dash='dash'),
518
+ opacity=0.5,
519
+ fill='tonexty',
520
+ fillcolor='rgba(255, 0, 0, 0.1)'
521
+ ))
522
+
523
+ fig_temp.update_layout(
524
+ title='Monthly Temperatures',
525
+ xaxis_title='Month',
526
+ yaxis_title='Temperature (°C)',
527
+ xaxis=dict(tickmode='array', tickvals=months, ticktext=month_names),
528
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)
529
+ )
530
+ st.plotly_chart(fig_temp, use_container_width=True)
531
+
532
+ fig_hum = go.Figure()
533
+ fig_hum.add_trace(go.Scatter(
534
+ x=months,
535
+ y=humidity_avg,
536
+ mode='lines+markers',
537
+ name='Avg Humidity (%)',
538
+ line=dict(color='blue'),
539
+ marker=dict(size=8)
540
+ ))
541
+
542
+ if epw_data is not None:
543
+ humidity = epw_data[8].values
544
+ month_col = epw_data[1].values
545
+ humidity_min = []
546
+ humidity_max = []
547
+ for i in range(1, 13):
548
+ month_mask = (month_col == i)
549
+ humidity_min.append(round(np.nanmin(humidity[month_mask]), 1))
550
+ humidity_max.append(round(np.nanmax(humidity[month_mask]), 1))
551
+ fig_hum.add_trace(go.Scatter(
552
+ x=months,
553
+ y=humidity_max,
554
+ mode='lines',
555
+ name='Max Humidity (%)',
556
+ line=dict(color='blue', dash='dash'),
557
+ opacity=0.5
558
+ ))
559
+ fig_hum.add_trace(go.Scatter(
560
+ x=months,
561
+ y=humidity_min,
562
+ mode='lines',
563
+ name='Min Humidity (%)',
564
+ line=dict(color='blue', dash='dash'),
565
+ opacity=0.5,
566
+ fill='tonexty',
567
+ fillcolor='rgba(0, 0, 255, 0.1)'
568
+ ))
569
+
570
+ fig_hum.update_layout(
571
+ title='Monthly Relative Humidity',
572
+ xaxis_title='Month',
573
+ yaxis_title='Relative Humidity (%)',
574
+ xaxis=dict(tickmode='array', tickvals=months, ticktext=month_names),
575
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)
576
+ )
577
+ st.plotly_chart(fig_hum, use_container_width=True)
578
+
579
+ def export_to_json(self, file_path: str) -> None:
580
+ """Export all climate data to a JSON file."""
581
+ data = {loc_id: loc.to_dict() for loc_id, loc in self.locations.items()}
582
+ with open(file_path, 'w') as f:
583
+ json.dump(data, f, indent=4)
584
+
585
+ @classmethod
586
+ def from_json(cls, file_path: str) -> 'ClimateData':
587
+ """Load climate data from a JSON file."""
588
+ with open(file_path, 'r') as f:
589
+ data = json.load(f)
590
+ climate_data = cls()
591
+ for loc_id, loc_dict in data.items():
592
+ location = ClimateLocation(**loc_dict)
593
+ climate_data.add_location(location)
594
+ return climate_data
595
+
596
+ if __name__ == "__main__":
597
+ climate_data = ClimateData()
598
+ session_state = {"building_info": {"country": "Iceland", "city": "Reyugalvik"}, "page": "Climate Data"}
599
+ climate_data.display_climate_input(session_state)
data/drapery.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Drapery module for HVAC Load Calculator.
3
+ This module provides classes and functions for handling drapery properties
4
+ and calculating their effects on window heat transfer.
5
+
6
+ Based on ASHRAE principles for drapery thermal characteristics.
7
+ """
8
+
9
+ from typing import Dict, Any, Optional, Tuple
10
+ from enum import Enum
11
+
12
+
13
+ class DraperyOpenness(Enum):
14
+ """Enum for drapery openness classification."""
15
+ OPEN = "Open (>25%)"
16
+ SEMI_OPEN = "Semi-open (7-25%)"
17
+ CLOSED = "Closed (0-7%)"
18
+
19
+
20
+ class DraperyColor(Enum):
21
+ """Enum for drapery color/reflectance classification."""
22
+ DARK = "Dark (0-25%)"
23
+ MEDIUM = "Medium (25-50%)"
24
+ LIGHT = "Light (>50%)"
25
+
26
+
27
+ class Drapery:
28
+ """Class for drapery properties and calculations."""
29
+
30
+ def __init__(self,
31
+ openness: float = 0.05,
32
+ reflectance: float = 0.5,
33
+ transmittance: float = 0.3,
34
+ fullness: float = 1.0,
35
+ enabled: bool = True):
36
+ """
37
+ Initialize drapery object.
38
+
39
+ Args:
40
+ openness: Openness factor (0-1), fraction of fabric area that is open
41
+ reflectance: Reflectance factor (0-1), fraction of incident radiation reflected
42
+ transmittance: Transmittance factor (0-1), fraction of incident radiation transmitted
43
+ fullness: Fullness factor (0-2), ratio of fabric width to covered width
44
+ enabled: Whether the drapery is enabled/present
45
+ """
46
+ self.openness = max(0.0, min(1.0, openness))
47
+ self.reflectance = max(0.0, min(1.0, reflectance))
48
+ self.transmittance = max(0.0, min(1.0, transmittance))
49
+ self.fullness = max(0.0, min(2.0, fullness))
50
+ self.enabled = enabled
51
+
52
+ # Calculate derived properties
53
+ self.absorptance = 1.0 - self.reflectance - self.transmittance
54
+
55
+ # Classify drapery based on openness and reflectance
56
+ self.openness_class = self._classify_openness(self.openness)
57
+ self.color_class = self._classify_color(self.reflectance)
58
+
59
+ @staticmethod
60
+ def _classify_openness(openness: float) -> DraperyOpenness:
61
+ """Classify drapery based on openness factor."""
62
+ if openness > 0.25:
63
+ return DraperyOpenness.OPEN
64
+ elif openness > 0.07:
65
+ return DraperyOpenness.SEMI_OPEN
66
+ else:
67
+ return DraperyOpenness.CLOSED
68
+
69
+ @staticmethod
70
+ def _classify_color(reflectance: float) -> DraperyColor:
71
+ """Classify drapery based on reflectance factor."""
72
+ if reflectance > 0.5:
73
+ return DraperyColor.LIGHT
74
+ elif reflectance > 0.25:
75
+ return DraperyColor.MEDIUM
76
+ else:
77
+ return DraperyColor.DARK
78
+
79
+ def get_classification(self) -> str:
80
+ """Get drapery classification string."""
81
+ openness_map = {
82
+ DraperyOpenness.OPEN: "I",
83
+ DraperyOpenness.SEMI_OPEN: "II",
84
+ DraperyOpenness.CLOSED: "III"
85
+ }
86
+
87
+ color_map = {
88
+ DraperyColor.DARK: "D",
89
+ DraperyColor.MEDIUM: "M",
90
+ DraperyColor.LIGHT: "L"
91
+ }
92
+
93
+ return f"{openness_map[self.openness_class]}{color_map[self.color_class]}"
94
+
95
+ def calculate_iac(self, glazing_shgc: float = 0.87) -> float:
96
+ """
97
+ Calculate Interior Attenuation Coefficient (IAC) for the drapery.
98
+
99
+ The IAC represents the fraction of heat flow that enters the room
100
+ after being modified by the drapery.
101
+
102
+ Args:
103
+ glazing_shgc: Solar Heat Gain Coefficient of the glazing (default: 0.87 for clear glass)
104
+
105
+ Returns:
106
+ IAC value (0-1)
107
+ """
108
+ if not self.enabled:
109
+ return 1.0 # No attenuation if drapery is not enabled
110
+
111
+ # Calculate base IAC for flat drapery (no fullness)
112
+ # This is based on the principles from the Keyes Universal Chart
113
+ # and ASHRAE's IAC calculation methods
114
+
115
+ # Calculate yarn reflectance (based on openness and reflectance)
116
+ if self.openness < 0.0001: # Prevent division by zero
117
+ yarn_reflectance = self.reflectance
118
+ else:
119
+ yarn_reflectance = self.reflectance / (1.0 - self.openness)
120
+ yarn_reflectance = min(1.0, yarn_reflectance) # Cap at 1.0
121
+
122
+ # Base IAC calculation using fabric properties
123
+ # This is a simplified version of the ASHWAT model calculations
124
+ base_iac = 1.0 - (1.0 - self.openness) * (1.0 - self.transmittance / (1.0 - self.openness)) * yarn_reflectance
125
+
126
+ # Adjust for fullness
127
+ # Fullness creates multiple reflections between adjacent fabric surfaces
128
+ if self.fullness <= 0.0:
129
+ fullness_factor = 1.0
130
+ else:
131
+ # Fullness effect increases with higher fullness values
132
+ # More fullness means more fabric area and more reflections
133
+ fullness_factor = 1.0 - 0.15 * (self.fullness / 2.0)
134
+
135
+ # Apply fullness adjustment
136
+ adjusted_iac = base_iac * fullness_factor
137
+
138
+ # Ensure IAC is within valid range
139
+ adjusted_iac = max(0.1, min(1.0, adjusted_iac))
140
+
141
+ return adjusted_iac
142
+
143
+ def calculate_shading_coefficient(self, glazing_shgc: float = 0.87) -> float:
144
+ """
145
+ Calculate shading coefficient for the drapery.
146
+
147
+ The shading coefficient is the ratio of solar heat gain through
148
+ the window with the drapery to that of standard clear glass.
149
+
150
+ Args:
151
+ glazing_shgc: Solar Heat Gain Coefficient of the glazing (default: 0.87 for clear glass)
152
+
153
+ Returns:
154
+ Shading coefficient (0-1)
155
+ """
156
+ # Calculate IAC
157
+ iac = self.calculate_iac(glazing_shgc)
158
+
159
+ # Calculate shading coefficient
160
+ # SC = IAC * SHGC / 0.87
161
+ shading_coefficient = iac * glazing_shgc / 0.87
162
+
163
+ return shading_coefficient
164
+
165
+ def calculate_u_value_adjustment(self, base_u_value: float) -> float:
166
+ """
167
+ Calculate U-value adjustment for the drapery.
168
+
169
+ The drapery adds thermal resistance to the window assembly,
170
+ reducing the overall U-value.
171
+
172
+ Args:
173
+ base_u_value: Base U-value of the window without drapery (W/m²K)
174
+
175
+ Returns:
176
+ Adjusted U-value (W/m²K)
177
+ """
178
+ if not self.enabled:
179
+ return base_u_value # No adjustment if drapery is not enabled
180
+
181
+ # Calculate additional thermal resistance based on drapery properties
182
+ # This is a simplified approach based on ASHRAE principles
183
+
184
+ # Base resistance from drapery
185
+ # More closed fabrics provide more resistance
186
+ base_resistance = 0.05 # m²K/W, typical for medium-weight drapery
187
+
188
+ # Adjust for openness (more closed = more resistance)
189
+ openness_factor = 1.0 - self.openness
190
+
191
+ # Adjust for fullness (more fullness = more resistance due to air gaps)
192
+ fullness_factor = 1.0 + 0.25 * self.fullness
193
+
194
+ # Calculate total additional resistance
195
+ additional_resistance = base_resistance * openness_factor * fullness_factor
196
+
197
+ # Convert base U-value to resistance
198
+ base_resistance = 1.0 / base_u_value
199
+
200
+ # Add drapery resistance
201
+ total_resistance = base_resistance + additional_resistance
202
+
203
+ # Convert back to U-value
204
+ adjusted_u_value = 1.0 / total_resistance
205
+
206
+ return adjusted_u_value
207
+
208
+ def to_dict(self) -> Dict[str, Any]:
209
+ """Convert drapery object to dictionary."""
210
+ return {
211
+ "openness": self.openness,
212
+ "reflectance": self.reflectance,
213
+ "transmittance": self.transmittance,
214
+ "fullness": self.fullness,
215
+ "enabled": self.enabled,
216
+ "absorptance": self.absorptance,
217
+ "openness_class": self.openness_class.value,
218
+ "color_class": self.color_class.value,
219
+ "classification": self.get_classification()
220
+ }
221
+
222
+ @classmethod
223
+ def from_dict(cls, data: Dict[str, Any]) -> 'Drapery':
224
+ """Create drapery object from dictionary."""
225
+ return cls(
226
+ openness=data.get("openness", 0.05),
227
+ reflectance=data.get("reflectance", 0.5),
228
+ transmittance=data.get("transmittance", 0.3),
229
+ fullness=data.get("fullness", 1.0),
230
+ enabled=data.get("enabled", True)
231
+ )
232
+
233
+ @classmethod
234
+ def from_classification(cls, classification: str, fullness: float = 1.0) -> 'Drapery':
235
+ """
236
+ Create drapery object from ASHRAE classification.
237
+
238
+ Args:
239
+ classification: ASHRAE classification (ID, IM, IL, IID, IIM, IIL, IIID, IIIM, IIIL)
240
+ fullness: Fullness factor (0-2)
241
+
242
+ Returns:
243
+ Drapery object
244
+ """
245
+ # Parse classification
246
+ if len(classification) < 2:
247
+ raise ValueError(f"Invalid classification: {classification}")
248
+
249
+ # Handle single-character openness class (I) vs two-character (II, III)
250
+ if classification.startswith("II") or classification.startswith("III"):
251
+ if classification.startswith("III"):
252
+ openness_class = "III"
253
+ color_class = classification[3] if len(classification) > 3 else ""
254
+ else: # II
255
+ openness_class = "II"
256
+ color_class = classification[2] if len(classification) > 2 else ""
257
+ else: # I
258
+ openness_class = "I"
259
+ color_class = classification[1] if len(classification) > 1 else ""
260
+
261
+ # Set default values
262
+ openness = 0.05
263
+ reflectance = 0.5
264
+ transmittance = 0.3
265
+
266
+ # Set openness based on class
267
+ if openness_class == "I":
268
+ openness = 0.3 # Open (>25%)
269
+ elif openness_class == "II":
270
+ openness = 0.15 # Semi-open (7-25%)
271
+ elif openness_class == "III":
272
+ openness = 0.03 # Closed (0-7%)
273
+
274
+ # Set reflectance and transmittance based on color class
275
+ if color_class == "D":
276
+ reflectance = 0.2 # Dark (0-25%)
277
+ transmittance = 0.05
278
+ elif color_class == "M":
279
+ reflectance = 0.4 # Medium (25-50%)
280
+ transmittance = 0.15
281
+ elif color_class == "L":
282
+ reflectance = 0.7 # Light (>50%)
283
+ transmittance = 0.2
284
+
285
+ return cls(
286
+ openness=openness,
287
+ reflectance=reflectance,
288
+ transmittance=transmittance,
289
+ fullness=fullness
290
+ )
291
+
292
+
293
+ # Predefined drapery types based on ASHRAE classifications
294
+ PREDEFINED_DRAPERIES = {
295
+ "ID": Drapery.from_classification("ID"),
296
+ "IM": Drapery.from_classification("IM"),
297
+ "IL": Drapery.from_classification("IL"),
298
+ "IID": Drapery.from_classification("IID"),
299
+ "IIM": Drapery.from_classification("IIM"),
300
+ "IIL": Drapery.from_classification("IIL"),
301
+ "IIID": Drapery.from_classification("IIID"),
302
+ "IIIM": Drapery.from_classification("IIIM"),
303
+ "IIIL": Drapery.from_classification("IIIL")
304
+ }
data/reference_data.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reference data structures for HVAC Load Calculator.
3
+ This module contains reference data for materials, construction types, and other HVAC-related data.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional
7
+ import pandas as pd
8
+ import json
9
+ import os
10
+ import logging
11
+ from uuid import uuid4
12
+
13
+ # Configure logging
14
+ logging.basicConfig(level=logging.INFO)
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Define paths
18
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
19
+ DEFAULT_DATA_FILE = os.path.join(DATA_DIR, "reference_data.json")
20
+
21
+
22
+ class ReferenceData:
23
+ """Class for managing reference data for the HVAC calculator."""
24
+
25
+ def __init__(self):
26
+ """Initialize reference data structures."""
27
+ self.materials = {}
28
+ self.wall_types = {}
29
+ self.roof_types = {}
30
+ self.floor_types = {}
31
+ self.window_types = {}
32
+ self.door_types = {}
33
+ self.internal_loads = {}
34
+
35
+ try:
36
+ self._load_all_data()
37
+ except Exception as e:
38
+ logger.error(f"Error initializing reference data: {str(e)}")
39
+ raise
40
+
41
+ def _load_all_data(self) -> None:
42
+ """Load all reference data, attempting to load from JSON first."""
43
+ try:
44
+ if os.path.exists(DEFAULT_DATA_FILE):
45
+ self._load_from_json(DEFAULT_DATA_FILE)
46
+ else:
47
+ self._load_default_data()
48
+ self.export_to_json(DEFAULT_DATA_FILE)
49
+ except Exception as e:
50
+ logger.error(f"Error loading reference data: {str(e)}")
51
+ self._load_default_data() # Fallback to default data
52
+
53
+ def _load_default_data(self) -> None:
54
+ """Load default reference data."""
55
+ self.materials = self._load_materials()
56
+ self.wall_types = self._load_wall_types()
57
+ self.roof_types = self._load_roof_types()
58
+ self.floor_types = self._load_floor_types()
59
+ self.window_types = self._load_window_types()
60
+ self.door_types = self._load_door_types()
61
+ self.internal_loads = self._load_internal_loads()
62
+
63
+ def _load_materials(self) -> Dict[str, Dict[str, Any]]:
64
+ """
65
+ Load material properties.
66
+ Returns:
67
+ Dictionary of material properties
68
+ """
69
+ return {
70
+ "brick": {
71
+ "id": str(uuid4()),
72
+ "name": "Common Brick",
73
+ "conductivity": 0.72, # W/(m·K)
74
+ "density": 1920, # kg/m³
75
+ "specific_heat": 840, # J/(kg·K)
76
+ "typical_thickness": 0.1, # m
77
+ "emissivity": 0.9,
78
+ "solar_absorptance": 0.7
79
+ },
80
+ "concrete": {
81
+ "id": str(uuid4()),
82
+ "name": "Concrete",
83
+ "conductivity": 1.4, # W/(m·K)
84
+ "density": 2300, # kg/m³
85
+ "specific_heat": 880, # J/(kg·K)
86
+ "typical_thickness": 0.2, # m
87
+ "emissivity": 0.92,
88
+ "solar_absorptance": 0.65
89
+ },
90
+ "mineral_wool": {
91
+ "id": str(uuid4()),
92
+ "name": "Mineral Wool Insulation",
93
+ "conductivity": 0.04, # W/(m·K)
94
+ "density": 30, # kg/m³
95
+ "specific_heat": 840, # J/(kg·K)
96
+ "typical_thickness": 0.1, # m
97
+ "emissivity": 0.9,
98
+ "solar_absorptance": 0.6
99
+ },
100
+ # Additional materials
101
+ "polyurethane_foam": {
102
+ "id": str(uuid4()),
103
+ "name": "Polyurethane Foam",
104
+ "conductivity": 0.025, # W/(m·K)
105
+ "density": 40, # kg/m³
106
+ "specific_heat": 1500, # J/(kg·K)
107
+ "typical_thickness": 0.05, # m
108
+ "emissivity": 0.9,
109
+ "solar_absorptance": 0.6
110
+ },
111
+ "fiberglass_insulation": {
112
+ "id": str(uuid4()),
113
+ "name": "Fiberglass Insulation",
114
+ "conductivity": 0.045, # W/(m·K)
115
+ "density": 12, # kg/m³
116
+ "specific_heat": 850, # J/(kg·K)
117
+ "typical_thickness": 0.15, # m
118
+ "emissivity": 0.9,
119
+ "solar_absorptance": 0.6
120
+ },
121
+ "stucco": {
122
+ "id": str(uuid4()),
123
+ "name": "Stucco",
124
+ "conductivity": 0.7, # W/(m·K)
125
+ "density": 1850, # kg/m³
126
+ "specific_heat": 900, # J/(kg·K)
127
+ "typical_thickness": 0.025, # m
128
+ "emissivity": 0.92,
129
+ "solar_absorptance": 0.5
130
+ }
131
+ # Add more materials as needed
132
+ }
133
+
134
+ def _load_wall_types(self) -> Dict[str, Dict[str, Any]]:
135
+ """
136
+ Load predefined wall types.
137
+ Returns:
138
+ Dictionary of wall types with properties
139
+ """
140
+ return {
141
+ "brick_veneer_wood_frame": {
142
+ "id": str(uuid4()),
143
+ "name": "Brick Veneer with Wood Frame",
144
+ "description": "Brick veneer with wood frame, insulation, and gypsum board",
145
+ "u_value": 0.35, # W/(m²·K)
146
+ "wall_group": "B",
147
+ "layers": [
148
+ {"material": "brick", "thickness": 0.1},
149
+ {"material": "air_gap", "thickness": 0.025},
150
+ {"material": "wood", "thickness": 0.038},
151
+ {"material": "mineral_wool", "thickness": 0.089},
152
+ {"material": "gypsum_board", "thickness": 0.0125}
153
+ ],
154
+ "thermal_mass": 180, # kg/m²
155
+ "color": "Medium"
156
+ },
157
+ "insulated_concrete_form": {
158
+ "id": str(uuid4()),
159
+ "name": "Insulated Concrete Form",
160
+ "description": "ICF with EPS insulation and concrete core",
161
+ "u_value": 0.25, # W/(m²·K)
162
+ "wall_group": "C",
163
+ "layers": [
164
+ {"material": "eps_insulation", "thickness": 0.05},
165
+ {"material": "concrete", "thickness": 0.15},
166
+ {"material": "eps_insulation", "thickness": 0.05},
167
+ {"material": "gypsum_board", "thickness": 0.0125}
168
+ ],
169
+ "thermal_mass": 220, # kg/m²
170
+ "color": "Light"
171
+ },
172
+ # Additional wall types
173
+ "sip_panel": {
174
+ "id": str(uuid4()),
175
+ "name": "Structural Insulated Panel",
176
+ "description": "SIP with OSB and EPS core",
177
+ "u_value": 0.28, # W/(m²·K)
178
+ "wall_group": "A",
179
+ "layers": [
180
+ {"material": "wood", "thickness": 0.012},
181
+ {"material": "eps_insulation", "thickness": 0.15},
182
+ {"material": "wood", "thickness": 0.012},
183
+ {"material": "gypsum_board", "thickness": 0.0125}
184
+ ],
185
+ "thermal_mass": 80, # kg/m²
186
+ "color": "Light"
187
+ }
188
+ }
189
+
190
+ def _load_roof_types(self) -> Dict[str, Dict[str, Any]]:
191
+ """
192
+ Load predefined roof types.
193
+ Returns:
194
+ Dictionary of roof types with properties
195
+ """
196
+ return {
197
+ "flat_roof_concrete": {
198
+ "id": str(uuid4()),
199
+ "name": "Flat Concrete Roof with Insulation",
200
+ "description": "Flat concrete roof with insulation and ceiling",
201
+ "u_value": 0.25, # W/(m²·K)
202
+ "roof_group": "B",
203
+ "layers": [
204
+ {"material": "concrete", "thickness": 0.15},
205
+ {"material": "eps_insulation", "thickness": 0.15},
206
+ {"material": "gypsum_board", "thickness": 0.0125}
207
+ ],
208
+ "solar_absorptance": 0.7,
209
+ "emissivity": 0.9
210
+ },
211
+ "green_roof": {
212
+ "id": str(uuid4()),
213
+ "name": "Green Roof",
214
+ "description": "Vegetated roof with insulation and drainage",
215
+ "u_value": 0.22, # W/(m²·K)
216
+ "roof_group": "A",
217
+ "layers": [
218
+ {"material": "soil", "thickness": 0.1},
219
+ {"material": "eps_insulation", "thickness": 0.1},
220
+ {"material": "concrete", "thickness": 0.1},
221
+ {"material": "gypsum_board", "thickness": 0.0125}
222
+ ],
223
+ "solar_absorptance": 0.5,
224
+ "emissivity": 0.95
225
+ }
226
+ }
227
+
228
+ def _load_floor_types(self) -> Dict[str, Dict[str, Any]]:
229
+ """
230
+ Load predefined floor types.
231
+ Returns:
232
+ Dictionary of floor types with properties
233
+ """
234
+ return {
235
+ "concrete_slab_on_grade": {
236
+ "id": str(uuid4()),
237
+ "name": "Concrete Slab on Grade",
238
+ "description": "Concrete slab on grade with insulation",
239
+ "u_value": 0.3, # W/(m²·K)
240
+ "is_ground_contact": True,
241
+ "layers": [
242
+ {"material": "concrete", "thickness": 0.1},
243
+ {"material": "eps_insulation", "thickness": 0.05}
244
+ ],
245
+ "thermal_mass": 230 # kg/m²
246
+ },
247
+ "radiant_floor": {
248
+ "id": str(uuid4()),
249
+ "name": "Radiant Floor",
250
+ "description": "Concrete floor with radiant heating and insulation",
251
+ "u_value": 0.27, # W/(m²·K)
252
+ "is_ground_contact": True,
253
+ "layers": [
254
+ {"material": "tile", "thickness": 0.015},
255
+ {"material": "concrete", "thickness": 0.1},
256
+ {"material": "eps_insulation", "thickness": 0.075}
257
+ ],
258
+ "thermal_mass": 240 # kg/m²
259
+ }
260
+ }
261
+
262
+ def _load_window_types(self) -> Dict[str, Dict[str, Any]]:
263
+ """
264
+ Load predefined window types.
265
+ Returns:
266
+ Dictionary of window types with properties
267
+ """
268
+ return {
269
+ "double_glazed_argon_low_e": {
270
+ "id": str(uuid4()),
271
+ "name": "Double Glazed with Argon and Low-E",
272
+ "description": "Double glazed window with argon fill, low-e coating, and vinyl frame",
273
+ "u_value": 1.4, # W/(m²·K)
274
+ "shgc": 0.4,
275
+ "vt": 0.7,
276
+ "glazing_layers": 2,
277
+ "gas_fill": "Argon",
278
+ "frame_type": "Vinyl",
279
+ "low_e_coating": True,
280
+ "frame_factor": 0.15
281
+ },
282
+ "electrochromic_window": {
283
+ "id": str(uuid4()),
284
+ "name": "Electrochromic Window",
285
+ "description": "Smart window with dynamic tinting",
286
+ "u_value": 1.2, # W/(m²·K)
287
+ "shgc": 0.35,
288
+ "vt": 0.65,
289
+ "glazing_layers": 2,
290
+ "gas_fill": "Argon",
291
+ "frame_type": "Fiberglass",
292
+ "low_e_coating": True,
293
+ "frame_factor": 0.12
294
+ }
295
+ }
296
+
297
+ def _load_door_types(self) -> Dict[str, Dict[str, Any]]:
298
+ """
299
+ Load predefined door types.
300
+ Returns:
301
+ Dictionary of door types with properties
302
+ """
303
+ return {
304
+ "insulated_steel_door": {
305
+ "id": str(uuid4()),
306
+ "name": "Insulated Steel Door",
307
+ "description": "Insulated steel door with no glazing",
308
+ "u_value": 1.2, # W/(m²·K)
309
+ "glazing_percentage": 0,
310
+ "door_type": "Solid",
311
+ "thermal_mass": 50 # kg/m²
312
+ },
313
+ "insulated_fiberglass_door": {
314
+ "id": str(uuid4()),
315
+ "name": "Insulated Fiberglass Door",
316
+ "description": "Insulated fiberglass door with small glazing",
317
+ "u_value": 1.5, # W/(m²·K)
318
+ "glazing_percentage": 10,
319
+ "door_type": "Partially glazed",
320
+ "shgc": 0.7,
321
+ "vt": 0.8,
322
+ "thermal_mass": 40 # kg/m²
323
+ }
324
+ }
325
+
326
+ def _load_internal_loads(self) -> Dict[str, Dict[str, Any]]:
327
+ """
328
+ Load internal load data.
329
+ Returns:
330
+ Dictionary of internal load types with properties
331
+ """
332
+ return {
333
+ "occupancy": {
334
+ "office_typing": {
335
+ "id": str(uuid4()),
336
+ "name": "Office Typing",
337
+ "sensible_heat": 75, # W per person
338
+ "latent_heat": 55, # W per person
339
+ "metabolic_rate": 1.2 # met
340
+ },
341
+ "retail_sales": {
342
+ "id": str(uuid4()),
343
+ "name": "Retail Sales",
344
+ "sensible_heat": 80, # W per person
345
+ "latent_heat": 70, # W per person
346
+ "metabolic_rate": 1.4 # met
347
+ }
348
+ },
349
+ "lighting": {
350
+ "led_high_efficiency": {
351
+ "id": str(uuid4()),
352
+ "name": "High Efficiency LED",
353
+ "power_density_range": [4, 8], # W/m²
354
+ "heat_to_space": 0.85,
355
+ "efficacy": 120 # lm/W
356
+ }
357
+ },
358
+ "equipment": {
359
+ "computer_workstation": {
360
+ "id": str(uuid4()),
361
+ "name": "Computer Workstation",
362
+ "power_density_range": [15, 25], # W/m²
363
+ "sensible_fraction": 0.95,
364
+ "latent_fraction": 0.05
365
+ }
366
+ }
367
+ }
368
+
369
+ def _load_from_json(self, file_path: str) -> None:
370
+ """
371
+ Load reference data from JSON file.
372
+ Args:
373
+ file_path: Path to JSON file
374
+ """
375
+ try:
376
+ with open(file_path, 'r') as f:
377
+ data = json.load(f)
378
+ self.materials = data.get("materials", self.materials)
379
+ self.wall_types = data.get("wall_types", self.wall_types)
380
+ self.roof_types = data.get("roof_types", self.roof_types)
381
+ self.floor_types = data.get("floor_types", self.floor_types)
382
+ self.window_types = data.get("window_types", self.window_types)
383
+ self.door_types = data.get("door_types", self.door_types)
384
+ self.internal_loads = data.get("internal_loads", self.internal_loads)
385
+ logger.info(f"Successfully loaded reference data from {file_path}")
386
+ except Exception as e:
387
+ logger.error(f"Error loading JSON data from {file_path}: {str(e)}")
388
+ raise
389
+
390
+ def export_to_json(self, file_path: str) -> None:
391
+ """
392
+ Export all reference data to a JSON file.
393
+ Args:
394
+ file_path: Path to the output JSON file
395
+ """
396
+ try:
397
+ data = {
398
+ "materials": self.materials,
399
+ "wall_types": self.wall_types,
400
+ "roof_types": self.roof_types,
401
+ "floor_types": self.floor_types,
402
+ "window_types": self.window_types,
403
+ "door_types": self.door_types,
404
+ "internal_loads": self.internal_loads
405
+ }
406
+ with open(file_path, 'w') as f:
407
+ json.dump(data, f, indent=4)
408
+ logger.info(f"Successfully exported reference data to {file_path}")
409
+ except Exception as e:
410
+ logger.error(f"Error exporting to JSON: {str(e)}")
411
+ raise
412
+
413
+ # Getter methods with validation
414
+ def get_material(self, material_id: str) -> Optional[Dict[str, Any]]:
415
+ return self.materials.get(material_id)
416
+
417
+ def get_wall_type(self, wall_type_id: str) -> Optional[Dict[str, Any]]:
418
+ return self.wall_types.get(wall_type_id)
419
+
420
+ def get_roof_type(self, roof_type_id: str) -> Optional[Dict[str, Any]]:
421
+ return self.roof_types.get(roof_type_id)
422
+
423
+ def get_floor_type(self, floor_type_id: str) -> Optional[Dict[str, Any]]:
424
+ return self.floor_types.get(floor_type_id)
425
+
426
+ def get_window_type(self, window_type_id: str) -> Optional[Dict[str, Any]]:
427
+ return self.window_types.get(window_type_id)
428
+
429
+ def get_door_type(self, door_type_id: str) -> Optional[Dict[str, Any]]:
430
+ return self.door_types.get(door_type_id)
431
+
432
+ def get_internal_load(self, load_type: str, load_id: str) -> Optional[Dict[str, Any]]:
433
+ return self.internal_loads.get(load_type, {}).get(load_id)
434
+
435
+
436
+ # Singleton instance
437
+ try:
438
+ reference_data = ReferenceData()
439
+ except Exception as e:
440
+ logger.error(f"Failed to create ReferenceData instance: {str(e)}")
441
+ raise
442
+
443
+ if __name__ == "__main__":
444
+ try:
445
+ reference_data.export_to_json(DEFAULT_DATA_FILE)
446
+ except Exception as e:
447
+ logger.error(f"Error exporting default data: {str(e)}")
gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
requirements.txt CHANGED
@@ -1,3 +1,8 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
1
+ streamlit==1.28.0
2
+ pandas==2.0.3
3
+ numpy==1.24.3
4
+ plotly==5.18.0
5
+ matplotlib==3.7.2
6
+ openpyxl==3.1.2
7
+ xlsxwriter==3.1.2
8
+ pycountry==1.2
utils/area_calculation_system.py ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Area calculation system module for HVAC Load Calculator.
3
+ This module implements net wall area calculation and area validation functions.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ from dataclasses import dataclass, field
12
+
13
+ # Import data models
14
+ from data.building_components import Wall, Window, Door, Orientation, ComponentType
15
+
16
+ # Define paths
17
+ DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+
19
+
20
+ class AreaCalculationSystem:
21
+ """Class for managing area calculations and validations."""
22
+
23
+ def __init__(self):
24
+ """Initialize area calculation system."""
25
+ self.walls = {}
26
+ self.windows = {}
27
+ self.doors = {}
28
+
29
+ def add_wall(self, wall: Wall) -> None:
30
+ """
31
+ Add a wall to the area calculation system.
32
+
33
+ Args:
34
+ wall: Wall object
35
+ """
36
+ self.walls[wall.id] = wall
37
+
38
+ def add_window(self, window: Window) -> None:
39
+ """
40
+ Add a window to the area calculation system.
41
+
42
+ Args:
43
+ window: Window object
44
+ """
45
+ self.windows[window.id] = window
46
+
47
+ def add_door(self, door: Door) -> None:
48
+ """
49
+ Add a door to the area calculation system.
50
+
51
+ Args:
52
+ door: Door object
53
+ """
54
+ self.doors[door.id] = door
55
+
56
+ def remove_wall(self, wall_id: str) -> bool:
57
+ """
58
+ Remove a wall from the area calculation system.
59
+
60
+ Args:
61
+ wall_id: Wall identifier
62
+
63
+ Returns:
64
+ True if the wall was removed, False otherwise
65
+ """
66
+ if wall_id not in self.walls:
67
+ return False
68
+
69
+ # Remove all windows and doors associated with this wall
70
+ for window_id, window in list(self.windows.items()):
71
+ if window.wall_id == wall_id:
72
+ del self.windows[window_id]
73
+
74
+ for door_id, door in list(self.doors.items()):
75
+ if door.wall_id == wall_id:
76
+ del self.doors[door_id]
77
+
78
+ del self.walls[wall_id]
79
+ return True
80
+
81
+ def remove_window(self, window_id: str) -> bool:
82
+ """
83
+ Remove a window from the area calculation system.
84
+
85
+ Args:
86
+ window_id: Window identifier
87
+
88
+ Returns:
89
+ True if the window was removed, False otherwise
90
+ """
91
+ if window_id not in self.windows:
92
+ return False
93
+
94
+ # Remove window from associated wall
95
+ window = self.windows[window_id]
96
+ if window.wall_id and window.wall_id in self.walls:
97
+ wall = self.walls[window.wall_id]
98
+ if window_id in wall.windows:
99
+ wall.windows.remove(window_id)
100
+ self._update_wall_net_area(wall.id)
101
+
102
+ del self.windows[window_id]
103
+ return True
104
+
105
+ def remove_door(self, door_id: str) -> bool:
106
+ """
107
+ Remove a door from the area calculation system.
108
+
109
+ Args:
110
+ door_id: Door identifier
111
+
112
+ Returns:
113
+ True if the door was removed, False otherwise
114
+ """
115
+ if door_id not in self.doors:
116
+ return False
117
+
118
+ # Remove door from associated wall
119
+ door = self.doors[door_id]
120
+ if door.wall_id and door.wall_id in self.walls:
121
+ wall = self.walls[door.wall_id]
122
+ if door_id in wall.doors:
123
+ wall.doors.remove(door_id)
124
+ self._update_wall_net_area(wall.id)
125
+
126
+ del self.doors[door_id]
127
+ return True
128
+
129
+ def assign_window_to_wall(self, window_id: str, wall_id: str) -> bool:
130
+ """
131
+ Assign a window to a wall.
132
+
133
+ Args:
134
+ window_id: Window identifier
135
+ wall_id: Wall identifier
136
+
137
+ Returns:
138
+ True if the window was assigned, False otherwise
139
+ """
140
+ if window_id not in self.windows or wall_id not in self.walls:
141
+ return False
142
+
143
+ window = self.windows[window_id]
144
+ wall = self.walls[wall_id]
145
+
146
+ # Remove window from previous wall if assigned
147
+ if window.wall_id and window.wall_id in self.walls and window.wall_id != wall_id:
148
+ prev_wall = self.walls[window.wall_id]
149
+ if window_id in prev_wall.windows:
150
+ prev_wall.windows.remove(window_id)
151
+ self._update_wall_net_area(prev_wall.id)
152
+
153
+ # Assign window to new wall
154
+ window.wall_id = wall_id
155
+ window.orientation = wall.orientation
156
+
157
+ # Add window to wall's window list if not already there
158
+ if window_id not in wall.windows:
159
+ wall.windows.append(window_id)
160
+
161
+ # Update wall net area
162
+ self._update_wall_net_area(wall_id)
163
+
164
+ return True
165
+
166
+ def assign_door_to_wall(self, door_id: str, wall_id: str) -> bool:
167
+ """
168
+ Assign a door to a wall.
169
+
170
+ Args:
171
+ door_id: Door identifier
172
+ wall_id: Wall identifier
173
+
174
+ Returns:
175
+ True if the door was assigned, False otherwise
176
+ """
177
+ if door_id not in self.doors or wall_id not in self.walls:
178
+ return False
179
+
180
+ door = self.doors[door_id]
181
+ wall = self.walls[wall_id]
182
+
183
+ # Remove door from previous wall if assigned
184
+ if door.wall_id and door.wall_id in self.walls and door.wall_id != wall_id:
185
+ prev_wall = self.walls[door.wall_id]
186
+ if door_id in prev_wall.doors:
187
+ prev_wall.doors.remove(door_id)
188
+ self._update_wall_net_area(prev_wall.id)
189
+
190
+ # Assign door to new wall
191
+ door.wall_id = wall_id
192
+ door.orientation = wall.orientation
193
+
194
+ # Add door to wall's door list if not already there
195
+ if door_id not in wall.doors:
196
+ wall.doors.append(door_id)
197
+
198
+ # Update wall net area
199
+ self._update_wall_net_area(wall_id)
200
+
201
+ return True
202
+
203
+ def _update_wall_net_area(self, wall_id: str) -> None:
204
+ """
205
+ Update the net area of a wall by subtracting windows and doors.
206
+
207
+ Args:
208
+ wall_id: Wall identifier
209
+ """
210
+ if wall_id not in self.walls:
211
+ return
212
+
213
+ wall = self.walls[wall_id]
214
+
215
+ # Calculate total window area
216
+ total_window_area = sum(self.windows[window_id].area
217
+ for window_id in wall.windows
218
+ if window_id in self.windows)
219
+
220
+ # Calculate total door area
221
+ total_door_area = sum(self.doors[door_id].area
222
+ for door_id in wall.doors
223
+ if door_id in self.doors)
224
+
225
+ # Update wall net area
226
+ if wall.gross_area is None:
227
+ wall.gross_area = wall.area
228
+
229
+ wall.net_area = wall.gross_area - total_window_area - total_door_area
230
+ wall.area = wall.net_area # Update the main area property
231
+
232
+ def update_all_net_areas(self) -> None:
233
+ """Update the net areas of all walls."""
234
+ for wall_id in self.walls:
235
+ self._update_wall_net_area(wall_id)
236
+
237
+ def validate_areas(self) -> List[Dict[str, Any]]:
238
+ """
239
+ Validate all areas and return a list of validation issues.
240
+
241
+ Returns:
242
+ List of validation issues
243
+ """
244
+ issues = []
245
+
246
+ # Check for negative or zero net wall areas
247
+ for wall_id, wall in self.walls.items():
248
+ if wall.net_area <= 0:
249
+ issues.append({
250
+ "type": "error",
251
+ "component_id": wall_id,
252
+ "component_type": "Wall",
253
+ "message": f"Wall '{wall.name}' has a negative or zero net area. "
254
+ f"Gross area: {wall.gross_area} m², "
255
+ f"Window area: {sum(self.windows[window_id].area for window_id in wall.windows if window_id in self.windows)} m², "
256
+ f"Door area: {sum(self.doors[door_id].area for door_id in wall.doors if door_id in self.doors)} m², "
257
+ f"Net area: {wall.net_area} m²."
258
+ })
259
+ elif wall.net_area < 0.5:
260
+ issues.append({
261
+ "type": "warning",
262
+ "component_id": wall_id,
263
+ "component_type": "Wall",
264
+ "message": f"Wall '{wall.name}' has a very small net area ({wall.net_area} m²). "
265
+ f"Consider adjusting window and door sizes."
266
+ })
267
+
268
+ # Check for windows without walls
269
+ for window_id, window in self.windows.items():
270
+ if not window.wall_id:
271
+ issues.append({
272
+ "type": "warning",
273
+ "component_id": window_id,
274
+ "component_type": "Window",
275
+ "message": f"Window '{window.name}' is not assigned to any wall."
276
+ })
277
+ elif window.wall_id not in self.walls:
278
+ issues.append({
279
+ "type": "error",
280
+ "component_id": window_id,
281
+ "component_type": "Window",
282
+ "message": f"Window '{window.name}' is assigned to a non-existent wall (ID: {window.wall_id})."
283
+ })
284
+
285
+ # Check for doors without walls
286
+ for door_id, door in self.doors.items():
287
+ if not door.wall_id:
288
+ issues.append({
289
+ "type": "warning",
290
+ "component_id": door_id,
291
+ "component_type": "Door",
292
+ "message": f"Door '{door.name}' is not assigned to any wall."
293
+ })
294
+ elif door.wall_id not in self.walls:
295
+ issues.append({
296
+ "type": "error",
297
+ "component_id": door_id,
298
+ "component_type": "Door",
299
+ "message": f"Door '{door.name}' is assigned to a non-existent wall (ID: {door.wall_id})."
300
+ })
301
+
302
+ # Check for windows and doors with zero area
303
+ for window_id, window in self.windows.items():
304
+ if window.area <= 0:
305
+ issues.append({
306
+ "type": "error",
307
+ "component_id": window_id,
308
+ "component_type": "Window",
309
+ "message": f"Window '{window.name}' has a zero or negative area ({window.area} m²)."
310
+ })
311
+
312
+ for door_id, door in self.doors.items():
313
+ if door.area <= 0:
314
+ issues.append({
315
+ "type": "error",
316
+ "component_id": door_id,
317
+ "component_type": "Door",
318
+ "message": f"Door '{door.name}' has a zero or negative area ({door.area} m²)."
319
+ })
320
+
321
+ return issues
322
+
323
+ def get_wall_components(self, wall_id: str) -> Dict[str, List[str]]:
324
+ """
325
+ Get all components (windows and doors) associated with a wall.
326
+
327
+ Args:
328
+ wall_id: Wall identifier
329
+
330
+ Returns:
331
+ Dictionary with lists of window and door IDs
332
+ """
333
+ if wall_id not in self.walls:
334
+ return {"windows": [], "doors": []}
335
+
336
+ wall = self.walls[wall_id]
337
+ return {
338
+ "windows": [window_id for window_id in wall.windows if window_id in self.windows],
339
+ "doors": [door_id for door_id in wall.doors if door_id in self.doors]
340
+ }
341
+
342
+ def get_wall_area_breakdown(self, wall_id: str) -> Dict[str, float]:
343
+ """
344
+ Get a breakdown of wall areas (gross, net, windows, doors).
345
+
346
+ Args:
347
+ wall_id: Wall identifier
348
+
349
+ Returns:
350
+ Dictionary with area breakdown
351
+ """
352
+ if wall_id not in self.walls:
353
+ return {}
354
+
355
+ wall = self.walls[wall_id]
356
+
357
+ # Calculate total window area
358
+ window_area = sum(self.windows[window_id].area
359
+ for window_id in wall.windows
360
+ if window_id in self.windows)
361
+
362
+ # Calculate total door area
363
+ door_area = sum(self.doors[door_id].area
364
+ for door_id in wall.doors
365
+ if door_id in self.doors)
366
+
367
+ return {
368
+ "gross_area": wall.gross_area,
369
+ "net_area": wall.net_area,
370
+ "window_area": window_area,
371
+ "door_area": door_area
372
+ }
373
+
374
+ def get_total_areas(self) -> Dict[str, float]:
375
+ """
376
+ Get total areas for all component types.
377
+
378
+ Returns:
379
+ Dictionary with total areas
380
+ """
381
+ # Calculate total wall areas
382
+ total_wall_gross_area = sum(wall.gross_area for wall in self.walls.values() if wall.gross_area is not None)
383
+ total_wall_net_area = sum(wall.net_area for wall in self.walls.values() if wall.net_area is not None)
384
+
385
+ # Calculate total window area
386
+ total_window_area = sum(window.area for window in self.windows.values())
387
+
388
+ # Calculate total door area
389
+ total_door_area = sum(door.area for door in self.doors.values())
390
+
391
+ return {
392
+ "total_wall_gross_area": total_wall_gross_area,
393
+ "total_wall_net_area": total_wall_net_area,
394
+ "total_window_area": total_window_area,
395
+ "total_door_area": total_door_area
396
+ }
397
+
398
+ def get_areas_by_orientation(self) -> Dict[str, Dict[str, float]]:
399
+ """
400
+ Get areas for all component types grouped by orientation.
401
+
402
+ Returns:
403
+ Dictionary with areas by orientation
404
+ """
405
+ # Initialize result dictionary
406
+ result = {}
407
+
408
+ # Process walls
409
+ for wall in self.walls.values():
410
+ orientation = wall.orientation.value
411
+ if orientation not in result:
412
+ result[orientation] = {
413
+ "wall_gross_area": 0,
414
+ "wall_net_area": 0,
415
+ "window_area": 0,
416
+ "door_area": 0
417
+ }
418
+
419
+ result[orientation]["wall_gross_area"] += wall.gross_area if wall.gross_area is not None else 0
420
+ result[orientation]["wall_net_area"] += wall.net_area if wall.net_area is not None else 0
421
+
422
+ # Process windows
423
+ for window in self.windows.values():
424
+ orientation = window.orientation.value
425
+ if orientation not in result:
426
+ result[orientation] = {
427
+ "wall_gross_area": 0,
428
+ "wall_net_area": 0,
429
+ "window_area": 0,
430
+ "door_area": 0
431
+ }
432
+
433
+ result[orientation]["window_area"] += window.area
434
+
435
+ # Process doors
436
+ for door in self.doors.values():
437
+ orientation = door.orientation.value
438
+ if orientation not in result:
439
+ result[orientation] = {
440
+ "wall_gross_area": 0,
441
+ "wall_net_area": 0,
442
+ "window_area": 0,
443
+ "door_area": 0
444
+ }
445
+
446
+ result[orientation]["door_area"] += door.area
447
+
448
+ return result
449
+
450
+ def export_to_json(self, file_path: str) -> None:
451
+ """
452
+ Export all components to a JSON file.
453
+
454
+ Args:
455
+ file_path: Path to the output JSON file
456
+ """
457
+ data = {
458
+ "walls": {wall_id: wall.to_dict() for wall_id, wall in self.walls.items()},
459
+ "windows": {window_id: window.to_dict() for window_id, window in self.windows.items()},
460
+ "doors": {door_id: door.to_dict() for door_id, door in self.doors.items()}
461
+ }
462
+
463
+ with open(file_path, 'w') as f:
464
+ json.dump(data, f, indent=4)
465
+
466
+ def import_from_json(self, file_path: str) -> Tuple[int, int, int]:
467
+ """
468
+ Import components from a JSON file.
469
+
470
+ Args:
471
+ file_path: Path to the input JSON file
472
+
473
+ Returns:
474
+ Tuple with counts of walls, windows, and doors imported
475
+ """
476
+ from data.building_components import BuildingComponentFactory
477
+
478
+ with open(file_path, 'r') as f:
479
+ data = json.load(f)
480
+
481
+ wall_count = 0
482
+ window_count = 0
483
+ door_count = 0
484
+
485
+ # Import walls
486
+ for wall_id, wall_data in data.get("walls", {}).items():
487
+ try:
488
+ wall = BuildingComponentFactory.create_component(wall_data)
489
+ self.walls[wall_id] = wall
490
+ wall_count += 1
491
+ except Exception as e:
492
+ print(f"Error importing wall {wall_id}: {e}")
493
+
494
+ # Import windows
495
+ for window_id, window_data in data.get("windows", {}).items():
496
+ try:
497
+ window = BuildingComponentFactory.create_component(window_data)
498
+ self.windows[window_id] = window
499
+ window_count += 1
500
+ except Exception as e:
501
+ print(f"Error importing window {window_id}: {e}")
502
+
503
+ # Import doors
504
+ for door_id, door_data in data.get("doors", {}).items():
505
+ try:
506
+ door = BuildingComponentFactory.create_component(door_data)
507
+ self.doors[door_id] = door
508
+ door_count += 1
509
+ except Exception as e:
510
+ print(f"Error importing door {door_id}: {e}")
511
+
512
+ # Update all net areas
513
+ self.update_all_net_areas()
514
+
515
+ return (wall_count, window_count, door_count)
516
+
517
+
518
+ # Create a singleton instance
519
+ area_calculation_system = AreaCalculationSystem()
520
+
521
+ # Export area calculation system to JSON if needed
522
+ if __name__ == "__main__":
523
+ area_calculation_system.export_to_json(os.path.join(DATA_DIR, "data", "area_calculation_system.json"))
utils/component_library.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Component library module for HVAC Load Calculator.
3
+ This module implements the preset component database and component selection interface.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ import uuid
12
+ from dataclasses import asdict
13
+
14
+ # Import data models
15
+ from data.building_components import (
16
+ BuildingComponent, Wall, Roof, Floor, Window, Door, Skylight,
17
+ MaterialLayer, Orientation, ComponentType, BuildingComponentFactory
18
+ )
19
+ from data.reference_data import reference_data
20
+
21
+ # Define paths
22
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
23
+
24
+
25
+ class ComponentLibrary:
26
+ """Class for managing building component library."""
27
+
28
+ def __init__(self):
29
+ """Initialize component library."""
30
+ self.components = {}
31
+ self.load_preset_components()
32
+
33
+ def load_preset_components(self):
34
+ """Load preset components from reference data."""
35
+ # Load preset walls
36
+ for wall_id, wall_data in reference_data.wall_types.items():
37
+ # Create material layers
38
+ material_layers = []
39
+ for layer_data in wall_data.get("layers", []):
40
+ material_id = layer_data.get("material")
41
+ thickness = layer_data.get("thickness")
42
+
43
+ material = reference_data.get_material(material_id)
44
+ if material:
45
+ layer = MaterialLayer(
46
+ name=material["name"],
47
+ thickness=thickness,
48
+ conductivity=material["conductivity"],
49
+ density=material.get("density"),
50
+ specific_heat=material.get("specific_heat")
51
+ )
52
+ material_layers.append(layer)
53
+
54
+ # Create wall component
55
+ component_id = f"preset_wall_{wall_id}"
56
+ wall = Wall(
57
+ id=component_id,
58
+ name=wall_data["name"],
59
+ component_type=ComponentType.WALL,
60
+ u_value=wall_data["u_value"],
61
+ area=1.0, # Area will be set when component is used
62
+ orientation=Orientation.NOT_APPLICABLE, # Orientation will be set when component is used
63
+ wall_type=wall_data["name"],
64
+ wall_group=wall_data["wall_group"],
65
+ material_layers=material_layers
66
+ )
67
+
68
+ self.components[component_id] = wall
69
+
70
+ # Load preset roofs
71
+ for roof_id, roof_data in reference_data.roof_types.items():
72
+ # Create material layers
73
+ material_layers = []
74
+ for layer_data in roof_data.get("layers", []):
75
+ material_id = layer_data.get("material")
76
+ thickness = layer_data.get("thickness")
77
+
78
+ material = reference_data.get_material(material_id)
79
+ if material:
80
+ layer = MaterialLayer(
81
+ name=material["name"],
82
+ thickness=thickness,
83
+ conductivity=material["conductivity"],
84
+ density=material.get("density"),
85
+ specific_heat=material.get("specific_heat")
86
+ )
87
+ material_layers.append(layer)
88
+
89
+ # Create roof component
90
+ component_id = f"preset_roof_{roof_id}"
91
+ roof = Roof(
92
+ id=component_id,
93
+ name=roof_data["name"],
94
+ component_type=ComponentType.ROOF,
95
+ u_value=roof_data["u_value"],
96
+ area=1.0, # Area will be set when component is used
97
+ orientation=Orientation.HORIZONTAL,
98
+ roof_type=roof_data["name"],
99
+ roof_group=roof_data["roof_group"],
100
+ material_layers=material_layers
101
+ )
102
+
103
+ self.components[component_id] = roof
104
+
105
+ # Load preset floors
106
+ for floor_id, floor_data in reference_data.floor_types.items():
107
+ # Create material layers
108
+ material_layers = []
109
+ for layer_data in floor_data.get("layers", []):
110
+ material_id = layer_data.get("material")
111
+ thickness = layer_data.get("thickness")
112
+
113
+ material = reference_data.get_material(material_id)
114
+ if material:
115
+ layer = MaterialLayer(
116
+ name=material["name"],
117
+ thickness=thickness,
118
+ conductivity=material["conductivity"],
119
+ density=material.get("density"),
120
+ specific_heat=material.get("specific_heat")
121
+ )
122
+ material_layers.append(layer)
123
+
124
+ # Create floor component
125
+ component_id = f"preset_floor_{floor_id}"
126
+ floor = Floor(
127
+ id=component_id,
128
+ name=floor_data["name"],
129
+ component_type=ComponentType.FLOOR,
130
+ u_value=floor_data["u_value"],
131
+ area=1.0, # Area will be set when component is used
132
+ orientation=Orientation.HORIZONTAL,
133
+ floor_type=floor_data["name"],
134
+ is_ground_contact=floor_data["is_ground_contact"],
135
+ material_layers=material_layers
136
+ )
137
+
138
+ self.components[component_id] = floor
139
+
140
+ # Load preset windows
141
+ for window_id, window_data in reference_data.window_types.items():
142
+ # Create window component
143
+ component_id = f"preset_window_{window_id}"
144
+ window = Window(
145
+ id=component_id,
146
+ name=window_data["name"],
147
+ component_type=ComponentType.WINDOW,
148
+ u_value=window_data["u_value"],
149
+ area=1.0, # Area will be set when component is used
150
+ orientation=Orientation.NOT_APPLICABLE, # Orientation will be set when component is used
151
+ shgc=window_data["shgc"],
152
+ vt=window_data["vt"],
153
+ window_type=window_data["name"],
154
+ glazing_layers=window_data["glazing_layers"],
155
+ gas_fill=window_data["gas_fill"],
156
+ low_e_coating=window_data["low_e_coating"]
157
+ )
158
+
159
+ self.components[component_id] = window
160
+
161
+ # Load preset doors
162
+ for door_id, door_data in reference_data.door_types.items():
163
+ # Create door component
164
+ component_id = f"preset_door_{door_id}"
165
+ door = Door(
166
+ id=component_id,
167
+ name=door_data["name"],
168
+ component_type=ComponentType.DOOR,
169
+ u_value=door_data["u_value"],
170
+ area=1.0, # Area will be set when component is used
171
+ orientation=Orientation.NOT_APPLICABLE, # Orientation will be set when component is used
172
+ door_type=door_data["door_type"],
173
+ glazing_percentage=door_data["glazing_percentage"],
174
+ shgc=door_data.get("shgc", 0.0),
175
+ vt=door_data.get("vt", 0.0)
176
+ )
177
+
178
+ self.components[component_id] = door
179
+
180
+ def get_component(self, component_id: str) -> Optional[BuildingComponent]:
181
+ """
182
+ Get a component by ID.
183
+
184
+ Args:
185
+ component_id: Component identifier
186
+
187
+ Returns:
188
+ BuildingComponent object or None if not found
189
+ """
190
+ return self.components.get(component_id)
191
+
192
+ def get_components_by_type(self, component_type: ComponentType) -> List[BuildingComponent]:
193
+ """
194
+ Get all components of a specific type.
195
+
196
+ Args:
197
+ component_type: Component type
198
+
199
+ Returns:
200
+ List of BuildingComponent objects
201
+ """
202
+ return [comp for comp in self.components.values() if comp.component_type == component_type]
203
+
204
+ def get_preset_components_by_type(self, component_type: ComponentType) -> List[BuildingComponent]:
205
+ """
206
+ Get all preset components of a specific type.
207
+
208
+ Args:
209
+ component_type: Component type
210
+
211
+ Returns:
212
+ List of BuildingComponent objects
213
+ """
214
+ return [comp for comp in self.components.values()
215
+ if comp.component_type == component_type and comp.id.startswith("preset_")]
216
+
217
+ def get_custom_components_by_type(self, component_type: ComponentType) -> List[BuildingComponent]:
218
+ """
219
+ Get all custom components of a specific type.
220
+
221
+ Args:
222
+ component_type: Component type
223
+
224
+ Returns:
225
+ List of BuildingComponent objects
226
+ """
227
+ return [comp for comp in self.components.values()
228
+ if comp.component_type == component_type and comp.id.startswith("custom_")]
229
+
230
+ def add_component(self, component: BuildingComponent) -> str:
231
+ """
232
+ Add a component to the library.
233
+
234
+ Args:
235
+ component: BuildingComponent object
236
+
237
+ Returns:
238
+ Component ID
239
+ """
240
+ if component.id in self.components:
241
+ # Generate a new ID if the component ID already exists
242
+ component.id = f"custom_{component.component_type.value.lower()}_{str(uuid.uuid4())[:8]}"
243
+
244
+ self.components[component.id] = component
245
+ return component.id
246
+
247
+ def update_component(self, component_id: str, component: BuildingComponent) -> bool:
248
+ """
249
+ Update a component in the library.
250
+
251
+ Args:
252
+ component_id: ID of the component to update
253
+ component: Updated BuildingComponent object
254
+
255
+ Returns:
256
+ True if the component was updated, False otherwise
257
+ """
258
+ if component_id not in self.components:
259
+ return False
260
+
261
+ # Preserve the original ID
262
+ component.id = component_id
263
+ self.components[component_id] = component
264
+ return True
265
+
266
+ def remove_component(self, component_id: str) -> bool:
267
+ """
268
+ Remove a component from the library.
269
+
270
+ Args:
271
+ component_id: ID of the component to remove
272
+
273
+ Returns:
274
+ True if the component was removed, False otherwise
275
+ """
276
+ if component_id not in self.components:
277
+ return False
278
+
279
+ # Don't allow removing preset components
280
+ if component_id.startswith("preset_"):
281
+ return False
282
+
283
+ del self.components[component_id]
284
+ return True
285
+
286
+ def clone_component(self, component_id: str, new_name: str = None) -> Optional[str]:
287
+ """
288
+ Clone a component in the library.
289
+
290
+ Args:
291
+ component_id: ID of the component to clone
292
+ new_name: Name for the cloned component (optional)
293
+
294
+ Returns:
295
+ ID of the cloned component or None if the original component was not found
296
+ """
297
+ if component_id not in self.components:
298
+ return None
299
+
300
+ # Get the original component
301
+ original = self.components[component_id]
302
+
303
+ # Create a copy of the component
304
+ component_dict = asdict(original)
305
+
306
+ # Generate a new ID
307
+ component_dict["id"] = f"custom_{original.component_type.value.lower()}_{str(uuid.uuid4())[:8]}"
308
+
309
+ # Set new name if provided
310
+ if new_name:
311
+ component_dict["name"] = new_name
312
+ else:
313
+ component_dict["name"] = f"Copy of {original.name}"
314
+
315
+ # Create a new component
316
+ new_component = BuildingComponentFactory.create_component(component_dict)
317
+
318
+ # Add the new component to the library
319
+ self.components[new_component.id] = new_component
320
+
321
+ return new_component.id
322
+
323
+ def export_to_json(self, file_path: str) -> None:
324
+ """
325
+ Export all components to a JSON file.
326
+
327
+ Args:
328
+ file_path: Path to the output JSON file
329
+ """
330
+ data = {comp_id: comp.to_dict() for comp_id, comp in self.components.items()}
331
+
332
+ with open(file_path, 'w') as f:
333
+ json.dump(data, f, indent=4)
334
+
335
+ def import_from_json(self, file_path: str) -> int:
336
+ """
337
+ Import components from a JSON file.
338
+
339
+ Args:
340
+ file_path: Path to the input JSON file
341
+
342
+ Returns:
343
+ Number of components imported
344
+ """
345
+ with open(file_path, 'r') as f:
346
+ data = json.load(f)
347
+
348
+ count = 0
349
+ for comp_id, comp_data in data.items():
350
+ try:
351
+ component = BuildingComponentFactory.create_component(comp_data)
352
+ self.components[comp_id] = component
353
+ count += 1
354
+ except Exception as e:
355
+ print(f"Error importing component {comp_id}: {e}")
356
+
357
+ return count
358
+
359
+
360
+ # Create a singleton instance
361
+ component_library = ComponentLibrary()
362
+
363
+ # Export component library to JSON if needed
364
+ if __name__ == "__main__":
365
+ component_library.export_to_json(os.path.join(DATA_DIR, "component_library.json"))
utils/component_visualization.py ADDED
@@ -0,0 +1,721 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hierarchical component visualization module for HVAC Load Calculator.
3
+ This module provides visualization tools for building components.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ import plotly.graph_objects as go
10
+ import plotly.express as px
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ import math
13
+
14
+ # Import data models
15
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
16
+
17
+
18
+ class ComponentVisualization:
19
+ """Class for hierarchical component visualization."""
20
+
21
+ @staticmethod
22
+ def create_component_summary_table(components: Dict[str, List[Any]]) -> pd.DataFrame:
23
+ """
24
+ Create a summary table of building components.
25
+
26
+ Args:
27
+ components: Dictionary with lists of building components
28
+
29
+ Returns:
30
+ DataFrame with component summary
31
+ """
32
+ # Initialize data
33
+ data = []
34
+
35
+ # Process walls
36
+ for wall in components.get("walls", []):
37
+ data.append({
38
+ "Component Type": "Wall",
39
+ "Name": wall.name,
40
+ "Orientation": wall.orientation.name,
41
+ "Area (m²)": wall.area,
42
+ "U-Value (W/m²·K)": wall.u_value,
43
+ "Heat Transfer (W/K)": wall.area * wall.u_value
44
+ })
45
+
46
+ # Process roofs
47
+ for roof in components.get("roofs", []):
48
+ data.append({
49
+ "Component Type": "Roof",
50
+ "Name": roof.name,
51
+ "Orientation": roof.orientation.name,
52
+ "Area (m²)": roof.area,
53
+ "U-Value (W/m²·K)": roof.u_value,
54
+ "Heat Transfer (W/K)": roof.area * roof.u_value
55
+ })
56
+
57
+ # Process floors
58
+ for floor in components.get("floors", []):
59
+ data.append({
60
+ "Component Type": "Floor",
61
+ "Name": floor.name,
62
+ "Orientation": "Horizontal",
63
+ "Area (m²)": floor.area,
64
+ "U-Value (W/m²·K)": floor.u_value,
65
+ "Heat Transfer (W/K)": floor.area * floor.u_value
66
+ })
67
+
68
+ # Process windows
69
+ for window in components.get("windows", []):
70
+ data.append({
71
+ "Component Type": "Window",
72
+ "Name": window.name,
73
+ "Orientation": window.orientation.name,
74
+ "Area (m²)": window.area,
75
+ "U-Value (W/m²·K)": window.u_value,
76
+ "Heat Transfer (W/K)": window.area * window.u_value,
77
+ "SHGC": window.shgc if hasattr(window, "shgc") else None
78
+ })
79
+
80
+ # Process doors
81
+ for door in components.get("doors", []):
82
+ data.append({
83
+ "Component Type": "Door",
84
+ "Name": door.name,
85
+ "Orientation": door.orientation.name,
86
+ "Area (m²)": door.area,
87
+ "U-Value (W/m²·K)": door.u_value,
88
+ "Heat Transfer (W/K)": door.area * door.u_value
89
+ })
90
+
91
+ # Create DataFrame
92
+ df = pd.DataFrame(data)
93
+
94
+ return df
95
+
96
+ @staticmethod
97
+ def create_component_area_chart(components: Dict[str, List[Any]]) -> go.Figure:
98
+ """
99
+ Create a pie chart of component areas.
100
+
101
+ Args:
102
+ components: Dictionary with lists of building components
103
+
104
+ Returns:
105
+ Plotly figure with component area breakdown
106
+ """
107
+ # Calculate total areas by component type
108
+ areas = {
109
+ "Walls": sum(wall.area for wall in components.get("walls", [])),
110
+ "Roofs": sum(roof.area for roof in components.get("roofs", [])),
111
+ "Floors": sum(floor.area for floor in components.get("floors", [])),
112
+ "Windows": sum(window.area for window in components.get("windows", [])),
113
+ "Doors": sum(door.area for door in components.get("doors", []))
114
+ }
115
+
116
+ # Create labels and values
117
+ labels = list(areas.keys())
118
+ values = list(areas.values())
119
+
120
+ # Create pie chart
121
+ fig = go.Figure(data=[go.Pie(
122
+ labels=labels,
123
+ values=values,
124
+ hole=0.3,
125
+ textinfo="label+percent",
126
+ insidetextorientation="radial"
127
+ )])
128
+
129
+ # Update layout
130
+ fig.update_layout(
131
+ title="Building Component Areas",
132
+ height=500,
133
+ legend=dict(
134
+ orientation="h",
135
+ yanchor="bottom",
136
+ y=1.02,
137
+ xanchor="right",
138
+ x=1
139
+ )
140
+ )
141
+
142
+ return fig
143
+
144
+ @staticmethod
145
+ def create_orientation_area_chart(components: Dict[str, List[Any]]) -> go.Figure:
146
+ """
147
+ Create a bar chart of areas by orientation.
148
+
149
+ Args:
150
+ components: Dictionary with lists of building components
151
+
152
+ Returns:
153
+ Plotly figure with area breakdown by orientation
154
+ """
155
+ # Initialize areas by orientation
156
+ orientation_areas = {
157
+ "NORTH": 0,
158
+ "NORTHEAST": 0,
159
+ "EAST": 0,
160
+ "SOUTHEAST": 0,
161
+ "SOUTH": 0,
162
+ "SOUTHWEST": 0,
163
+ "WEST": 0,
164
+ "NORTHWEST": 0,
165
+ "HORIZONTAL": 0
166
+ }
167
+
168
+ # Calculate areas by orientation for walls
169
+ for wall in components.get("walls", []):
170
+ orientation_areas[wall.orientation.name] += wall.area
171
+
172
+ # Calculate areas by orientation for windows
173
+ for window in components.get("windows", []):
174
+ orientation_areas[window.orientation.name] += window.area
175
+
176
+ # Calculate areas by orientation for doors
177
+ for door in components.get("doors", []):
178
+ orientation_areas[door.orientation.name] += door.area
179
+
180
+ # Add roofs and floors to horizontal
181
+ for roof in components.get("roofs", []):
182
+ if roof.orientation.name == "HORIZONTAL":
183
+ orientation_areas["HORIZONTAL"] += roof.area
184
+ else:
185
+ orientation_areas[roof.orientation.name] += roof.area
186
+
187
+ for floor in components.get("floors", []):
188
+ orientation_areas["HORIZONTAL"] += floor.area
189
+
190
+ # Create labels and values
191
+ orientations = []
192
+ areas = []
193
+
194
+ for orientation, area in orientation_areas.items():
195
+ if area > 0:
196
+ orientations.append(orientation)
197
+ areas.append(area)
198
+
199
+ # Create bar chart
200
+ fig = go.Figure(data=[go.Bar(
201
+ x=orientations,
202
+ y=areas,
203
+ text=areas,
204
+ texttemplate="%{y:.1f} m²",
205
+ textposition="auto"
206
+ )])
207
+
208
+ # Update layout
209
+ fig.update_layout(
210
+ title="Building Component Areas by Orientation",
211
+ xaxis_title="Orientation",
212
+ yaxis_title="Area (m²)",
213
+ height=500
214
+ )
215
+
216
+ return fig
217
+
218
+ @staticmethod
219
+ def create_heat_transfer_chart(components: Dict[str, List[Any]]) -> go.Figure:
220
+ """
221
+ Create a bar chart of heat transfer coefficients by component type.
222
+
223
+ Args:
224
+ components: Dictionary with lists of building components
225
+
226
+ Returns:
227
+ Plotly figure with heat transfer breakdown
228
+ """
229
+ # Calculate heat transfer by component type
230
+ heat_transfer = {
231
+ "Walls": sum(wall.area * wall.u_value for wall in components.get("walls", [])),
232
+ "Roofs": sum(roof.area * roof.u_value for roof in components.get("roofs", [])),
233
+ "Floors": sum(floor.area * floor.u_value for floor in components.get("floors", [])),
234
+ "Windows": sum(window.area * window.u_value for window in components.get("windows", [])),
235
+ "Doors": sum(door.area * door.u_value for door in components.get("doors", []))
236
+ }
237
+
238
+ # Create labels and values
239
+ labels = list(heat_transfer.keys())
240
+ values = list(heat_transfer.values())
241
+
242
+ # Create bar chart
243
+ fig = go.Figure(data=[go.Bar(
244
+ x=labels,
245
+ y=values,
246
+ text=values,
247
+ texttemplate="%{y:.1f} W/K",
248
+ textposition="auto"
249
+ )])
250
+
251
+ # Update layout
252
+ fig.update_layout(
253
+ title="Heat Transfer Coefficients by Component Type",
254
+ xaxis_title="Component Type",
255
+ yaxis_title="Heat Transfer Coefficient (W/K)",
256
+ height=500
257
+ )
258
+
259
+ return fig
260
+
261
+ @staticmethod
262
+ def create_3d_building_model(components: Dict[str, List[Any]]) -> go.Figure:
263
+ """
264
+ Create a 3D visualization of the building components.
265
+
266
+ Args:
267
+ components: Dictionary with lists of building components
268
+
269
+ Returns:
270
+ Plotly figure with 3D building model
271
+ """
272
+ # Initialize figure
273
+ fig = go.Figure()
274
+
275
+ # Define colors
276
+ colors = {
277
+ "Wall": "lightblue",
278
+ "Roof": "red",
279
+ "Floor": "brown",
280
+ "Window": "skyblue",
281
+ "Door": "orange"
282
+ }
283
+
284
+ # Define orientation vectors
285
+ orientation_vectors = {
286
+ "NORTH": (0, 1, 0),
287
+ "NORTHEAST": (0.7071, 0.7071, 0),
288
+ "EAST": (1, 0, 0),
289
+ "SOUTHEAST": (0.7071, -0.7071, 0),
290
+ "SOUTH": (0, -1, 0),
291
+ "SOUTHWEST": (-0.7071, -0.7071, 0),
292
+ "WEST": (-1, 0, 0),
293
+ "NORTHWEST": (-0.7071, 0.7071, 0),
294
+ "HORIZONTAL": (0, 0, 1)
295
+ }
296
+
297
+ # Define building dimensions (simplified model)
298
+ building_width = 10
299
+ building_depth = 10
300
+ building_height = 3
301
+
302
+ # Create walls
303
+ for i, wall in enumerate(components.get("walls", [])):
304
+ orientation = wall.orientation.name
305
+ vector = orientation_vectors[orientation]
306
+
307
+ # Determine wall position and dimensions
308
+ if orientation in ["NORTH", "SOUTH"]:
309
+ width = building_width
310
+ height = building_height
311
+ depth = 0.3
312
+
313
+ if orientation == "NORTH":
314
+ x = 0
315
+ y = building_depth / 2
316
+ else: # SOUTH
317
+ x = 0
318
+ y = -building_depth / 2
319
+
320
+ z = building_height / 2
321
+
322
+ elif orientation in ["EAST", "WEST"]:
323
+ width = 0.3
324
+ height = building_height
325
+ depth = building_depth
326
+
327
+ if orientation == "EAST":
328
+ x = building_width / 2
329
+ y = 0
330
+ else: # WEST
331
+ x = -building_width / 2
332
+ y = 0
333
+
334
+ z = building_height / 2
335
+
336
+ else: # Diagonal orientations
337
+ width = building_width / 2
338
+ height = building_height
339
+ depth = 0.3
340
+
341
+ if orientation == "NORTHEAST":
342
+ x = building_width / 4
343
+ y = building_depth / 4
344
+ elif orientation == "SOUTHEAST":
345
+ x = building_width / 4
346
+ y = -building_depth / 4
347
+ elif orientation == "SOUTHWEST":
348
+ x = -building_width / 4
349
+ y = -building_depth / 4
350
+ else: # NORTHWEST
351
+ x = -building_width / 4
352
+ y = building_depth / 4
353
+
354
+ z = building_height / 2
355
+
356
+ # Add wall to figure
357
+ fig.add_trace(go.Mesh3d(
358
+ x=[x - width/2, x + width/2, x + width/2, x - width/2, x - width/2, x + width/2, x + width/2, x - width/2],
359
+ y=[y - depth/2, y - depth/2, y + depth/2, y + depth/2, y - depth/2, y - depth/2, y + depth/2, y + depth/2],
360
+ z=[z - height/2, z - height/2, z - height/2, z - height/2, z + height/2, z + height/2, z + height/2, z + height/2],
361
+ i=[0, 0, 0, 1, 4, 4],
362
+ j=[1, 2, 4, 2, 5, 6],
363
+ k=[2, 3, 7, 3, 6, 7],
364
+ color=colors["Wall"],
365
+ opacity=0.7,
366
+ name=f"Wall: {wall.name}"
367
+ ))
368
+
369
+ # Create roof
370
+ for i, roof in enumerate(components.get("roofs", [])):
371
+ # Add roof to figure
372
+ fig.add_trace(go.Mesh3d(
373
+ x=[-building_width/2, building_width/2, building_width/2, -building_width/2],
374
+ y=[-building_depth/2, -building_depth/2, building_depth/2, building_depth/2],
375
+ z=[building_height, building_height, building_height, building_height],
376
+ i=[0],
377
+ j=[1],
378
+ k=[2],
379
+ color=colors["Roof"],
380
+ opacity=0.7,
381
+ name=f"Roof: {roof.name}"
382
+ ))
383
+
384
+ fig.add_trace(go.Mesh3d(
385
+ x=[-building_width/2, -building_width/2, building_width/2],
386
+ y=[building_depth/2, -building_depth/2, -building_depth/2],
387
+ z=[building_height, building_height, building_height],
388
+ i=[0],
389
+ j=[1],
390
+ k=[2],
391
+ color=colors["Roof"],
392
+ opacity=0.7,
393
+ name=f"Roof: {roof.name}"
394
+ ))
395
+
396
+ # Create floor
397
+ for i, floor in enumerate(components.get("floors", [])):
398
+ # Add floor to figure
399
+ fig.add_trace(go.Mesh3d(
400
+ x=[-building_width/2, building_width/2, building_width/2, -building_width/2],
401
+ y=[-building_depth/2, -building_depth/2, building_depth/2, building_depth/2],
402
+ z=[0, 0, 0, 0],
403
+ i=[0],
404
+ j=[1],
405
+ k=[2],
406
+ color=colors["Floor"],
407
+ opacity=0.7,
408
+ name=f"Floor: {floor.name}"
409
+ ))
410
+
411
+ fig.add_trace(go.Mesh3d(
412
+ x=[-building_width/2, -building_width/2, building_width/2],
413
+ y=[building_depth/2, -building_depth/2, -building_depth/2],
414
+ z=[0, 0, 0],
415
+ i=[0],
416
+ j=[1],
417
+ k=[2],
418
+ color=colors["Floor"],
419
+ opacity=0.7,
420
+ name=f"Floor: {floor.name}"
421
+ ))
422
+
423
+ # Create windows
424
+ for i, window in enumerate(components.get("windows", [])):
425
+ orientation = window.orientation.name
426
+ vector = orientation_vectors[orientation]
427
+
428
+ # Determine window position and dimensions
429
+ window_width = 1.5
430
+ window_height = 1.2
431
+ window_depth = 0.1
432
+
433
+ if orientation == "NORTH":
434
+ x = i * 3 - building_width/4
435
+ y = building_depth / 2
436
+ z = building_height / 2
437
+ elif orientation == "SOUTH":
438
+ x = i * 3 - building_width/4
439
+ y = -building_depth / 2
440
+ z = building_height / 2
441
+ elif orientation == "EAST":
442
+ x = building_width / 2
443
+ y = i * 3 - building_depth/4
444
+ z = building_height / 2
445
+ elif orientation == "WEST":
446
+ x = -building_width / 2
447
+ y = i * 3 - building_depth/4
448
+ z = building_height / 2
449
+ else:
450
+ # Skip diagonal orientations for simplicity
451
+ continue
452
+
453
+ # Add window to figure
454
+ fig.add_trace(go.Mesh3d(
455
+ x=[x - window_width/2, x + window_width/2, x + window_width/2, x - window_width/2, x - window_width/2, x + window_width/2, x + window_width/2, x - window_width/2],
456
+ y=[y - window_depth/2, y - window_depth/2, y + window_depth/2, y + window_depth/2, y - window_depth/2, y - window_depth/2, y + window_depth/2, y + window_depth/2],
457
+ z=[z - window_height/2, z - window_height/2, z - window_height/2, z - window_height/2, z + window_height/2, z + window_height/2, z + window_height/2, z + window_height/2],
458
+ i=[0, 0, 0, 1, 4, 4],
459
+ j=[1, 2, 4, 2, 5, 6],
460
+ k=[2, 3, 7, 3, 6, 7],
461
+ color=colors["Window"],
462
+ opacity=0.5,
463
+ name=f"Window: {window.name}"
464
+ ))
465
+
466
+ # Create doors
467
+ for i, door in enumerate(components.get("doors", [])):
468
+ orientation = door.orientation.name
469
+ vector = orientation_vectors[orientation]
470
+
471
+ # Determine door position and dimensions
472
+ door_width = 1.0
473
+ door_height = 2.0
474
+ door_depth = 0.1
475
+
476
+ if orientation == "NORTH":
477
+ x = i * 3
478
+ y = building_depth / 2
479
+ z = door_height / 2
480
+ elif orientation == "SOUTH":
481
+ x = i * 3
482
+ y = -building_depth / 2
483
+ z = door_height / 2
484
+ elif orientation == "EAST":
485
+ x = building_width / 2
486
+ y = i * 3
487
+ z = door_height / 2
488
+ elif orientation == "WEST":
489
+ x = -building_width / 2
490
+ y = i * 3
491
+ z = door_height / 2
492
+ else:
493
+ # Skip diagonal orientations for simplicity
494
+ continue
495
+
496
+ # Add door to figure
497
+ fig.add_trace(go.Mesh3d(
498
+ x=[x - door_width/2, x + door_width/2, x + door_width/2, x - door_width/2, x - door_width/2, x + door_width/2, x + door_width/2, x - door_width/2],
499
+ y=[y - door_depth/2, y - door_depth/2, y + door_depth/2, y + door_depth/2, y - door_depth/2, y - door_depth/2, y + door_depth/2, y + door_depth/2],
500
+ z=[z - door_height/2, z - door_height/2, z - door_height/2, z - door_height/2, z + door_height/2, z + door_height/2, z + door_height/2, z + door_height/2],
501
+ i=[0, 0, 0, 1, 4, 4],
502
+ j=[1, 2, 4, 2, 5, 6],
503
+ k=[2, 3, 7, 3, 6, 7],
504
+ color=colors["Door"],
505
+ opacity=0.7,
506
+ name=f"Door: {door.name}"
507
+ ))
508
+
509
+ # Update layout
510
+ fig.update_layout(
511
+ title="3D Building Model",
512
+ scene=dict(
513
+ xaxis_title="X",
514
+ yaxis_title="Y",
515
+ zaxis_title="Z",
516
+ aspectmode="data"
517
+ ),
518
+ height=700,
519
+ margin=dict(l=0, r=0, b=0, t=30)
520
+ )
521
+
522
+ return fig
523
+
524
+ @staticmethod
525
+ def display_component_visualization(components: Dict[str, List[Any]]) -> None:
526
+ """
527
+ Display component visualization in Streamlit.
528
+
529
+ Args:
530
+ components: Dictionary with lists of building components
531
+ """
532
+ st.header("Building Component Visualization")
533
+
534
+ # Create tabs for different visualizations
535
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
536
+ "Component Summary",
537
+ "Area Breakdown",
538
+ "Orientation Analysis",
539
+ "Heat Transfer Analysis",
540
+ "3D Building Model"
541
+ ])
542
+
543
+ with tab1:
544
+ st.subheader("Component Summary")
545
+ df = ComponentVisualization.create_component_summary_table(components)
546
+ st.dataframe(df, use_container_width=True)
547
+
548
+ # Add download button for CSV
549
+ csv = df.to_csv(index=False).encode('utf-8')
550
+ st.download_button(
551
+ label="Download Component Summary as CSV",
552
+ data=csv,
553
+ file_name="component_summary.csv",
554
+ mime="text/csv"
555
+ )
556
+
557
+ with tab2:
558
+ st.subheader("Area Breakdown")
559
+ fig = ComponentVisualization.create_component_area_chart(components)
560
+ st.plotly_chart(fig, use_container_width=True)
561
+
562
+ with tab3:
563
+ st.subheader("Orientation Analysis")
564
+ fig = ComponentVisualization.create_orientation_area_chart(components)
565
+ st.plotly_chart(fig, use_container_width=True)
566
+
567
+ with tab4:
568
+ st.subheader("Heat Transfer Analysis")
569
+ fig = ComponentVisualization.create_heat_transfer_chart(components)
570
+ st.plotly_chart(fig, use_container_width=True)
571
+
572
+ with tab5:
573
+ st.subheader("3D Building Model")
574
+ fig = ComponentVisualization.create_3d_building_model(components)
575
+ st.plotly_chart(fig, use_container_width=True)
576
+
577
+
578
+ # Create a singleton instance
579
+ component_visualization = ComponentVisualization()
580
+
581
+ # Example usage
582
+ if __name__ == "__main__":
583
+ import streamlit as st
584
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
585
+
586
+ # Create sample building components
587
+ walls = [
588
+ Wall(
589
+ id="wall1",
590
+ name="North Wall",
591
+ component_type=ComponentType.WALL,
592
+ u_value=0.5,
593
+ area=20.0,
594
+ orientation=Orientation.NORTH,
595
+ wall_type="Brick",
596
+ wall_group="B"
597
+ ),
598
+ Wall(
599
+ id="wall2",
600
+ name="South Wall",
601
+ component_type=ComponentType.WALL,
602
+ u_value=0.5,
603
+ area=20.0,
604
+ orientation=Orientation.SOUTH,
605
+ wall_type="Brick",
606
+ wall_group="B"
607
+ ),
608
+ Wall(
609
+ id="wall3",
610
+ name="East Wall",
611
+ component_type=ComponentType.WALL,
612
+ u_value=0.5,
613
+ area=15.0,
614
+ orientation=Orientation.EAST,
615
+ wall_type="Brick",
616
+ wall_group="B"
617
+ ),
618
+ Wall(
619
+ id="wall4",
620
+ name="West Wall",
621
+ component_type=ComponentType.WALL,
622
+ u_value=0.5,
623
+ area=15.0,
624
+ orientation=Orientation.WEST,
625
+ wall_type="Brick",
626
+ wall_group="B"
627
+ )
628
+ ]
629
+
630
+ roofs = [
631
+ Roof(
632
+ id="roof1",
633
+ name="Flat Roof",
634
+ component_type=ComponentType.ROOF,
635
+ u_value=0.3,
636
+ area=100.0,
637
+ orientation=Orientation.HORIZONTAL,
638
+ roof_type="Concrete",
639
+ roof_group="C"
640
+ )
641
+ ]
642
+
643
+ floors = [
644
+ Floor(
645
+ id="floor1",
646
+ name="Ground Floor",
647
+ component_type=ComponentType.FLOOR,
648
+ u_value=0.4,
649
+ area=100.0,
650
+ floor_type="Concrete"
651
+ )
652
+ ]
653
+
654
+ windows = [
655
+ Window(
656
+ id="window1",
657
+ name="North Window 1",
658
+ component_type=ComponentType.WINDOW,
659
+ u_value=2.8,
660
+ area=4.0,
661
+ orientation=Orientation.NORTH,
662
+ shgc=0.7,
663
+ vt=0.8,
664
+ window_type="Double Glazed",
665
+ glazing_layers=2,
666
+ gas_fill="Air",
667
+ low_e_coating=False
668
+ ),
669
+ Window(
670
+ id="window2",
671
+ name="South Window 1",
672
+ component_type=ComponentType.WINDOW,
673
+ u_value=2.8,
674
+ area=6.0,
675
+ orientation=Orientation.SOUTH,
676
+ shgc=0.7,
677
+ vt=0.8,
678
+ window_type="Double Glazed",
679
+ glazing_layers=2,
680
+ gas_fill="Air",
681
+ low_e_coating=False
682
+ ),
683
+ Window(
684
+ id="window3",
685
+ name="East Window 1",
686
+ component_type=ComponentType.WINDOW,
687
+ u_value=2.8,
688
+ area=3.0,
689
+ orientation=Orientation.EAST,
690
+ shgc=0.7,
691
+ vt=0.8,
692
+ window_type="Double Glazed",
693
+ glazing_layers=2,
694
+ gas_fill="Air",
695
+ low_e_coating=False
696
+ )
697
+ ]
698
+
699
+ doors = [
700
+ Door(
701
+ id="door1",
702
+ name="Front Door",
703
+ component_type=ComponentType.DOOR,
704
+ u_value=2.0,
705
+ area=2.0,
706
+ orientation=Orientation.SOUTH,
707
+ door_type="Solid Wood"
708
+ )
709
+ ]
710
+
711
+ # Create components dictionary
712
+ components = {
713
+ "walls": walls,
714
+ "roofs": roofs,
715
+ "floors": floors,
716
+ "windows": windows,
717
+ "doors": doors
718
+ }
719
+
720
+ # Display component visualization
721
+ component_visualization.display_component_visualization(components)
utils/cooling_load.py ADDED
@@ -0,0 +1,1029 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cooling load calculation module for HVAC Load Calculator.
3
+ Implements ASHRAE steady-state methods with Cooling Load Temperature Difference (CLTD).
4
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
5
+
6
+ Author: Dr Majed Abuseif
7
+ Date: April 2025
8
+ Version: 1.0.6
9
+ """
10
+
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ import numpy as np
13
+ import logging
14
+ from data.ashrae_tables import ASHRAETables
15
+ from utils.heat_transfer import HeatTransferCalculations
16
+ from utils.psychrometrics import Psychrometrics
17
+ from app.component_selection import Wall, Roof, Window, Door, Orientation
18
+
19
+ # Set up logging
20
+ logging.basicConfig(level=logging.INFO)
21
+ logger = logging.getLogger(__name__)
22
+
23
+ class CoolingLoadCalculator:
24
+ """Class for cooling load calculations based on ASHRAE steady-state methods."""
25
+
26
+ def __init__(self, debug_mode: bool = False):
27
+ """
28
+ Initialize cooling load calculator.
29
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
30
+
31
+ Args:
32
+ debug_mode: Enable debug logging if True
33
+ """
34
+ self.ashrae_tables = ASHRAETables()
35
+ self.heat_transfer = HeatTransferCalculations()
36
+ self.psychrometrics = Psychrometrics()
37
+ self.hours = list(range(24))
38
+ self.valid_latitudes = ['24N', '32N', '40N', '48N', '56N']
39
+ self.valid_months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
40
+ self.valid_wall_groups = ['A', 'B', 'C', 'D']
41
+ self.valid_roof_groups = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
42
+ self.debug_mode = debug_mode
43
+ if debug_mode:
44
+ logger.setLevel(logging.DEBUG)
45
+
46
+ def validate_latitude(self, latitude: Any) -> str:
47
+ """
48
+ Validate and normalize latitude input.
49
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.2.
50
+
51
+ Args:
52
+ latitude: Latitude input (str, float, or other)
53
+
54
+ Returns:
55
+ Valid latitude string ('24N', '32N', '40N', '48N', '56N')
56
+ """
57
+ try:
58
+ if not isinstance(latitude, str):
59
+ try:
60
+ lat_val = float(latitude)
61
+ if lat_val <= 28:
62
+ return '24N'
63
+ elif lat_val <= 36:
64
+ return '32N'
65
+ elif lat_val <= 44:
66
+ return '40N'
67
+ elif lat_val <= 52:
68
+ return '48N'
69
+ else:
70
+ return '56N'
71
+ except (ValueError, TypeError):
72
+ latitude = str(latitude)
73
+
74
+ latitude = latitude.strip().upper()
75
+ if self.debug_mode:
76
+ logger.debug(f"Validating latitude: {latitude}")
77
+
78
+ if '_' in latitude:
79
+ parts = latitude.split('_')
80
+ if len(parts) > 1:
81
+ lat_part = parts[0]
82
+ if self.debug_mode:
83
+ logger.warning(f"Detected concatenated input: {latitude}. Using latitude={lat_part}")
84
+ latitude = lat_part
85
+
86
+ if '.' in latitude or any(c.isdigit() for c in latitude):
87
+ num_part = ''.join(c for c in latitude if c.isdigit() or c == '.')
88
+ try:
89
+ lat_val = float(num_part)
90
+ if lat_val <= 28:
91
+ mapped_latitude = '24N'
92
+ elif lat_val <= 36:
93
+ mapped_latitude = '32N'
94
+ elif lat_val <= 44:
95
+ mapped_latitude = '40N'
96
+ elif lat_val <= 52:
97
+ mapped_latitude = '48N'
98
+ else:
99
+ mapped_latitude = '56N'
100
+ if self.debug_mode:
101
+ logger.debug(f"Mapped numerical latitude {lat_val} to {mapped_latitude}")
102
+ return mapped_latitude
103
+ except ValueError:
104
+ if self.debug_mode:
105
+ logger.warning(f"Cannot parse numerical latitude: {latitude}. Defaulting to '32N'")
106
+ return '32N'
107
+
108
+ if latitude in self.valid_latitudes:
109
+ return latitude
110
+
111
+ if self.debug_mode:
112
+ logger.warning(f"Invalid latitude: {latitude}. Defaulting to '32N'")
113
+ return '32N'
114
+
115
+ except Exception as e:
116
+ if self.debug_mode:
117
+ logger.error(f"Error validating latitude {latitude}: {str(e)}")
118
+ return '32N'
119
+
120
+ def validate_month(self, month: Any) -> str:
121
+ """
122
+ Validate and normalize month input.
123
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.2.
124
+
125
+ Args:
126
+ month: Month input (str or other)
127
+
128
+ Returns:
129
+ Valid month string in uppercase
130
+ """
131
+ try:
132
+ if not isinstance(month, str):
133
+ month = str(month)
134
+
135
+ month_upper = month.strip().upper()
136
+ if month_upper not in self.valid_months:
137
+ if self.debug_mode:
138
+ logger.warning(f"Invalid month: {month}. Defaulting to 'JUL'")
139
+ return 'JUL'
140
+ return month_upper
141
+
142
+ except Exception as e:
143
+ if self.debug_mode:
144
+ logger.error(f"Error validating month {month}: {str(e)}")
145
+ return 'JUL'
146
+
147
+ def validate_hour(self, hour: Any) -> int:
148
+ """
149
+ Validate and normalize hour input.
150
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.2.
151
+
152
+ Args:
153
+ hour: Hour input (int, float, or other)
154
+
155
+ Returns:
156
+ Valid hour integer (0-23)
157
+ """
158
+ try:
159
+ hour = int(float(str(hour)))
160
+ if not 0 <= hour <= 23:
161
+ if self.debug_mode:
162
+ logger.warning(f"Invalid hour: {hour}. Defaulting to 15")
163
+ return 15
164
+ return hour
165
+ except (ValueError, TypeError):
166
+ if self.debug_mode:
167
+ logger.warning(f"Invalid hour format: {hour}. Defaulting to 15")
168
+ return 15
169
+
170
+ def validate_conditions(self, outdoor_temp: float, indoor_temp: float,
171
+ outdoor_rh: float, indoor_rh: float) -> None:
172
+ """
173
+ Validate temperature and relative humidity inputs.
174
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.2.
175
+
176
+ Args:
177
+ outdoor_temp: Outdoor temperature in °C
178
+ indoor_temp: Indoor temperature in °C
179
+ outdoor_rh: Outdoor relative humidity in %
180
+ indoor_rh: Indoor relative humidity in %
181
+
182
+ Raises:
183
+ ValueError: If inputs are invalid
184
+ """
185
+ if not -50 <= outdoor_temp <= 60 or not -50 <= indoor_temp <= 60:
186
+ raise ValueError("Temperatures must be between -50°C and 60°C")
187
+ if not 0 <= outdoor_rh <= 100 or not 0 <= indoor_rh <= 100:
188
+ raise ValueError("Relative humidities must be between 0 and 100%")
189
+ if outdoor_temp - indoor_temp < 1:
190
+ raise ValueError("Outdoor temperature must be at least 1°C above indoor temperature for cooling")
191
+
192
+ def calculate_hourly_cooling_loads(
193
+ self,
194
+ building_components: Dict[str, List[Any]],
195
+ outdoor_conditions: Dict[str, Any],
196
+ indoor_conditions: Dict[str, Any],
197
+ internal_loads: Dict[str, Any],
198
+ building_volume: float,
199
+ p_atm: float = 101325
200
+ ) -> Dict[str, Any]:
201
+ """
202
+ Calculate hourly cooling loads for all components.
203
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
204
+
205
+ Args:
206
+ building_components: Dictionary of building components
207
+ outdoor_conditions: Outdoor weather conditions (temperature, relative_humidity, latitude, month)
208
+ indoor_conditions: Indoor design conditions (temperature, relative_humidity)
209
+ internal_loads: Internal heat gains (people, lights, equipment, infiltration, ventilation)
210
+ building_volume: Building volume in cubic meters
211
+ p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
212
+
213
+ Returns:
214
+ Dictionary containing hourly cooling loads
215
+ """
216
+ hourly_loads = {
217
+ 'walls': {h: 0.0 for h in range(1, 25)},
218
+ 'roofs': {h: 0.0 for h in range(1, 25)},
219
+ 'windows_conduction': {h: 0.0 for h in range(1, 25)},
220
+ 'windows_solar': {h: 0.0 for h in range(1, 25)},
221
+ 'doors': {h: 0.0 for h in range(1, 25)},
222
+ 'people_sensible': {h: 0.0 for h in range(1, 25)},
223
+ 'people_latent': {h: 0.0 for h in range(1, 25)},
224
+ 'lights': {h: 0.0 for h in range(1, 25)},
225
+ 'equipment_sensible': {h: 0.0 for h in range(1, 25)},
226
+ 'equipment_latent': {h: 0.0 for h in range(1, 25)},
227
+ 'infiltration_sensible': {h: 0.0 for h in range(1, 25)},
228
+ 'infiltration_latent': {h: 0.0 for h in range(1, 25)},
229
+ 'ventilation_sensible': {h: 0.0 for h in range(1, 25)},
230
+ 'ventilation_latent': {h: 0.0 for h in range(1, 25)}
231
+ }
232
+
233
+ try:
234
+ # Validate conditions
235
+ self.validate_conditions(
236
+ outdoor_conditions['temperature'],
237
+ indoor_conditions['temperature'],
238
+ outdoor_conditions.get('relative_humidity', 50.0),
239
+ indoor_conditions.get('relative_humidity', 50.0)
240
+ )
241
+
242
+ latitude = self.validate_latitude(outdoor_conditions.get('latitude', '32N'))
243
+ month = self.validate_month(outdoor_conditions.get('month', 'JUL'))
244
+ if self.debug_mode:
245
+ logger.debug(f"calculate_hourly_cooling_loads: latitude={latitude}, month={month}, outdoor_conditions={outdoor_conditions}")
246
+
247
+ # Calculate loads for walls
248
+ for wall in building_components.get('walls', []):
249
+ for hour in range(24):
250
+ load = self.calculate_wall_cooling_load(
251
+ wall=wall,
252
+ outdoor_temp=outdoor_conditions['temperature'],
253
+ indoor_temp=indoor_conditions['temperature'],
254
+ month=month,
255
+ hour=hour,
256
+ latitude=latitude
257
+ )
258
+ hourly_loads['walls'][hour + 1] += load
259
+
260
+ # Calculate loads for roofs
261
+ for roof in building_components.get('roofs', []):
262
+ for hour in range(24):
263
+ load = self.calculate_roof_cooling_load(
264
+ roof=roof,
265
+ outdoor_temp=outdoor_conditions['temperature'],
266
+ indoor_temp=indoor_conditions['temperature'],
267
+ month=month,
268
+ hour=hour,
269
+ latitude=latitude
270
+ )
271
+ hourly_loads['roofs'][hour + 1] += load
272
+
273
+ # Calculate loads for windows
274
+ for window in building_components.get('windows', []):
275
+ for hour in range(24):
276
+ load_dict = self.calculate_window_cooling_load(
277
+ window=window,
278
+ outdoor_temp=outdoor_conditions['temperature'],
279
+ indoor_temp=indoor_conditions['temperature'],
280
+ month=month,
281
+ hour=hour,
282
+ latitude=latitude,
283
+ shading_coefficient=window.shading_coefficient
284
+ )
285
+ hourly_loads['windows_conduction'][hour + 1] += load_dict['conduction']
286
+ hourly_loads['windows_solar'][hour + 1] += load_dict['solar']
287
+
288
+ # Calculate loads for doors
289
+ for door in building_components.get('doors', []):
290
+ for hour in range(24):
291
+ load = self.calculate_door_cooling_load(
292
+ door=door,
293
+ outdoor_temp=outdoor_conditions['temperature'],
294
+ indoor_temp=indoor_conditions['temperature']
295
+ )
296
+ hourly_loads['doors'][hour + 1] += load
297
+
298
+ # Calculate internal loads
299
+ for hour in range(24):
300
+ # People loads
301
+ people_load = self.calculate_people_cooling_load(
302
+ num_people=internal_loads['people']['number'],
303
+ activity_level=internal_loads['people']['activity_level'],
304
+ hour=hour
305
+ )
306
+ hourly_loads['people_sensible'][hour + 1] += people_load['sensible']
307
+ hourly_loads['people_latent'][hour + 1] += people_load['latent']
308
+
309
+ # Lighting loads
310
+ lights_load = self.calculate_lights_cooling_load(
311
+ power=internal_loads['lights']['power'],
312
+ use_factor=internal_loads['lights']['use_factor'],
313
+ special_allowance=internal_loads['lights']['special_allowance'],
314
+ hour=hour
315
+ )
316
+ hourly_loads['lights'][hour + 1] += lights_load
317
+
318
+ # Equipment loads
319
+ equipment_load = self.calculate_equipment_cooling_load(
320
+ power=internal_loads['equipment']['power'],
321
+ use_factor=internal_loads['equipment']['use_factor'],
322
+ radiation_factor=internal_loads['equipment']['radiation_factor'],
323
+ hour=hour
324
+ )
325
+ hourly_loads['equipment_sensible'][hour + 1] += equipment_load['sensible']
326
+ hourly_loads['equipment_latent'][hour + 1] += equipment_load['latent']
327
+
328
+ # Infiltration loads
329
+ infiltration_load = self.calculate_infiltration_cooling_load(
330
+ flow_rate=internal_loads['infiltration']['flow_rate'],
331
+ building_volume=building_volume,
332
+ outdoor_temp=outdoor_conditions['temperature'],
333
+ outdoor_rh=outdoor_conditions['relative_humidity'],
334
+ indoor_temp=indoor_conditions['temperature'],
335
+ indoor_rh=indoor_conditions['relative_humidity'],
336
+ p_atm=p_atm
337
+ )
338
+ hourly_loads['infiltration_sensible'][hour + 1] += infiltration_load['sensible']
339
+ hourly_loads['infiltration_latent'][hour + 1] += infiltration_load['latent']
340
+
341
+ # Ventilation loads
342
+ ventilation_load = self.calculate_ventilation_cooling_load(
343
+ flow_rate=internal_loads['ventilation']['flow_rate'],
344
+ outdoor_temp=outdoor_conditions['temperature'],
345
+ outdoor_rh=outdoor_conditions['relative_humidity'],
346
+ indoor_temp=indoor_conditions['temperature'],
347
+ indoor_rh=indoor_conditions['relative_humidity'],
348
+ p_atm=p_atm
349
+ )
350
+ hourly_loads['ventilation_sensible'][hour + 1] += ventilation_load['sensible']
351
+ hourly_loads['ventilation_latent'][hour + 1] += ventilation_load['latent']
352
+
353
+ return hourly_loads
354
+
355
+ except Exception as e:
356
+ if self.debug_mode:
357
+ logger.error(f"Error in calculate_hourly_cooling_loads: {str(e)}")
358
+ raise Exception(f"Error in calculate_hourly_cooling_loads: {str(e)}")
359
+
360
+ def calculate_design_cooling_load(self, hourly_loads: Dict[str, Any]) -> Dict[str, Any]:
361
+ """
362
+ Calculate design cooling load based on peak hourly loads.
363
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
364
+
365
+ Args:
366
+ hourly_loads: Dictionary of hourly cooling loads
367
+
368
+ Returns:
369
+ Dictionary containing design cooling loads
370
+ """
371
+ try:
372
+ design_loads = {}
373
+ total_loads = []
374
+
375
+ for hour in range(1, 25):
376
+ total_load = sum([
377
+ hourly_loads['walls'][hour],
378
+ hourly_loads['roofs'][hour],
379
+ hourly_loads['windows_conduction'][hour],
380
+ hourly_loads['windows_solar'][hour],
381
+ hourly_loads['doors'][hour],
382
+ hourly_loads['people_sensible'][hour],
383
+ hourly_loads['people_latent'][hour],
384
+ hourly_loads['lights'][hour],
385
+ hourly_loads['equipment_sensible'][hour],
386
+ hourly_loads['equipment_latent'][hour],
387
+ hourly_loads['infiltration_sensible'][hour],
388
+ hourly_loads['infiltration_latent'][hour],
389
+ hourly_loads['ventilation_sensible'][hour],
390
+ hourly_loads['ventilation_latent'][hour]
391
+ ])
392
+ total_loads.append(total_load)
393
+
394
+ design_hour = range(1, 25)[np.argmax(total_loads)]
395
+
396
+ design_loads = {
397
+ 'design_hour': design_hour,
398
+ 'walls': hourly_loads['walls'][design_hour],
399
+ 'roofs': hourly_loads['roofs'][design_hour],
400
+ 'windows_conduction': hourly_loads['windows_conduction'][design_hour],
401
+ 'windows_solar': hourly_loads['windows_solar'][design_hour],
402
+ 'doors': hourly_loads['doors'][design_hour],
403
+ 'people_sensible': hourly_loads['people_sensible'][design_hour],
404
+ 'people_latent': hourly_loads['people_latent'][design_hour],
405
+ 'lights': hourly_loads['lights'][design_hour],
406
+ 'equipment_sensible': hourly_loads['equipment_sensible'][design_hour],
407
+ 'equipment_latent': hourly_loads['equipment_latent'][design_hour],
408
+ 'infiltration_sensible': hourly_loads['infiltration_sensible'][design_hour],
409
+ 'infiltration_latent': hourly_loads['infiltration_latent'][design_hour],
410
+ 'ventilation_sensible': hourly_loads['ventilation_sensible'][design_hour],
411
+ 'ventilation_latent': hourly_loads['ventilation_latent'][design_hour]
412
+ }
413
+
414
+ return design_loads
415
+
416
+ except Exception as e:
417
+ if self.debug_mode:
418
+ logger.error(f"Error in calculate_design_cooling_load: {str(e)}")
419
+ raise Exception(f"Error in calculate_design_cooling_load: {str(e)}")
420
+
421
+ def calculate_cooling_load_summary(self, design_loads: Dict[str, Any]) -> Dict[str, float]:
422
+ """
423
+ Calculate summary of cooling loads.
424
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.5.
425
+
426
+ Args:
427
+ design_loads: Dictionary of design cooling loads
428
+
429
+ Returns:
430
+ Dictionary containing cooling load summary
431
+ """
432
+ try:
433
+ total_sensible = (
434
+ design_loads['walls'] +
435
+ design_loads['roofs'] +
436
+ design_loads['windows_conduction'] +
437
+ design_loads['windows_solar'] +
438
+ design_loads['doors'] +
439
+ design_loads['people_sensible'] +
440
+ design_loads['lights'] +
441
+ design_loads['equipment_sensible'] +
442
+ design_loads['infiltration_sensible'] +
443
+ design_loads['ventilation_sensible']
444
+ )
445
+
446
+ total_latent = (
447
+ design_loads['people_latent'] +
448
+ design_loads['equipment_latent'] +
449
+ design_loads['infiltration_latent'] +
450
+ design_loads['ventilation_latent']
451
+ )
452
+
453
+ total = total_sensible + total_latent
454
+
455
+ return {
456
+ 'total_sensible': total_sensible,
457
+ 'total_latent': total_latent,
458
+ 'total': total
459
+ }
460
+
461
+ except Exception as e:
462
+ if self.debug_mode:
463
+ logger.error(f"Error in calculate_cooling_load_summary: {str(e)}")
464
+ raise Exception(f"Error in calculate_cooling_load_summary: {str(e)}")
465
+
466
+ def calculate_wall_cooling_load(
467
+ self,
468
+ wall: Wall,
469
+ outdoor_temp: float,
470
+ indoor_temp: float,
471
+ month: str,
472
+ hour: int,
473
+ latitude: str
474
+ ) -> float:
475
+ """
476
+ Calculate cooling load for a wall using CLTD method.
477
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.10.
478
+
479
+ Args:
480
+ wall: Wall component
481
+ outdoor_temp: Outdoor temperature (°C)
482
+ indoor_temp: Indoor temperature (°C)
483
+ month: Design month
484
+ hour: Hour of the day
485
+ latitude: Latitude (e.g., '24N')
486
+
487
+ Returns:
488
+ Cooling load in Watts
489
+ """
490
+ try:
491
+ latitude = self.validate_latitude(latitude)
492
+ month = self.validate_month(month)
493
+ hour = self.validate_hour(hour)
494
+ if self.debug_mode:
495
+ logger.debug(f"calculate_wall_cooling_load: latitude={latitude}, month={month}, hour={hour}, wall_group={wall.wall_group}, orientation={wall.orientation.value}")
496
+
497
+ wall_group = str(wall.wall_group).upper()
498
+ if wall_group not in self.valid_wall_groups:
499
+ numeric_map = {'1': 'A', '2': 'B', '3': 'C', '4': 'D'}
500
+ if wall_group in numeric_map:
501
+ wall_group = numeric_map[wall_group]
502
+ if self.debug_mode:
503
+ logger.info(f"Mapped wall_group {wall.wall_group} to {wall_group}")
504
+ else:
505
+ if self.debug_mode:
506
+ logger.warning(f"Invalid wall group: {wall_group}. Defaulting to 'A'")
507
+ wall_group = 'A'
508
+
509
+ try:
510
+ cltd = self.ashrae_tables.calculate_corrected_cltd_wall(
511
+ wall_group=wall_group,
512
+ orientation=wall.orientation.value,
513
+ hour=hour,
514
+ color='Dark',
515
+ month=month,
516
+ latitude=latitude,
517
+ indoor_temp=indoor_temp,
518
+ outdoor_temp=outdoor_temp
519
+ )
520
+ except Exception as e:
521
+ if self.debug_mode:
522
+ logger.error(f"calculate_corrected_cltd_wall failed for wall_group={wall_group}: {str(e)}")
523
+ logger.warning("Using default CLTD=8.0°C")
524
+ cltd = 8.0
525
+
526
+ load = wall.u_value * wall.area * cltd
527
+ if self.debug_mode:
528
+ logger.debug(f"Wall load: u_value={wall.u_value}, area={wall.area}, cltd={cltd}, load={load}")
529
+ return max(load, 0.0)
530
+
531
+ except Exception as e:
532
+ if self.debug_mode:
533
+ logger.error(f"Error in calculate_wall_cooling_load: {str(e)}")
534
+ raise Exception(f"Error in calculate_wall_cooling_load: {str(e)}")
535
+
536
+ def calculate_roof_cooling_load(
537
+ self,
538
+ roof: Roof,
539
+ outdoor_temp: float,
540
+ indoor_temp: float,
541
+ month: str,
542
+ hour: int,
543
+ latitude: str
544
+ ) -> float:
545
+ """
546
+ Calculate cooling load for a roof using CLTD method.
547
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.10.
548
+
549
+ Args:
550
+ roof: Roof component
551
+ outdoor_temp: Outdoor temperature (°C)
552
+ indoor_temp: Indoor temperature (°C)
553
+ month: Design month
554
+ hour: Hour of the day
555
+ latitude: Latitude (e.g., '24N')
556
+
557
+ Returns:
558
+ Cooling load in Watts
559
+ """
560
+ try:
561
+ latitude = self.validate_latitude(latitude)
562
+ month = self.validate_month(month)
563
+ hour = self.validate_hour(hour)
564
+ if self.debug_mode:
565
+ logger.debug(f"calculate_roof_cooling_load: latitude={latitude}, month={month}, hour={hour}, roof_group={roof.roof_group}")
566
+
567
+ roof_group = str(roof.roof_group).upper()
568
+ if roof_group not in self.valid_roof_groups:
569
+ numeric_map = {'1': 'A', '2': 'B', '3': 'C', '4': 'D', '5': 'E', '6': 'F', '7': 'G', '8': 'G'}
570
+ if roof_group in numeric_map:
571
+ roof_group = numeric_map[roof_group]
572
+ if self.debug_mode:
573
+ logger.info(f"Mapped roof_group {roof.roof_group} to {roof_group}")
574
+ else:
575
+ if self.debug_mode:
576
+ logger.warning(f"Invalid roof group: {roof_group}. Defaulting to 'A'")
577
+ roof_group = 'A'
578
+
579
+ try:
580
+ cltd = self.ashrae_tables.calculate_corrected_cltd_roof(
581
+ roof_group=roof_group,
582
+ hour=hour,
583
+ color='Dark',
584
+ month=month,
585
+ latitude=latitude,
586
+ indoor_temp=indoor_temp,
587
+ outdoor_temp=outdoor_temp
588
+ )
589
+ except Exception as e:
590
+ if self.debug_mode:
591
+ logger.error(f"calculate_corrected_cltd_roof failed for roof_group={roof_group}: {str(e)}")
592
+ logger.warning("Using default CLTD=8.0°C")
593
+ cltd = 8.0
594
+
595
+ load = roof.u_value * roof.area * cltd
596
+ if self.debug_mode:
597
+ logger.debug(f"Roof load: u_value={roof.u_value}, area={roof.area}, cltd={cltd}, load={load}")
598
+ return max(load, 0.0)
599
+
600
+ except Exception as e:
601
+ if self.debug_mode:
602
+ logger.error(f"Error in calculate_roof_cooling_load: {str(e)}")
603
+ raise Exception(f"Error in calculate_roof_cooling_load: {str(e)}")
604
+
605
+ def calculate_window_cooling_load(
606
+ self,
607
+ window: Window,
608
+ outdoor_temp: float,
609
+ indoor_temp: float,
610
+ month: str,
611
+ hour: int,
612
+ latitude: str,
613
+ shading_coefficient: float
614
+ ) -> Dict[str, float]:
615
+ """
616
+ Calculate cooling load for a window (conduction and solar).
617
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.12-18.13.
618
+
619
+ Args:
620
+ window: Window component
621
+ outdoor_temp: Outdoor temperature (°C)
622
+ indoor_temp: Indoor temperature (°C)
623
+ month: Design month
624
+ hour: Hour of the day
625
+ latitude: Latitude (e.g., '24N')
626
+ shading_coefficient: Shading coefficient
627
+
628
+ Returns:
629
+ Dictionary with conduction, solar, and total loads in Watts
630
+ """
631
+ try:
632
+ latitude = self.validate_latitude(latitude)
633
+ month = self.validate_month(month)
634
+ hour = self.validate_hour(hour)
635
+ if self.debug_mode:
636
+ logger.debug(f"calculate_window_cooling_load: latitude={latitude}, month={month}, hour={hour}, orientation={window.orientation.value}")
637
+
638
+ # Conduction load
639
+ cltd = outdoor_temp - indoor_temp
640
+ conduction_load = window.u_value * window.area * cltd
641
+
642
+ # Solar load
643
+ try:
644
+ scl = self.ashrae_tables.get_scl(
645
+ latitude=latitude,
646
+ month=month,
647
+ orientation=window.orientation.value,
648
+ hour=hour
649
+ )
650
+ except Exception as e:
651
+ if self.debug_mode:
652
+ logger.error(f"get_scl failed for latitude={latitude}, month={month}, orientation={window.orientation.value}: {str(e)}")
653
+ logger.warning("Using default SCL=100 W/m²")
654
+ scl = 100.0
655
+
656
+ solar_load = window.area * window.shgc * shading_coefficient * scl
657
+
658
+ total_load = conduction_load + solar_load
659
+ if self.debug_mode:
660
+ logger.debug(f"Window load: conduction={conduction_load}, solar={solar_load}, total={total_load}")
661
+
662
+ return {
663
+ 'conduction': max(conduction_load, 0.0),
664
+ 'solar': max(solar_load, 0.0),
665
+ 'total': max(total_load, 0.0)
666
+ }
667
+
668
+ except Exception as e:
669
+ if self.debug_mode:
670
+ logger.error(f"Error in calculate_window_cooling_load: {str(e)}")
671
+ raise Exception(f"Error in calculate_window_cooling_load: {str(e)}")
672
+
673
+ def calculate_door_cooling_load(
674
+ self,
675
+ door: Door,
676
+ outdoor_temp: float,
677
+ indoor_temp: float
678
+ ) -> float:
679
+ """
680
+ Calculate cooling load for a door.
681
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
682
+
683
+ Args:
684
+ door: Door component
685
+ outdoor_temp: Outdoor temperature (°C)
686
+ indoor_temp: Indoor temperature (°C)
687
+
688
+ Returns:
689
+ Cooling load in Watts
690
+ """
691
+ try:
692
+ if self.debug_mode:
693
+ logger.debug(f"calculate_door_cooling_load: u_value={door.u_value}, area={door.area}")
694
+
695
+ cltd = outdoor_temp - indoor_temp
696
+ load = door.u_value * door.area * cltd
697
+ if self.debug_mode:
698
+ logger.debug(f"Door load: cltd={cltd}, load={load}")
699
+ return max(load, 0.0)
700
+
701
+ except Exception as e:
702
+ if self.debug_mode:
703
+ logger.error(f"Error in calculate_door_cooling_load: {str(e)}")
704
+ raise Exception(f"Error in calculate_door_cooling_load: {str(e)}")
705
+
706
+ def calculate_people_cooling_load(
707
+ self,
708
+ num_people: int,
709
+ activity_level: str,
710
+ hour: int
711
+ ) -> Dict[str, float]:
712
+ """
713
+ Calculate cooling load from people.
714
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Table 18.4.
715
+
716
+ Args:
717
+ num_people: Number of people
718
+ activity_level: Activity level ('Seated/Resting', 'Light Work', etc.)
719
+ hour: Hour of the day
720
+
721
+ Returns:
722
+ Dictionary with sensible and latent loads in Watts
723
+ """
724
+ try:
725
+ hour = self.validate_hour(hour)
726
+ if self.debug_mode:
727
+ logger.debug(f"calculate_people_cooling_load: num_people={num_people}, activity_level={activity_level}, hour={hour}")
728
+
729
+ heat_gains = {
730
+ 'Seated/Resting': {'sensible': 70, 'latent': 45},
731
+ 'Light Work': {'sensible': 85, 'latent': 65},
732
+ 'Moderate Work': {'sensible': 100, 'latent': 100},
733
+ 'Heavy Work': {'sensible': 145, 'latent': 170}
734
+ }
735
+
736
+ gains = heat_gains.get(activity_level, heat_gains['Seated/Resting'])
737
+ if activity_level not in heat_gains:
738
+ if self.debug_mode:
739
+ logger.warning(f"Invalid activity_level: {activity_level}. Defaulting to 'Seated/Resting'")
740
+
741
+ try:
742
+ clf = self.ashrae_tables.get_clf_people(
743
+ zone_type='A',
744
+ hours_occupied='6h',
745
+ hour=hour
746
+ )
747
+ except Exception as e:
748
+ if self.debug_mode:
749
+ logger.error(f"get_clf_people failed: {str(e)}")
750
+ logger.warning("Using default CLF=0.5")
751
+ clf = 0.5
752
+
753
+ sensible_load = num_people * gains['sensible'] * clf
754
+ latent_load = num_people * gains['latent']
755
+ if self.debug_mode:
756
+ logger.debug(f"People load: sensible={sensible_load}, latent={latent_load}, clf={clf}")
757
+
758
+ return {
759
+ 'sensible': max(sensible_load, 0.0),
760
+ 'latent': max(latent_load, 0.0)
761
+ }
762
+
763
+ except Exception as e:
764
+ if self.debug_mode:
765
+ logger.error(f"Error in calculate_people_cooling_load: {str(e)}")
766
+ raise Exception(f"Error in calculate_people_cooling_load: {str(e)}")
767
+
768
+ def calculate_lights_cooling_load(
769
+ self,
770
+ power: float,
771
+ use_factor: float,
772
+ special_allowance: float,
773
+ hour: int
774
+ ) -> float:
775
+ """
776
+ Calculate cooling load from lighting.
777
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Table 18.5.
778
+
779
+ Args:
780
+ power: Total lighting power (W)
781
+ use_factor: Usage factor (0.0 to 1.0)
782
+ special_allowance: Special allowance factor
783
+ hour: Hour of the day
784
+
785
+ Returns:
786
+ Cooling load in Watts
787
+ """
788
+ try:
789
+ hour = self.validate_hour(hour)
790
+ if self.debug_mode:
791
+ logger.debug(f"calculate_lights_cooling_load: power={power}, use_factor={use_factor}, special_allowance={special_allowance}, hour={hour}")
792
+
793
+ try:
794
+ clf = self.ashrae_tables.get_clf_lights(
795
+ zone_type='A',
796
+ hours_occupied='6h',
797
+ hour=hour
798
+ )
799
+ except Exception as e:
800
+ if self.debug_mode:
801
+ logger.error(f"get_clf_lights failed: {str(e)}")
802
+ logger.warning("Using default CLF=0.8")
803
+ clf = 0.8
804
+
805
+ load = power * use_factor * special_allowance * clf
806
+ if self.debug_mode:
807
+ logger.debug(f"Lights load: clf={clf}, load={load}")
808
+ return max(load, 0.0)
809
+
810
+ except Exception as e:
811
+ if self.debug_mode:
812
+ logger.error(f"Error in calculate_lights_cooling_load: {str(e)}")
813
+ raise Exception(f"Error in calculate_lights_cooling_load: {str(e)}")
814
+
815
+ def calculate_equipment_cooling_load(
816
+ self,
817
+ power: float,
818
+ use_factor: float,
819
+ radiation_factor: float,
820
+ hour: int
821
+ ) -> Dict[str, float]:
822
+ """
823
+ Calculate cooling load from equipment.
824
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Table 18.6.
825
+
826
+ Args:
827
+ power: Total equipment power (W)
828
+ use_factor: Usage factor (0.0 to 1.0)
829
+ radiation_factor: Radiation factor (0.0 to 1.0)
830
+ hour: Hour of the day
831
+
832
+ Returns:
833
+ Dictionary with sensible and latent loads in Watts
834
+ """
835
+ try:
836
+ hour = self.validate_hour(hour)
837
+ if self.debug_mode:
838
+ logger.debug(f"calculate_equipment_cooling_load: power={power}, use_factor={use_factor}, radiation_factor={radiation_factor}, hour={hour}")
839
+
840
+ try:
841
+ clf = self.ashrae_tables.get_clf_equipment(
842
+ zone_type='A',
843
+ hours_operated='6h',
844
+ hour=hour
845
+ )
846
+ except Exception as e:
847
+ if self.debug_mode:
848
+ logger.error(f"get_clf_equipment failed: {str(e)}")
849
+ logger.warning("Using default CLF=0.7")
850
+ clf = 0.7
851
+
852
+ sensible_load = power * use_factor * radiation_factor * clf
853
+ latent_load = power * use_factor * (1 - radiation_factor)
854
+ if self.debug_mode:
855
+ logger.debug(f"Equipment load: sensible={sensible_load}, latent={latent_load}, clf={clf}")
856
+
857
+ return {
858
+ 'sensible': max(sensible_load, 0.0),
859
+ 'latent': max(latent_load, 0.0)
860
+ }
861
+
862
+ except Exception as e:
863
+ if self.debug_mode:
864
+ logger.error(f"Error in calculate_equipment_cooling_load: {str(e)}")
865
+ raise Exception(f"Error in calculate_equipment_cooling_load: {str(e)}")
866
+
867
+ def calculate_infiltration_cooling_load(
868
+ self,
869
+ flow_rate: float,
870
+ building_volume: float,
871
+ outdoor_temp: float,
872
+ outdoor_rh: float,
873
+ indoor_temp: float,
874
+ indoor_rh: float,
875
+ p_atm: float = 101325
876
+ ) -> Dict[str, float]:
877
+ """
878
+ Calculate cooling load from infiltration.
879
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.5-18.6.
880
+
881
+ Args:
882
+ flow_rate: Infiltration flow rate (m³/s)
883
+ building_volume: Building volume (m³)
884
+ outdoor_temp: Outdoor temperature (°C)
885
+ outdoor_rh: Outdoor relative humidity (%)
886
+ indoor_temp: Indoor temperature (°C)
887
+ indoor_rh: Indoor relative humidity (%)
888
+ p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
889
+
890
+ Returns:
891
+ Dictionary with sensible and latent loads in Watts
892
+ """
893
+ try:
894
+ if self.debug_mode:
895
+ logger.debug(f"calculate_infiltration_cooling_load: flow_rate={flow_rate}, building_volume={building_volume}, outdoor_temp={outdoor_temp}, indoor_temp={indoor_temp}")
896
+
897
+ self.validate_conditions(outdoor_temp, indoor_temp, outdoor_rh, indoor_rh)
898
+ if flow_rate < 0 or building_volume <= 0:
899
+ raise ValueError("Flow rate cannot be negative and building volume must be positive")
900
+
901
+ # Calculate air changes per hour (ACH)
902
+ ach = (flow_rate * 3600) / building_volume if building_volume > 0 else 0.5
903
+ if ach < 0:
904
+ if self.debug_mode:
905
+ logger.warning(f"Invalid ACH: {ach}. Defaulting to 0.5")
906
+ ach = 0.5
907
+
908
+ # Calculate humidity ratio difference
909
+ outdoor_w = self.heat_transfer.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh, p_atm)
910
+ indoor_w = self.heat_transfer.psychrometrics.humidity_ratio(indoor_temp, indoor_rh, p_atm)
911
+ delta_w = max(0, outdoor_w - indoor_w)
912
+
913
+ # Calculate sensible and latent loads using heat_transfer methods
914
+ sensible_load = self.heat_transfer.infiltration_heat_transfer(
915
+ flow_rate, outdoor_temp - indoor_temp, indoor_temp, indoor_rh, p_atm
916
+ )
917
+ latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
918
+ flow_rate, delta_w, indoor_temp, indoor_rh, p_atm
919
+ )
920
+
921
+ if self.debug_mode:
922
+ logger.debug(f"Infiltration load: sensible={sensible_load}, latent={latent_load}, ach={ach}, outdoor_w={outdoor_w}, indoor_w={indoor_w}")
923
+
924
+ return {
925
+ 'sensible': max(sensible_load, 0.0),
926
+ 'latent': max(latent_load, 0.0)
927
+ }
928
+
929
+ except Exception as e:
930
+ if self.debug_mode:
931
+ logger.error(f"Error in calculate_infiltration_cooling_load: {str(e)}")
932
+ raise Exception(f"Error in calculate_infiltration_cooling_load: {str(e)}")
933
+
934
+ def calculate_ventilation_cooling_load(
935
+ self,
936
+ flow_rate: float,
937
+ outdoor_temp: float,
938
+ outdoor_rh: float,
939
+ indoor_temp: float,
940
+ indoor_rh: float,
941
+ p_atm: float = 101325
942
+ ) -> Dict[str, float]:
943
+ """
944
+ Calculate cooling load from ventilation.
945
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.5-18.6.
946
+
947
+ Args:
948
+ flow_rate: Ventilation flow rate (m³/s)
949
+ outdoor_temp: Outdoor temperature (°C)
950
+ outdoor_rh: Outdoor relative humidity (%)
951
+ indoor_temp: Indoor temperature (°C)
952
+ indoor_rh: Indoor relative humidity (%)
953
+ p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
954
+
955
+ Returns:
956
+ Dictionary with sensible and latent loads in Watts
957
+ """
958
+ try:
959
+ if self.debug_mode:
960
+ logger.debug(f"calculate_ventilation_cooling_load: flow_rate={flow_rate}, outdoor_temp={outdoor_temp}, indoor_temp={indoor_temp}")
961
+
962
+ self.validate_conditions(outdoor_temp, indoor_temp, outdoor_rh, indoor_rh)
963
+ if flow_rate < 0:
964
+ raise ValueError("Flow rate cannot be negative")
965
+
966
+ # Calculate humidity ratio difference
967
+ outdoor_w = self.heat_transfer.psychrometrics.humidity_ratio(outdoor_temp, outdoor_rh, p_atm)
968
+ indoor_w = self.heat_transfer.psychrometrics.humidity_ratio(indoor_temp, indoor_rh, p_atm)
969
+ delta_w = max(0, outdoor_w - indoor_w)
970
+
971
+ # Calculate sensible and latent loads using heat_transfer methods
972
+ sensible_load = self.heat_transfer.infiltration_heat_transfer(
973
+ flow_rate, outdoor_temp - indoor_temp, indoor_temp, indoor_rh, p_atm
974
+ )
975
+ latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
976
+ flow_rate, delta_w, indoor_temp, indoor_rh, p_atm
977
+ )
978
+
979
+ if self.debug_mode:
980
+ logger.debug(f"Ventilation load: sensible={sensible_load}, latent={latent_load}, outdoor_w={outdoor_w}, indoor_w={indoor_w}")
981
+
982
+ return {
983
+ 'sensible': max(sensible_load, 0.0),
984
+ 'latent': max(latent_load, 0.0)
985
+ }
986
+
987
+ except Exception as e:
988
+ if self.debug_mode:
989
+ logger.error(f"Error in calculate_ventilation_cooling_load: {str(e)}")
990
+ raise Exception(f"Error in calculate_ventilation_cooling_load: {str(e)}")
991
+
992
+ # Example usage
993
+ if __name__ == "__main__":
994
+ calculator = CoolingLoadCalculator(debug_mode=True)
995
+
996
+ # Example inputs
997
+ components = {
998
+ 'walls': [Wall(id="w1", name="North Wall", area=20.0, u_value=0.5, orientation=Orientation.NORTH, wall_group='A')],
999
+ 'roofs': [Roof(id="r1", name="Main Roof", area=100.0, u_value=0.3, orientation=Orientation.HORIZONTAL, roof_group='A')],
1000
+ 'windows': [Window(id="win1", name="South Window", area=10.0, u_value=2.8, orientation=Orientation.SOUTH, shgc=0.7, shading_coefficient=0.8)],
1001
+ 'doors': [Door(id="d1", name="Main Door", area=2.0, u_value=2.0, orientation=Orientation.NORTH)]
1002
+ }
1003
+ outdoor_conditions = {
1004
+ 'temperature': 35.0,
1005
+ 'relative_humidity': 60.0,
1006
+ 'latitude': '32N',
1007
+ 'month': 'JUL'
1008
+ }
1009
+ indoor_conditions = {
1010
+ 'temperature': 24.0,
1011
+ 'relative_humidity': 50.0
1012
+ }
1013
+ internal_loads = {
1014
+ 'people': {'number': 10, 'activity_level': 'Seated/Resting'},
1015
+ 'lights': {'power': 1000.0, 'use_factor': 0.8, 'special_allowance': 1.0},
1016
+ 'equipment': {'power': 500.0, 'use_factor': 0.7, 'radiation_factor': 0.5},
1017
+ 'infiltration': {'flow_rate': 0.05},
1018
+ 'ventilation': {'flow_rate': 0.1}
1019
+ }
1020
+ building_volume = 300.0
1021
+
1022
+ # Calculate hourly loads
1023
+ hourly_loads = calculator.calculate_hourly_cooling_loads(
1024
+ components, outdoor_conditions, indoor_conditions, internal_loads, building_volume
1025
+ )
1026
+ design_loads = calculator.calculate_design_cooling_load(hourly_loads)
1027
+ summary = calculator.calculate_cooling_load_summary(design_loads)
1028
+
1029
+ logger.info(f"Design Cooling Load Summary: {summary}")
utils/heat_transfer.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Heat transfer calculation module for HVAC Load Calculator.
3
+ This module implements heat transfer calculations for conduction, infiltration, and solar effects.
4
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapters 16 and 18.
5
+ """
6
+
7
+ from typing import Dict, List, Any, Optional, Tuple
8
+ import math
9
+ import numpy as np
10
+ import logging
11
+ from dataclasses import dataclass
12
+
13
+ # Configure logging
14
+ logging.basicConfig(level=logging.INFO)
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Import utility modules
18
+ from utils.psychrometrics import Psychrometrics
19
+
20
+ # Import data modules
21
+ from data.building_components import Orientation
22
+
23
+
24
+ class SolarCalculations:
25
+ """Class for solar geometry and radiation calculations."""
26
+
27
+ def validate_angle(self, angle: float, name: str, min_val: float, max_val: float) -> None:
28
+ """
29
+ Validate angle inputs for solar calculations.
30
+
31
+ Args:
32
+ angle: Angle in degrees
33
+ name: Name of the angle
34
+ min_val: Minimum allowed value
35
+ max_val: Maximum allowed value
36
+
37
+ Raises:
38
+ ValueError: If angle is out of range
39
+ """
40
+ if not min_val <= angle <= max_val:
41
+ raise ValueError(f"{name} {angle}° must be between {min_val}° and {max_val}°")
42
+
43
+ def solar_declination(self, day_of_year: int) -> float:
44
+ """
45
+ Calculate solar declination angle.
46
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Equation 14.6.
47
+
48
+ Args:
49
+ day_of_year: Day of the year (1-365)
50
+
51
+ Returns:
52
+ Declination angle in degrees
53
+ """
54
+ if not 1 <= day_of_year <= 365:
55
+ raise ValueError("Day of year must be between 1 and 365")
56
+
57
+ declination = 23.45 * math.sin(math.radians(360 * (284 + day_of_year) / 365))
58
+ self.validate_angle(declination, "Declination angle", -23.45, 23.45)
59
+ return declination
60
+
61
+ def solar_hour_angle(self, hour: float) -> float:
62
+ """
63
+ Calculate solar hour angle.
64
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Equation 14.7.
65
+
66
+ Args:
67
+ hour: Hour of the day (0-23)
68
+
69
+ Returns:
70
+ Hour angle in degrees
71
+ """
72
+ if not 0 <= hour <= 24:
73
+ raise ValueError("Hour must be between 0 and 24")
74
+
75
+ hour_angle = (hour - 12) * 15
76
+ self.validate_angle(hour_angle, "Hour angle", -180, 180)
77
+ return hour_angle
78
+
79
+ def solar_altitude(self, latitude: float, declination: float, hour_angle: float) -> float:
80
+ """
81
+ Calculate solar altitude angle.
82
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Equation 14.8.
83
+
84
+ Args:
85
+ latitude: Latitude in degrees
86
+ declination: Declination angle in degrees
87
+ hour_angle: Hour angle in degrees
88
+
89
+ Returns:
90
+ Altitude angle in degrees
91
+ """
92
+ self.validate_angle(latitude, "Latitude", -90, 90)
93
+ self.validate_angle(declination, "Declination", -23.45, 23.45)
94
+ self.validate_angle(hour_angle, "Hour angle", -180, 180)
95
+
96
+ sin_beta = (math.sin(math.radians(latitude)) * math.sin(math.radians(declination)) +
97
+ math.cos(math.radians(latitude)) * math.cos(math.radians(declination)) *
98
+ math.cos(math.radians(hour_angle)))
99
+ beta = math.degrees(math.asin(sin_beta))
100
+ self.validate_angle(beta, "Altitude angle", 0, 90)
101
+ return beta
102
+
103
+ def solar_azimuth(self, latitude: float, declination: float, hour_angle: float, altitude: float) -> float:
104
+ """
105
+ Calculate solar azimuth angle.
106
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Equation 14.9.
107
+
108
+ Args:
109
+ latitude: Latitude in degrees
110
+ declination: Declination angle in degrees
111
+ hour_angle: Hour angle in degrees
112
+ altitude: Altitude angle in degrees
113
+
114
+ Returns:
115
+ Azimuth angle in degrees
116
+ """
117
+ self.validate_angle(latitude, "Latitude", -90, 90)
118
+ self.validate_angle(declination, "Declination", -23.45, 23.45)
119
+ self.validate_angle(hour_angle, "Hour angle", -180, 180)
120
+ self.validate_angle(altitude, "Altitude", 0, 90)
121
+
122
+ sin_phi = (math.cos(math.radians(declination)) * math.sin(math.radians(hour_angle)) /
123
+ math.cos(math.radians(altitude)))
124
+ phi = math.degrees(math.asin(sin_phi))
125
+
126
+ if hour_angle > 0:
127
+ phi = 180 - phi
128
+ elif hour_angle < 0:
129
+ phi = -180 - phi
130
+
131
+ self.validate_angle(phi, "Azimuth angle", -180, 180)
132
+ return phi
133
+
134
+
135
+ class HeatTransferCalculations:
136
+ """Class for heat transfer calculations."""
137
+
138
+ def __init__(self):
139
+ """
140
+ Initialize heat transfer calculations with psychrometrics and solar calculations.
141
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16.
142
+ """
143
+ self.psychrometrics = Psychrometrics()
144
+ self.solar = SolarCalculations()
145
+ self.debug_mode = False
146
+
147
+ def conduction_heat_transfer(self, u_value: float, area: float, delta_t: float) -> float:
148
+ """
149
+ Calculate heat transfer via conduction.
150
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
151
+
152
+ Args:
153
+ u_value: U-value of the component in W/(m²·K)
154
+ area: Area of the component in m²
155
+ delta_t: Temperature difference in °C
156
+
157
+ Returns:
158
+ Heat transfer rate in W
159
+ """
160
+ if u_value < 0 or area < 0:
161
+ raise ValueError("U-value and area must be non-negative")
162
+
163
+ q = u_value * area * delta_t
164
+ return q
165
+
166
+ def infiltration_heat_transfer(self, flow_rate: float, delta_t: float,
167
+ t_db: float, rh: float, p_atm: float = 101325) -> float:
168
+ """
169
+ Calculate sensible heat transfer due to infiltration or ventilation.
170
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.5.
171
+
172
+ Args:
173
+ flow_rate: Air flow rate in m³/s
174
+ delta_t: Temperature difference in °C
175
+ t_db: Dry-bulb temperature for air properties in °C
176
+ rh: Relative humidity in % (0-100)
177
+ p_atm: Atmospheric pressure in Pa
178
+
179
+ Returns:
180
+ Sensible heat transfer rate in W
181
+ """
182
+ if flow_rate < 0:
183
+ raise ValueError("Flow rate cannot be negative")
184
+
185
+ # Calculate air density and specific heat using psychrometrics
186
+ w = self.psychrometrics.humidity_ratio(t_db, rh, p_atm)
187
+ rho = self.psychrometrics.density(t_db, w, p_atm)
188
+ c_p = 1006 + 1860 * w # Specific heat of moist air in J/(kg·K)
189
+
190
+ q = flow_rate * rho * c_p * delta_t
191
+ return q
192
+
193
+ def infiltration_latent_heat_transfer(self, flow_rate: float, delta_w: float,
194
+ t_db: float, rh: float, p_atm: float = 101325) -> float:
195
+ """
196
+ Calculate latent heat transfer due to infiltration or ventilation.
197
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.6.
198
+
199
+ Args:
200
+ flow_rate: Air flow rate in m³/s
201
+ delta_w: Humidity ratio difference in kg/kg
202
+ t_db: Dry-bulb temperature for air properties in °C
203
+ rh: Relative humidity in % (0-100)
204
+ p_atm: Atmospheric pressure in Pa
205
+
206
+ Returns:
207
+ Latent heat transfer rate in W
208
+ """
209
+ if flow_rate < 0 or delta_w < 0:
210
+ raise ValueError("Flow rate and humidity ratio difference cannot be negative")
211
+
212
+ # Calculate air density and latent heat
213
+ w = self.psychrometrics.humidity_ratio(t_db, rh, p_atm)
214
+ rho = self.psychrometrics.density(t_db, w, p_atm)
215
+ h_fg = 2501000 + 1840 * t_db # Latent heat of vaporization in J/kg
216
+
217
+ q = flow_rate * rho * h_fg * delta_w
218
+ return q
219
+
220
+ def wind_pressure_difference(self, wind_speed: float) -> float:
221
+ """
222
+ Calculate pressure difference due to wind.
223
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.3.
224
+
225
+ Args:
226
+ wind_speed: Wind speed in m/s
227
+
228
+ Returns:
229
+ Pressure difference in Pa
230
+ """
231
+ if wind_speed < 0:
232
+ raise ValueError("Wind speed cannot be negative")
233
+
234
+ c_p = 0.6 # Wind pressure coefficient
235
+ rho_air = 1.2 # Air density at standard conditions in kg/m³
236
+ delta_p = 0.5 * c_p * rho_air * wind_speed**2
237
+ return delta_p
238
+
239
+ def stack_pressure_difference(self, height: float, t_inside: float, t_outside: float) -> float:
240
+ """
241
+ Calculate pressure difference due to stack effect.
242
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.4.
243
+
244
+ Args:
245
+ height: Height of the building in m
246
+ t_inside: Inside temperature in K
247
+ t_outside: Outside temperature in K
248
+
249
+ Returns:
250
+ Pressure difference in Pa
251
+ """
252
+ if height < 0 or t_inside <= 0 or t_outside <= 0:
253
+ raise ValueError("Height and temperatures must be positive")
254
+
255
+ g = 9.81 # Gravitational acceleration in m/s²
256
+ rho_air = 1.2 # Air density at standard conditions in kg/m³
257
+ delta_p = rho_air * g * height * (1 / t_outside - 1 / t_inside)
258
+ return delta_p
259
+
260
+ def combined_pressure_difference(self, wind_pd: float, stack_pd: float) -> float:
261
+ """
262
+ Calculate combined pressure difference from wind and stack effects.
263
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Section 16.2.
264
+
265
+ Args:
266
+ wind_pd: Wind pressure difference in Pa
267
+ stack_pd: Stack pressure difference in Pa
268
+
269
+ Returns:
270
+ Combined pressure difference in Pa
271
+ """
272
+ delta_p = math.sqrt(wind_pd**2 + stack_pd**2)
273
+ return delta_p
274
+
275
+ def crack_method_infiltration(self, crack_length: float, crack_width: float, delta_p: float) -> float:
276
+ """
277
+ Calculate infiltration flow rate using crack method.
278
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 16, Equation 16.5.
279
+
280
+ Args:
281
+ crack_length: Length of cracks in m
282
+ crack_width: Width of cracks in m
283
+ delta_p: Pressure difference across cracks in Pa
284
+
285
+ Returns:
286
+ Infiltration flow rate in m³/s
287
+ """
288
+ if crack_length < 0 or crack_width < 0 or delta_p < 0:
289
+ raise ValueError("Crack dimensions and pressure difference cannot be negative")
290
+
291
+ c_d = 0.65 # Discharge coefficient
292
+ area = crack_length * crack_width
293
+ rho_air = 1.2 # Air density at standard conditions in kg/m³
294
+ q = c_d * area * math.sqrt(2 * delta_p / rho_air)
295
+ return q
296
+
297
+
298
+ # Example usage
299
+ if __name__ == "__main__":
300
+ heat_transfer = HeatTransferCalculations()
301
+ heat_transfer.debug_mode = True
302
+
303
+ # Example conduction calculation
304
+ u_value = 0.5 # W/(m²·K)
305
+ area = 20.0 # m²
306
+ delta_t = 26.0 # °C
307
+ q_conduction = heat_transfer.conduction_heat_transfer(u_value, area, delta_t)
308
+ logger.info(f"Conduction heat transfer: {q_conduction:.2f} W")
309
+
310
+ # Example infiltration calculation
311
+ flow_rate = 0.05 # m³/s
312
+ delta_t = 26.0 # °C
313
+ t_db = 21.0 # °C
314
+ rh = 40.0 # %
315
+ p_atm = 101325 # Pa
316
+ q_infiltration = heat_transfer.infiltration_heat_transfer(flow_rate, delta_t, t_db, rh, p_atm)
317
+ logger.info(f"Infiltration sensible heat transfer: {q_infiltration:.2f} W")
318
+
319
+ # Example solar calculation
320
+ latitude = 40.0 # degrees
321
+ day_of_year = 172 # June 21
322
+ hour = 12.0 # Noon
323
+ declination = heat_transfer.solar.solar_declination(day_of_year)
324
+ hour_angle = heat_transfer.solar.solar_hour_angle(hour)
325
+ altitude = heat_transfer.solar.solar_altitude(latitude, declination, hour_angle)
326
+ azimuth = heat_transfer.solar.solar_azimuth(latitude, declination, hour_angle, altitude)
327
+ logger.info(f"Solar altitude: {altitude:.2f}°, Azimuth: {azimuth:.2f}°")
utils/heating_load.py ADDED
@@ -0,0 +1,610 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Heating load calculation module for HVAC Load Calculator.
3
+ Implements ASHRAE steady-state methods with simplified thermal lag for compatibility.
4
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
5
+ """
6
+
7
+ from typing import Dict, List, Any, Optional, Tuple
8
+ import math
9
+ import numpy as np
10
+ import logging
11
+ from enum import Enum
12
+ from dataclasses import dataclass
13
+
14
+ # Configure logging
15
+ logging.basicConfig(level=logging.INFO)
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Import utility modules
19
+ from utils.psychrometrics import Psychrometrics
20
+ from utils.heat_transfer import HeatTransferCalculations
21
+
22
+ # Import data modules
23
+ from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
24
+
25
+
26
+ class HeatingLoadCalculator:
27
+ """Class for heating load calculations based on ASHRAE steady-state methods."""
28
+
29
+ def __init__(self, debug_mode: bool = False):
30
+ """
31
+ Initialize heating load calculator with psychrometric and heat transfer calculations.
32
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
33
+
34
+ Args:
35
+ debug_mode: Enable debug logging if True
36
+ """
37
+ self.psychrometrics = Psychrometrics()
38
+ self.heat_transfer = HeatTransferCalculations()
39
+ self.safety_factor = 1.15 # 15% safety factor for design loads
40
+ self.debug_mode = debug_mode
41
+ if debug_mode:
42
+ logger.setLevel(logging.DEBUG)
43
+
44
+ def validate_inputs(self, components: Dict[str, List[Any]], outdoor_temp: float, indoor_temp: float) -> None:
45
+ """
46
+ Validate input parameters for heating load calculations.
47
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
48
+
49
+ Args:
50
+ components: Dictionary of building components
51
+ outdoor_temp: Outdoor design temperature in °C
52
+ indoor_temp: Indoor design temperature in °C
53
+
54
+ Raises:
55
+ ValueError: If inputs are invalid
56
+ """
57
+ if not components:
58
+ raise ValueError("Building components dictionary cannot be empty")
59
+ for component_type, comp_list in components.items():
60
+ if not isinstance(comp_list, list):
61
+ raise ValueError(f"Components for {component_type} must be a list")
62
+ for comp in comp_list:
63
+ if not hasattr(comp, 'area') or comp.area <= 0:
64
+ raise ValueError(f"Invalid area for {component_type}: {comp.name}")
65
+ if not hasattr(comp, 'u_value') or comp.u_value <= 0:
66
+ raise ValueError(f"Invalid U-value for {component_type}: {comp.name}")
67
+ if not -50 <= outdoor_temp <= 60 or not -50 <= indoor_temp <= 60:
68
+ raise ValueError("Temperatures must be between -50°C and 60°C")
69
+ if indoor_temp - outdoor_temp < 1:
70
+ raise ValueError("Indoor temperature must be at least 1°C above outdoor temperature for heating")
71
+
72
+ def calculate_wall_heating_load(self, wall: Wall, outdoor_temp: float, indoor_temp: float) -> float:
73
+ """
74
+ Calculate heating load for a wall, with simplified thermal lag.
75
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
76
+
77
+ Args:
78
+ wall: Wall component
79
+ outdoor_temp: Outdoor temperature in °C
80
+ indoor_temp: Indoor temperature in °C
81
+
82
+ Returns:
83
+ Heating load in W
84
+ """
85
+ delta_t = indoor_temp - outdoor_temp
86
+ if delta_t <= 1:
87
+ return 0.0 # Skip calculation for small temperature differences
88
+
89
+ # Use default lag factor (no thermal mass adjustment)
90
+ lag_factor = 1.0
91
+ adjusted_delta_t = delta_t * lag_factor
92
+
93
+ load = self.heat_transfer.conduction_heat_transfer(wall.u_value, wall.area, adjusted_delta_t)
94
+ return max(0, load)
95
+
96
+ def calculate_roof_heating_load(self, roof: Roof, outdoor_temp: float, indoor_temp: float) -> float:
97
+ """
98
+ Calculate heating load for a roof, with simplified thermal lag.
99
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
100
+
101
+ Args:
102
+ roof: Roof component
103
+ outdoor_temp: Outdoor temperature in °C
104
+ indoor_temp: Indoor temperature in °C
105
+
106
+ Returns:
107
+ Heating load in W
108
+ """
109
+ delta_t = indoor_temp - outdoor_temp
110
+ if delta_t <= 1:
111
+ return 0.0
112
+
113
+ lag_factor = 1.0
114
+ adjusted_delta_t = delta_t * lag_factor
115
+
116
+ load = self.heat_transfer.conduction_heat_transfer(roof.u_value, roof.area, adjusted_delta_t)
117
+ return max(0, load)
118
+
119
+ def calculate_floor_heating_load(self, floor: Floor, ground_temp: float, indoor_temp: float) -> float:
120
+ """
121
+ Calculate heating load for a floor, using dynamic F-factor for ground contact.
122
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.3.
123
+
124
+ Args:
125
+ floor: Floor component
126
+ ground_temp: Ground temperature in °C
127
+ indoor_temp: Indoor temperature in °C
128
+
129
+ Returns:
130
+ Heating load in W
131
+ """
132
+ delta_t = indoor_temp - ground_temp
133
+ if delta_t <= 1:
134
+ return 0.0
135
+
136
+ if floor.is_ground_contact:
137
+ # Dynamic F-factor based on insulation
138
+ f_factor = 0.3 if floor.insulated else 0.73 # W/m·K
139
+ load = f_factor * floor.perimeter_length * delta_t
140
+ else:
141
+ load = self.heat_transfer.conduction_heat_transfer(floor.u_value, floor.area, delta_t)
142
+
143
+ if self.debug_mode:
144
+ logger.debug(f"Floor {floor.name} load: {load:.2f} W, Delta T: {delta_t:.2f}°C")
145
+
146
+ return max(0, load)
147
+
148
+ def calculate_window_heating_load(self, window: Window, outdoor_temp: float, indoor_temp: float) -> float:
149
+ """
150
+ Calculate heating load for a window.
151
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
152
+
153
+ Args:
154
+ window: Window component
155
+ outdoor_temp: Outdoor temperature in °C
156
+ indoor_temp: Indoor temperature in °C
157
+
158
+ Returns:
159
+ Heating load in W
160
+ """
161
+ delta_t = indoor_temp - outdoor_temp
162
+ if delta_t <= 1:
163
+ return 0.0
164
+
165
+ load = self.heat_transfer.conduction_heat_transfer(window.u_value, window.area, delta_t)
166
+ return max(0, load)
167
+
168
+ def calculate_door_heating_load(self, door: Door, outdoor_temp: float, indoor_temp: float) -> float:
169
+ """
170
+ Calculate heating load for a door.
171
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equation 18.1.
172
+
173
+ Args:
174
+ door: Door component
175
+ outdoor_temp: Outdoor temperature in °C
176
+ indoor_temp: Indoor temperature in °C
177
+
178
+ Returns:
179
+ Heating load in W
180
+ """
181
+ delta_t = indoor_temp - outdoor_temp
182
+ if delta_t <= 1:
183
+ return 0.0
184
+
185
+ load = self.heat_transfer.conduction_heat_transfer(door.u_value, door.area, delta_t)
186
+ return max(0, load)
187
+
188
+ def calculate_infiltration_heating_load(self, indoor_conditions: Dict[str, float],
189
+ outdoor_conditions: Dict[str, float],
190
+ infiltration: Dict[str, float],
191
+ building_height: float,
192
+ p_atm: float = 101325) -> Tuple[float, float]:
193
+ """
194
+ Calculate sensible and latent heating loads due to infiltration.
195
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.5-18.6.
196
+
197
+ Args:
198
+ indoor_conditions: Indoor conditions (temperature, relative_humidity)
199
+ outdoor_conditions: Outdoor conditions (design_temperature, design_relative_humidity, wind_speed)
200
+ infiltration: Infiltration parameters (flow_rate, crack_length, height)
201
+ building_height: Building height in m
202
+ p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
203
+
204
+ Returns:
205
+ Tuple of sensible and latent loads in W
206
+ """
207
+ delta_t = indoor_conditions['temperature'] - outdoor_conditions['design_temperature']
208
+ if delta_t <= 1:
209
+ return 0.0, 0.0
210
+
211
+ # Calculate pressure differences
212
+ wind_pd = self.heat_transfer.wind_pressure_difference(outdoor_conditions['wind_speed'])
213
+ stack_pd = self.heat_transfer.stack_pressure_difference(
214
+ building_height,
215
+ indoor_conditions['temperature'] + 273.15,
216
+ outdoor_conditions['design_temperature'] + 273.15
217
+ )
218
+ total_pd = self.heat_transfer.combined_pressure_difference(wind_pd, stack_pd)
219
+
220
+ # Calculate infiltration flow rate
221
+ crack_length = infiltration.get('crack_length', 20.0)
222
+ flow_rate = self.heat_transfer.crack_method_infiltration(crack_length, 0.0002, total_pd)
223
+
224
+ # Calculate humidity ratio difference
225
+ w_indoor = self.psychrometrics.humidity_ratio(
226
+ indoor_conditions['temperature'],
227
+ indoor_conditions['relative_humidity'],
228
+ p_atm
229
+ )
230
+ w_outdoor = self.psychrometrics.humidity_ratio(
231
+ outdoor_conditions['design_temperature'],
232
+ outdoor_conditions['design_relative_humidity'],
233
+ p_atm
234
+ )
235
+ delta_w = max(0, w_indoor - w_outdoor)
236
+
237
+ # Calculate sensible and latent loads using indoor conditions for air properties
238
+ sensible_load = self.heat_transfer.infiltration_heat_transfer(
239
+ flow_rate, delta_t,
240
+ indoor_conditions['temperature'],
241
+ indoor_conditions['relative_humidity'],
242
+ p_atm
243
+ )
244
+ latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
245
+ flow_rate, delta_w,
246
+ indoor_conditions['temperature'],
247
+ indoor_conditions['relative_humidity'],
248
+ p_atm
249
+ )
250
+
251
+ if self.debug_mode:
252
+ logger.debug(f"Infiltration flow rate: {flow_rate:.6f} m³/s, Sensible load: {sensible_load:.2f} W, Latent load: {latent_load:.2f} W")
253
+
254
+ return max(0, sensible_load), max(0, latent_load)
255
+
256
+ def calculate_ventilation_heating_load(self, ventilation: Dict[str, float],
257
+ indoor_conditions: Dict[str, float],
258
+ outdoor_conditions: Dict[str, float],
259
+ p_atm: float = 101325) -> Tuple[float, float]:
260
+ """
261
+ Calculate sensible and latent heating loads due to ventilation.
262
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Equations 18.5-18.6.
263
+
264
+ Args:
265
+ ventilation: Ventilation parameters (flow_rate)
266
+ indoor_conditions: Indoor conditions (temperature, relative_humidity)
267
+ outdoor_conditions: Outdoor conditions (design_temperature, design_relative_humidity)
268
+ p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
269
+
270
+ Returns:
271
+ Tuple of sensible and latent loads in W
272
+ """
273
+ delta_t = indoor_conditions['temperature'] - outdoor_conditions['design_temperature']
274
+ if delta_t <= 1:
275
+ return 0.0, 0.0
276
+
277
+ flow_rate = ventilation['flow_rate']
278
+
279
+ w_indoor = self.psychrometrics.humidity_ratio(
280
+ indoor_conditions['temperature'],
281
+ indoor_conditions['relative_humidity'],
282
+ p_atm
283
+ )
284
+ w_outdoor = self.psychrometrics.humidity_ratio(
285
+ outdoor_conditions['design_temperature'],
286
+ outdoor_conditions['design_relative_humidity'],
287
+ p_atm
288
+ )
289
+ delta_w = max(0, w_indoor - w_outdoor)
290
+
291
+ # Calculate sensible and latent loads using indoor conditions for air properties
292
+ sensible_load = self.heat_transfer.infiltration_heat_transfer(
293
+ flow_rate, delta_t,
294
+ indoor_conditions['temperature'],
295
+ indoor_conditions['relative_humidity'],
296
+ p_atm
297
+ )
298
+ latent_load = self.heat_transfer.infiltration_latent_heat_transfer(
299
+ flow_rate, delta_w,
300
+ indoor_conditions['temperature'],
301
+ indoor_conditions['relative_humidity'],
302
+ p_atm
303
+ )
304
+
305
+ if self.debug_mode:
306
+ logger.debug(f"Ventilation flow rate: {flow_rate:.6f} m³/s, Sensible load: {sensible_load:.2f} W, Latent load: {latent_load:.2f} W")
307
+
308
+ return max(0, sensible_load), max(0, latent_load)
309
+
310
+ def calculate_internal_gains(self, internal_loads: Dict[str, Any]) -> float:
311
+ """
312
+ Calculate internal heat gains from people, lighting, and equipment.
313
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.4.
314
+
315
+ Args:
316
+ internal_loads: Internal loads (people, lights, equipment)
317
+
318
+ Returns:
319
+ Total internal gains in W
320
+ """
321
+ total_gains = 0.0
322
+
323
+ # People gains
324
+ people = internal_loads.get('people', {})
325
+ if people.get('number', 0) > 0:
326
+ sensible_gain = people.get('sensible_gain', 70.0)
327
+ total_gains += people['number'] * sensible_gain
328
+
329
+ # Lighting gains
330
+ lights = internal_loads.get('lights', {})
331
+ if lights.get('power', 0) > 0:
332
+ total_gains += lights['power'] * lights.get('use_factor', 0.8)
333
+
334
+ # Equipment gains
335
+ equipment = internal_loads.get('equipment', {})
336
+ if equipment.get('power', 0) > 0:
337
+ total_gains += equipment['power'] * equipment.get('use_factor', 0.7)
338
+
339
+ return max(0, total_gains)
340
+
341
+ def calculate_design_heating_load(self, building_components: Dict[str, List[Any]],
342
+ outdoor_conditions: Dict[str, float],
343
+ indoor_conditions: Dict[str, float],
344
+ internal_loads: Dict[str, Any],
345
+ p_atm: float = 101325) -> Dict[str, float]:
346
+ """
347
+ Calculate design heating loads for all components.
348
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
349
+
350
+ Args:
351
+ building_components: Dictionary of building components
352
+ outdoor_conditions: Outdoor conditions (design_temperature, design_relative_humidity, ground_temperature, wind_speed)
353
+ indoor_conditions: Indoor conditions (temperature, relative_humidity)
354
+ internal_loads: Internal loads (people, lights, equipment, infiltration, ventilation)
355
+ p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
356
+
357
+ Returns:
358
+ Dictionary of design loads in W
359
+ """
360
+ try:
361
+ self.validate_inputs(building_components, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
362
+ except ValueError as e:
363
+ raise ValueError(f"Input validation failed: {str(e)}")
364
+
365
+ loads = {
366
+ 'walls': 0.0,
367
+ 'roofs': 0.0,
368
+ 'floors': 0.0,
369
+ 'windows': 0.0,
370
+ 'doors': 0.0,
371
+ 'infiltration_sensible': 0.0,
372
+ 'infiltration_latent': 0.0,
373
+ 'ventilation_sensible': 0.0,
374
+ 'ventilation_latent': 0.0,
375
+ 'internal_gains': 0.0
376
+ }
377
+
378
+ # Calculate envelope loads
379
+ for wall in building_components.get('walls', []):
380
+ loads['walls'] += self.calculate_wall_heating_load(wall, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
381
+
382
+ for roof in building_components.get('roofs', []):
383
+ loads['roofs'] += self.calculate_roof_heating_load(roof, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
384
+
385
+ for floor in building_components.get('floors', []):
386
+ loads['floors'] += self.calculate_floor_heating_load(floor, outdoor_conditions['ground_temperature'], indoor_conditions['temperature'])
387
+
388
+ for window in building_components.get('windows', []):
389
+ loads['windows'] += self.calculate_window_heating_load(window, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
390
+
391
+ for door in building_components.get('doors', []):
392
+ loads['doors'] += self.calculate_door_heating_load(door, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
393
+
394
+ # Calculate infiltration and ventilation loads
395
+ building_height = internal_loads.get('infiltration', {}).get('height', 3.0)
396
+ infiltration_sensible, infiltration_latent = self.calculate_infiltration_heating_load(
397
+ indoor_conditions, outdoor_conditions, internal_loads.get('infiltration', {}), building_height, p_atm
398
+ )
399
+ loads['infiltration_sensible'] = infiltration_sensible
400
+ loads['infiltration_latent'] = infiltration_latent
401
+
402
+ ventilation_sensible, ventilation_latent = self.calculate_ventilation_heating_load(
403
+ internal_loads.get('ventilation', {}), indoor_conditions, outdoor_conditions, p_atm
404
+ )
405
+ loads['ventilation_sensible'] = ventilation_sensible
406
+ loads['ventilation_latent'] = ventilation_latent
407
+
408
+ # Calculate internal gains (negative for heating)
409
+ loads['internal_gains'] = -self.calculate_internal_gains(internal_loads)
410
+
411
+ return loads
412
+
413
+ def calculate_heating_load_summary(self, design_loads: Dict[str, float]) -> Dict[str, float]:
414
+ """
415
+ Summarize heating loads with safety factor.
416
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
417
+
418
+ Args:
419
+ design_loads: Dictionary of design loads in W
420
+
421
+ Returns:
422
+ Summary dictionary with total, subtotal, and safety factor
423
+ """
424
+ subtotal = sum(
425
+ load for key, load in design_loads.items()
426
+ if key not in ['internal_gains'] and load > 0
427
+ )
428
+ internal_gains = design_loads.get('internal_gains', 0)
429
+
430
+ total = max(0, subtotal + internal_gains) * self.safety_factor
431
+
432
+ return {
433
+ 'subtotal': subtotal,
434
+ 'internal_gains': internal_gains,
435
+ 'total': total,
436
+ 'safety_factor': self.safety_factor
437
+ }
438
+
439
+ def calculate_heating_degree_days(self, base_temp: float, monthly_temps: Dict[str, float]) -> float:
440
+ """
441
+ Calculate heating degree days for a year.
442
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.3.
443
+
444
+ Args:
445
+ base_temp: Base temperature for HDD calculation in °C
446
+ monthly_temps: Dictionary of monthly average temperatures
447
+
448
+ Returns:
449
+ Total heating degree days
450
+ """
451
+ hdd = 0.0
452
+ days_per_month = {
453
+ 'Jan': 31, 'Feb': 28, 'Mar': 31, 'Apr': 30, 'May': 31, 'Jun': 30,
454
+ 'Jul': 31, 'Aug': 31, 'Sep': 30, 'Oct': 31, 'Nov': 30, 'Dec': 31
455
+ }
456
+
457
+ for month, temp in monthly_temps.items():
458
+ if temp < base_temp:
459
+ hdd += (base_temp - temp) * days_per_month[month]
460
+
461
+ return hdd
462
+
463
+ def calculate_annual_heating_energy(self, design_loads: Dict[str, float],
464
+ monthly_temps: Dict[str, float],
465
+ indoor_temp: float,
466
+ operating_hours: str) -> float:
467
+ """
468
+ Calculate annual heating energy consumption.
469
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 14, Section 14.3.
470
+
471
+ Args:
472
+ design_loads: Dictionary of design loads in W
473
+ monthly_temps: Dictionary of monthly average temperatures
474
+ indoor_temp: Indoor design temperature in °C
475
+ operating_hours: Operating hours (e.g., '8:00-18:00')
476
+
477
+ Returns:
478
+ Annual heating energy in kWh
479
+ """
480
+ base_temp = indoor_temp
481
+ hdd = self.calculate_heating_degree_days(base_temp, monthly_temps)
482
+
483
+ # Parse operating hours
484
+ start_hour, end_hour = map(lambda x: int(x.split(':')[0]), operating_hours.split('-'))
485
+ daily_hours = end_hour - start_hour
486
+
487
+ # Calculate design condition degree days
488
+ design_temp = min(monthly_temps.values())
489
+ design_delta_t = indoor_temp - design_temp
490
+ if design_delta_t <= 1:
491
+ return 0.0
492
+
493
+ total_load = self.calculate_heating_load_summary(design_loads)['total']
494
+
495
+ # Scale load by HDD and operating hours
496
+ annual_energy = (total_load / design_delta_t) * hdd * (daily_hours / 24) / 1000 # kWh
497
+
498
+ return max(0, annual_energy)
499
+
500
+ def calculate_monthly_heating_loads(self, building_components: Dict[str, List[Any]],
501
+ outdoor_conditions: Dict[str, float],
502
+ indoor_conditions: Dict[str, float],
503
+ internal_loads: Dict[str, Any],
504
+ monthly_temps: Dict[str, float],
505
+ p_atm: float = 101325) -> Dict[str, float]:
506
+ """
507
+ Calculate monthly heating loads.
508
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 18, Section 18.4.
509
+
510
+ Args:
511
+ building_components: Dictionary of building components
512
+ outdoor_conditions: Outdoor conditions
513
+ indoor_conditions: Indoor conditions
514
+ internal_loads: Internal loads
515
+ monthly_temps: Dictionary of monthly average temperatures
516
+ p_atm: Atmospheric pressure in Pa (default: 101325 Pa)
517
+
518
+ Returns:
519
+ Dictionary of monthly heating loads in kW
520
+ """
521
+ monthly_loads = {}
522
+ days_per_month = {
523
+ 'Jan': 31, 'Feb': 28, 'Mar': 31, 'Apr': 30, 'May': 31, 'Jun': 30,
524
+ 'Jul': 31, 'Aug': 31, 'Sep': 30, 'Oct': 31, 'Nov': 30, 'Dec': 31
525
+ }
526
+
527
+ for month, temp in monthly_temps.items():
528
+ modified_outdoor = outdoor_conditions.copy()
529
+ modified_outdoor['design_temperature'] = temp
530
+ modified_outdoor['ground_temperature'] = temp
531
+
532
+ try:
533
+ design_loads = self.calculate_design_heating_load(
534
+ building_components, modified_outdoor, indoor_conditions, internal_loads, p_atm
535
+ )
536
+ summary = self.calculate_heating_load_summary(design_loads)
537
+ monthly_loads[month] = summary['total'] / 1000 # kW
538
+ except ValueError:
539
+ monthly_loads[month] = 0.0 # Skip invalid months
540
+
541
+ return monthly_loads
542
+
543
+ # Example usage
544
+ if __name__ == "__main__":
545
+ calculator = HeatingLoadCalculator(debug_mode=True)
546
+
547
+ # Example building components
548
+ components = {
549
+ 'walls': [Wall(id="w1", name="North Wall", area=20.0, u_value=0.5, orientation=Orientation.NORTH)],
550
+ 'roofs': [Roof(id="r1", name="Main Roof", area=100.0, u_value=0.3, orientation=Orientation.HORIZONTAL)],
551
+ 'floors': [Floor(id="f1", name="Ground Floor", area=100.0, u_value=0.4, perimeter_length=40.0,
552
+ is_ground_contact=True, insulated=True, ground_temperature_c=10.0)],
553
+ 'windows': [Window(id="win1", name="South Window", area=10.0, u_value=2.8, orientation=Orientation.SOUTH,
554
+ shgc=0.7, shading_coefficient=0.8)],
555
+ 'doors': [Door(id="d1", name="Main Door", area=2.0, u_value=2.0, orientation=Orientation.NORTH)]
556
+ }
557
+
558
+ outdoor_conditions = {
559
+ 'design_temperature': -5.0,
560
+ 'design_relative_humidity': 80.0,
561
+ 'ground_temperature': 10.0,
562
+ 'wind_speed': 4.0
563
+ }
564
+ indoor_conditions = {
565
+ 'temperature': 21.0,
566
+ 'relative_humidity': 40.0
567
+ }
568
+ internal_loads = {
569
+ 'people': {'number': 10, 'sensible_gain': 70.0, 'operating_hours': '8:00-18:00'},
570
+ 'lights': {'power': 1000.0, 'use_factor': 0.8, 'hours_operation': '8h'},
571
+ 'equipment': {'power': 500.0, 'use_factor': 0.7, 'hours_operation': '8h'},
572
+ 'infiltration': {'flow_rate': 0.05, 'height': 3.0, 'crack_length': 20.0},
573
+ 'ventilation': {'flow_rate': 0.1},
574
+ 'operating_hours': '8:00-18:00'
575
+ }
576
+ monthly_temps = {
577
+ 'Jan': -5.0, 'Feb': -3.0, 'Mar': 2.0, 'Apr': 8.0, 'May': 14.0, 'Jun': 19.0,
578
+ 'Jul': 22.0, 'Aug': 21.0, 'Sep': 16.0, 'Oct': 10.0, 'Nov': 4.0, 'Dec': -2.0
579
+ }
580
+
581
+ # Calculate design loads
582
+ design_loads = calculator.calculate_design_heating_load(components, outdoor_conditions, indoor_conditions, internal_loads)
583
+ summary = calculator.calculate_heating_load_summary(design_loads)
584
+
585
+ # Log results
586
+ logger.info(f"Total Heating Load: {summary['total']:.2f} W")
587
+ logger.info(f"Wall Load: {design_loads['walls']:.2f} W")
588
+ logger.info(f"Roof Load: {design_loads['roofs']:.2f} W")
589
+ logger.info(f"Floor Load: {design_loads['floors']:.2f} W")
590
+ logger.info(f"Window Load: {design_loads['windows']:.2f} W")
591
+ logger.info(f"Door Load: {design_loads['doors']:.2f} W")
592
+ logger.info(f"Infiltration Sensible Load: {design_loads['infiltration_sensible']:.2f} W")
593
+ logger.info(f"Infiltration Latent Load: {design_loads['infiltration_latent']:.2f} W")
594
+ logger.info(f"Ventilation Sensible Load: {design_loads['ventilation_sensible']:.2f} W")
595
+ logger.info(f"Ventilation Latent Load: {design_loads['ventilation_latent']:.2f} W")
596
+ logger.info(f"Internal Gains: {design_loads['internal_gains']:.2f} W")
597
+
598
+ # Calculate annual energy
599
+ annual_energy = calculator.calculate_annual_heating_energy(
600
+ design_loads, monthly_temps, indoor_conditions['temperature'], internal_loads['operating_hours']
601
+ )
602
+ logger.info(f"Annual Heating Energy: {annual_energy:.2f} kWh")
603
+
604
+ # Calculate monthly loads
605
+ monthly_loads = calculator.calculate_monthly_heating_loads(
606
+ components, outdoor_conditions, indoor_conditions, internal_loads, monthly_temps
607
+ )
608
+ logger.info("Monthly Heating Loads (kW):")
609
+ for month, load in monthly_loads.items():
610
+ logger.info(f"{month}: {load:.2f} kW")
utils/psychrometric_visualization.py ADDED
@@ -0,0 +1,635 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Psychrometric visualization module for HVAC Load Calculator.
3
+ This module provides visualization tools for psychrometric processes.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ import plotly.graph_objects as go
10
+ import plotly.express as px
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ import math
13
+
14
+ # Import psychrometrics module
15
+ from utils.psychrometrics import Psychrometrics
16
+
17
+
18
+ class PsychrometricVisualization:
19
+ """Class for psychrometric visualization."""
20
+
21
+ def __init__(self):
22
+ """Initialize psychrometric visualization."""
23
+ self.psychrometrics = Psychrometrics()
24
+
25
+ # Define temperature and humidity ratio ranges for chart
26
+ self.temp_min = -10
27
+ self.temp_max = 50
28
+ self.w_min = 0
29
+ self.w_max = 0.030
30
+
31
+ # Define standard atmospheric pressure
32
+ self.pressure = 101325 # Pa
33
+
34
+ def create_psychrometric_chart(self, points: Optional[List[Dict[str, Any]]] = None,
35
+ processes: Optional[List[Dict[str, Any]]] = None,
36
+ comfort_zone: Optional[Dict[str, Any]] = None) -> go.Figure:
37
+ """
38
+ Create an interactive psychrometric chart.
39
+
40
+ Args:
41
+ points: List of points to plot on the chart
42
+ processes: List of processes to plot on the chart
43
+ comfort_zone: Dictionary with comfort zone parameters
44
+
45
+ Returns:
46
+ Plotly figure with psychrometric chart
47
+ """
48
+ # Create figure
49
+ fig = go.Figure()
50
+
51
+ # Generate temperature and humidity ratio grids
52
+ temp_range = np.linspace(self.temp_min, self.temp_max, 100)
53
+ w_range = np.linspace(self.w_min, self.w_max, 100)
54
+
55
+ # Generate saturation curve
56
+ sat_temps = np.linspace(self.temp_min, self.temp_max, 100)
57
+ sat_w = [self.psychrometrics.humidity_ratio(t, 100, self.pressure) for t in sat_temps]
58
+
59
+ # Plot saturation curve
60
+ fig.add_trace(go.Scatter(
61
+ x=sat_temps,
62
+ y=sat_w,
63
+ mode="lines",
64
+ line=dict(color="blue", width=2),
65
+ name="Saturation Curve"
66
+ ))
67
+
68
+ # Generate constant RH curves
69
+ rh_values = [10, 20, 30, 40, 50, 60, 70, 80, 90]
70
+
71
+ for rh in rh_values:
72
+ rh_temps = np.linspace(self.temp_min, self.temp_max, 50)
73
+ rh_w = [self.psychrometrics.humidity_ratio(t, rh, self.pressure) for t in rh_temps]
74
+
75
+ # Filter out values above saturation
76
+ valid_points = [(t, w) for t, w in zip(rh_temps, rh_w) if w <= self.psychrometrics.humidity_ratio(t, 100, self.pressure)]
77
+
78
+ if valid_points:
79
+ valid_temps, valid_w = zip(*valid_points)
80
+
81
+ fig.add_trace(go.Scatter(
82
+ x=valid_temps,
83
+ y=valid_w,
84
+ mode="lines",
85
+ line=dict(color="rgba(0, 0, 255, 0.3)", width=1, dash="dot"),
86
+ name=f"{rh}% RH",
87
+ hoverinfo="name"
88
+ ))
89
+
90
+ # Generate constant wet-bulb temperature lines
91
+ wb_values = np.arange(0, 35, 5)
92
+
93
+ for wb in wb_values:
94
+ wb_temps = np.linspace(wb, self.temp_max, 50)
95
+ wb_points = []
96
+
97
+ for t in wb_temps:
98
+ # Binary search to find humidity ratio for this wet-bulb temperature
99
+ w_low = 0
100
+ w_high = self.psychrometrics.humidity_ratio(t, 100, self.pressure)
101
+
102
+ for _ in range(10): # 10 iterations should be enough for good precision
103
+ w_mid = (w_low + w_high) / 2
104
+ rh = self.psychrometrics.relative_humidity(t, w_mid, self.pressure)
105
+ t_wb_calc = self.psychrometrics.wet_bulb_temperature(t, rh, self.pressure)
106
+
107
+ if abs(t_wb_calc - wb) < 0.1:
108
+ wb_points.append((t, w_mid))
109
+ break
110
+ elif t_wb_calc < wb:
111
+ w_low = w_mid
112
+ else:
113
+ w_high = w_mid
114
+
115
+ if wb_points:
116
+ wb_temps, wb_w = zip(*wb_points)
117
+
118
+ fig.add_trace(go.Scatter(
119
+ x=wb_temps,
120
+ y=wb_w,
121
+ mode="lines",
122
+ line=dict(color="rgba(0, 128, 0, 0.3)", width=1, dash="dash"),
123
+ name=f"{wb}°C WB",
124
+ hoverinfo="name"
125
+ ))
126
+
127
+ # Generate constant enthalpy lines
128
+ h_values = np.arange(0, 100, 10) * 1000 # kJ/kg to J/kg
129
+
130
+ for h in h_values:
131
+ h_temps = np.linspace(self.temp_min, self.temp_max, 50)
132
+ h_points = []
133
+
134
+ for t in h_temps:
135
+ # Calculate humidity ratio for this enthalpy
136
+ w = self.psychrometrics.find_humidity_ratio_for_enthalpy(t, h)
137
+
138
+ if 0 <= w <= self.psychrometrics.humidity_ratio(t, 100, self.pressure):
139
+ h_points.append((t, w))
140
+
141
+ if h_points:
142
+ h_temps, h_w = zip(*h_points)
143
+
144
+ fig.add_trace(go.Scatter(
145
+ x=h_temps,
146
+ y=h_w,
147
+ mode="lines",
148
+ line=dict(color="rgba(255, 0, 0, 0.3)", width=1, dash="dashdot"),
149
+ name=f"{h/1000:.0f} kJ/kg",
150
+ hoverinfo="name"
151
+ ))
152
+
153
+ # Generate constant specific volume lines
154
+ v_values = [0.8, 0.85, 0.9, 0.95, 1.0, 1.05]
155
+
156
+ for v in v_values:
157
+ v_temps = np.linspace(self.temp_min, self.temp_max, 50)
158
+ v_points = []
159
+
160
+ for t in h_temps:
161
+ # Binary search to find humidity ratio for this specific volume
162
+ w_low = 0
163
+ w_high = self.psychrometrics.humidity_ratio(t, 100, self.pressure)
164
+
165
+ for _ in range(10): # 10 iterations should be enough for good precision
166
+ w_mid = (w_low + w_high) / 2
167
+ v_calc = self.psychrometrics.specific_volume(t, w_mid, self.pressure)
168
+
169
+ if abs(v_calc - v) < 0.01:
170
+ v_points.append((t, w_mid))
171
+ break
172
+ elif v_calc < v:
173
+ w_low = w_mid
174
+ else:
175
+ w_high = w_mid
176
+
177
+ if v_points:
178
+ v_temps, v_w = zip(*v_points)
179
+
180
+ fig.add_trace(go.Scatter(
181
+ x=v_temps,
182
+ y=v_w,
183
+ mode="lines",
184
+ line=dict(color="rgba(128, 0, 128, 0.3)", width=1, dash="longdash"),
185
+ name=f"{v:.2f} m³/kg",
186
+ hoverinfo="name"
187
+ ))
188
+
189
+ # Add comfort zone if specified
190
+ if comfort_zone:
191
+ temp_min = comfort_zone.get("temp_min", 20)
192
+ temp_max = comfort_zone.get("temp_max", 26)
193
+ rh_min = comfort_zone.get("rh_min", 30)
194
+ rh_max = comfort_zone.get("rh_max", 60)
195
+
196
+ # Calculate humidity ratios at corners
197
+ w_bottom_left = self.psychrometrics.humidity_ratio(temp_min, rh_min, self.pressure)
198
+ w_bottom_right = self.psychrometrics.humidity_ratio(temp_max, rh_min, self.pressure)
199
+ w_top_right = self.psychrometrics.humidity_ratio(temp_max, rh_max, self.pressure)
200
+ w_top_left = self.psychrometrics.humidity_ratio(temp_min, rh_max, self.pressure)
201
+
202
+ # Add comfort zone as a filled polygon
203
+ fig.add_trace(go.Scatter(
204
+ x=[temp_min, temp_max, temp_max, temp_min, temp_min],
205
+ y=[w_bottom_left, w_bottom_right, w_top_right, w_top_left, w_bottom_left],
206
+ fill="toself",
207
+ fillcolor="rgba(0, 255, 0, 0.2)",
208
+ line=dict(color="green", width=2),
209
+ name="Comfort Zone"
210
+ ))
211
+
212
+ # Add points if specified
213
+ if points:
214
+ for i, point in enumerate(points):
215
+ temp = point.get("temp", 0)
216
+ rh = point.get("rh", 0)
217
+ w = point.get("w", self.psychrometrics.humidity_ratio(temp, rh, self.pressure))
218
+ name = point.get("name", f"Point {i+1}")
219
+ color = point.get("color", "blue")
220
+
221
+ fig.add_trace(go.Scatter(
222
+ x=[temp],
223
+ y=[w],
224
+ mode="markers+text",
225
+ marker=dict(size=10, color=color),
226
+ text=[name],
227
+ textposition="top center",
228
+ name=name,
229
+ hovertemplate=(
230
+ f"<b>{name}</b><br>" +
231
+ "Temperature: %{x:.1f}°C<br>" +
232
+ "Humidity Ratio: %{y:.5f} kg/kg<br>" +
233
+ f"Relative Humidity: {rh:.1f}%<br>"
234
+ )
235
+ ))
236
+
237
+ # Add processes if specified
238
+ if processes:
239
+ for i, process in enumerate(processes):
240
+ start_point = process.get("start", {})
241
+ end_point = process.get("end", {})
242
+
243
+ start_temp = start_point.get("temp", 0)
244
+ start_rh = start_point.get("rh", 0)
245
+ start_w = start_point.get("w", self.psychrometrics.humidity_ratio(start_temp, start_rh, self.pressure))
246
+
247
+ end_temp = end_point.get("temp", 0)
248
+ end_rh = end_point.get("rh", 0)
249
+ end_w = end_point.get("w", self.psychrometrics.humidity_ratio(end_temp, end_rh, self.pressure))
250
+
251
+ name = process.get("name", f"Process {i+1}")
252
+ color = process.get("color", "red")
253
+
254
+ fig.add_trace(go.Scatter(
255
+ x=[start_temp, end_temp],
256
+ y=[start_w, end_w],
257
+ mode="lines+markers",
258
+ line=dict(color=color, width=2, dash="solid"),
259
+ marker=dict(size=8, color=color),
260
+ name=name
261
+ ))
262
+
263
+ # Add arrow to indicate direction
264
+ fig.add_annotation(
265
+ x=end_temp,
266
+ y=end_w,
267
+ ax=start_temp,
268
+ ay=start_w,
269
+ xref="x",
270
+ yref="y",
271
+ axref="x",
272
+ ayref="y",
273
+ showarrow=True,
274
+ arrowhead=2,
275
+ arrowsize=1,
276
+ arrowwidth=2,
277
+ arrowcolor=color
278
+ )
279
+
280
+ # Update layout
281
+ fig.update_layout(
282
+ title="Psychrometric Chart",
283
+ xaxis_title="Dry-Bulb Temperature (°C)",
284
+ yaxis_title="Humidity Ratio (kg/kg)",
285
+ xaxis=dict(
286
+ range=[self.temp_min, self.temp_max],
287
+ gridcolor="rgba(0, 0, 0, 0.1)",
288
+ showgrid=True
289
+ ),
290
+ yaxis=dict(
291
+ range=[self.w_min, self.w_max],
292
+ gridcolor="rgba(0, 0, 0, 0.1)",
293
+ showgrid=True
294
+ ),
295
+ height=700,
296
+ margin=dict(l=50, r=50, b=50, t=50),
297
+ legend=dict(
298
+ orientation="h",
299
+ yanchor="bottom",
300
+ y=1.02,
301
+ xanchor="right",
302
+ x=1
303
+ ),
304
+ hovermode="closest"
305
+ )
306
+
307
+ return fig
308
+
309
+ def create_process_visualization(self, process: Dict[str, Any]) -> go.Figure:
310
+ """
311
+ Create a visualization of a psychrometric process.
312
+
313
+ Args:
314
+ process: Dictionary with process parameters
315
+
316
+ Returns:
317
+ Plotly figure with process visualization
318
+ """
319
+ # Extract process parameters
320
+ start_point = process.get("start", {})
321
+ end_point = process.get("end", {})
322
+
323
+ start_temp = start_point.get("temp", 0)
324
+ start_rh = start_point.get("rh", 0)
325
+
326
+ end_temp = end_point.get("temp", 0)
327
+ end_rh = end_point.get("rh", 0)
328
+
329
+ # Calculate psychrometric properties
330
+ start_props = self.psychrometrics.moist_air_properties(start_temp, start_rh, self.pressure)
331
+ end_props = self.psychrometrics.moist_air_properties(end_temp, end_rh, self.pressure)
332
+
333
+ # Calculate process changes
334
+ delta_t = end_temp - start_temp
335
+ delta_w = end_props["humidity_ratio"] - start_props["humidity_ratio"]
336
+ delta_h = end_props["enthalpy"] - start_props["enthalpy"]
337
+
338
+ # Determine process type
339
+ process_type = "Unknown"
340
+ if abs(delta_w) < 0.0001: # Sensible heating/cooling
341
+ if delta_t > 0:
342
+ process_type = "Sensible Heating"
343
+ else:
344
+ process_type = "Sensible Cooling"
345
+ elif abs(delta_t) < 0.1: # Humidification/Dehumidification
346
+ if delta_w > 0:
347
+ process_type = "Humidification"
348
+ else:
349
+ process_type = "Dehumidification"
350
+ elif delta_t > 0 and delta_w > 0:
351
+ process_type = "Heating and Humidification"
352
+ elif delta_t < 0 and delta_w < 0:
353
+ process_type = "Cooling and Dehumidification"
354
+ elif delta_t > 0 and delta_w < 0:
355
+ process_type = "Heating and Dehumidification"
356
+ elif delta_t < 0 and delta_w > 0:
357
+ process_type = "Cooling and Humidification"
358
+
359
+ # Create figure
360
+ fig = go.Figure()
361
+
362
+ # Add process to psychrometric chart
363
+ chart_fig = self.create_psychrometric_chart(
364
+ points=[
365
+ {"temp": start_temp, "rh": start_rh, "name": "Start", "color": "blue"},
366
+ {"temp": end_temp, "rh": end_rh, "name": "End", "color": "red"}
367
+ ],
368
+ processes=[
369
+ {"start": {"temp": start_temp, "rh": start_rh},
370
+ "end": {"temp": end_temp, "rh": end_rh},
371
+ "name": process_type,
372
+ "color": "green"}
373
+ ]
374
+ )
375
+
376
+ # Create process diagram
377
+ # Create data for process parameters
378
+ params = [
379
+ "Dry-Bulb Temperature (°C)",
380
+ "Relative Humidity (%)",
381
+ "Humidity Ratio (g/kg)",
382
+ "Enthalpy (kJ/kg)",
383
+ "Wet-Bulb Temperature (°C)",
384
+ "Dew Point Temperature (°C)",
385
+ "Specific Volume (m³/kg)"
386
+ ]
387
+
388
+ start_values = [
389
+ start_props["dry_bulb_temperature"],
390
+ start_props["relative_humidity"],
391
+ start_props["humidity_ratio"] * 1000, # Convert to g/kg
392
+ start_props["enthalpy"] / 1000, # Convert to kJ/kg
393
+ start_props["wet_bulb_temperature"],
394
+ start_props["dew_point_temperature"],
395
+ start_props["specific_volume"]
396
+ ]
397
+
398
+ end_values = [
399
+ end_props["dry_bulb_temperature"],
400
+ end_props["relative_humidity"],
401
+ end_props["humidity_ratio"] * 1000, # Convert to g/kg
402
+ end_props["enthalpy"] / 1000, # Convert to kJ/kg
403
+ end_props["wet_bulb_temperature"],
404
+ end_props["dew_point_temperature"],
405
+ end_props["specific_volume"]
406
+ ]
407
+
408
+ delta_values = [end - start for start, end in zip(start_values, end_values)]
409
+
410
+ # Create table
411
+ table_fig = go.Figure(data=[go.Table(
412
+ header=dict(
413
+ values=["Parameter", "Start", "End", "Change"],
414
+ fill_color="paleturquoise",
415
+ align="left",
416
+ font=dict(size=12)
417
+ ),
418
+ cells=dict(
419
+ values=[
420
+ params,
421
+ [f"{val:.2f}" for val in start_values],
422
+ [f"{val:.2f}" for val in end_values],
423
+ [f"{val:.2f}" for val in delta_values]
424
+ ],
425
+ fill_color="lavender",
426
+ align="left",
427
+ font=dict(size=11)
428
+ )
429
+ )])
430
+
431
+ table_fig.update_layout(
432
+ title=f"Process Parameters: {process_type}",
433
+ height=300,
434
+ margin=dict(l=0, r=0, b=0, t=30)
435
+ )
436
+
437
+ return chart_fig, table_fig
438
+
439
+ def display_psychrometric_visualization(self) -> None:
440
+ """
441
+ Display psychrometric visualization in Streamlit.
442
+ """
443
+ st.header("Psychrometric Visualization")
444
+
445
+ # Create tabs for different visualizations
446
+ tab1, tab2, tab3 = st.tabs([
447
+ "Interactive Psychrometric Chart",
448
+ "Process Visualization",
449
+ "Comfort Zone Analysis"
450
+ ])
451
+
452
+ with tab1:
453
+ st.subheader("Interactive Psychrometric Chart")
454
+
455
+ # Add controls for points
456
+ st.write("Add points to the chart:")
457
+
458
+ col1, col2, col3 = st.columns(3)
459
+
460
+ with col1:
461
+ point1_temp = st.number_input("Point 1 Temperature (°C)", -10.0, 50.0, 20.0, key="point1_temp")
462
+ point1_rh = st.number_input("Point 1 RH (%)", 0.0, 100.0, 50.0, key="point1_rh")
463
+
464
+ with col2:
465
+ point2_temp = st.number_input("Point 2 Temperature (°C)", -10.0, 50.0, 30.0, key="point2_temp")
466
+ point2_rh = st.number_input("Point 2 RH (%)", 0.0, 100.0, 40.0, key="point2_rh")
467
+
468
+ with col3:
469
+ show_process = st.checkbox("Show Process Line", True, key="show_process")
470
+ process_name = st.text_input("Process Name", "Cooling Process", key="process_name")
471
+
472
+ # Create points
473
+ points = [
474
+ {"temp": point1_temp, "rh": point1_rh, "name": "Point 1", "color": "blue"},
475
+ {"temp": point2_temp, "rh": point2_rh, "name": "Point 2", "color": "red"}
476
+ ]
477
+
478
+ # Create process if enabled
479
+ processes = []
480
+ if show_process:
481
+ processes.append({
482
+ "start": {"temp": point1_temp, "rh": point1_rh},
483
+ "end": {"temp": point2_temp, "rh": point2_rh},
484
+ "name": process_name,
485
+ "color": "green"
486
+ })
487
+
488
+ # Create and display chart
489
+ fig = self.create_psychrometric_chart(points=points, processes=processes)
490
+ st.plotly_chart(fig, use_container_width=True)
491
+
492
+ # Display point properties
493
+ col1, col2 = st.columns(2)
494
+
495
+ with col1:
496
+ st.subheader("Point 1 Properties")
497
+ props1 = self.psychrometrics.moist_air_properties(point1_temp, point1_rh, self.pressure)
498
+ st.write(f"Dry-Bulb Temperature: {props1['dry_bulb_temperature']:.2f} °C")
499
+ st.write(f"Relative Humidity: {props1['relative_humidity']:.2f} %")
500
+ st.write(f"Humidity Ratio: {props1['humidity_ratio']*1000:.2f} g/kg")
501
+ st.write(f"Enthalpy: {props1['enthalpy']/1000:.2f} kJ/kg")
502
+ st.write(f"Wet-Bulb Temperature: {props1['wet_bulb_temperature']:.2f} °C")
503
+ st.write(f"Dew Point Temperature: {props1['dew_point_temperature']:.2f} °C")
504
+
505
+ with col2:
506
+ st.subheader("Point 2 Properties")
507
+ props2 = self.psychrometrics.moist_air_properties(point2_temp, point2_rh, self.pressure)
508
+ st.write(f"Dry-Bulb Temperature: {props2['dry_bulb_temperature']:.2f} °C")
509
+ st.write(f"Relative Humidity: {props2['relative_humidity']:.2f} %")
510
+ st.write(f"Humidity Ratio: {props2['humidity_ratio']*1000:.2f} g/kg")
511
+ st.write(f"Enthalpy: {props2['enthalpy']/1000:.2f} kJ/kg")
512
+ st.write(f"Wet-Bulb Temperature: {props2['wet_bulb_temperature']:.2f} °C")
513
+ st.write(f"Dew Point Temperature: {props2['dew_point_temperature']:.2f} °C")
514
+
515
+ with tab2:
516
+ st.subheader("Process Visualization")
517
+
518
+ # Add controls for process
519
+ st.write("Define a psychrometric process:")
520
+
521
+ col1, col2 = st.columns(2)
522
+
523
+ with col1:
524
+ st.write("Starting Point")
525
+ start_temp = st.number_input("Temperature (°C)", -10.0, 50.0, 24.0, key="start_temp")
526
+ start_rh = st.number_input("RH (%)", 0.0, 100.0, 50.0, key="start_rh")
527
+
528
+ with col2:
529
+ st.write("Ending Point")
530
+ end_temp = st.number_input("Temperature (°C)", -10.0, 50.0, 14.0, key="end_temp")
531
+ end_rh = st.number_input("RH (%)", 0.0, 100.0, 90.0, key="end_rh")
532
+
533
+ # Create process
534
+ process = {
535
+ "start": {"temp": start_temp, "rh": start_rh},
536
+ "end": {"temp": end_temp, "rh": end_rh}
537
+ }
538
+
539
+ # Create and display process visualization
540
+ chart_fig, table_fig = self.create_process_visualization(process)
541
+
542
+ st.plotly_chart(chart_fig, use_container_width=True)
543
+ st.plotly_chart(table_fig, use_container_width=True)
544
+
545
+ # Calculate process energy requirements
546
+ start_props = self.psychrometrics.moist_air_properties(start_temp, start_rh, self.pressure)
547
+ end_props = self.psychrometrics.moist_air_properties(end_temp, end_rh, self.pressure)
548
+
549
+ delta_h = end_props["enthalpy"] - start_props["enthalpy"] # J/kg
550
+
551
+ st.subheader("Energy Calculations")
552
+
553
+ air_flow = st.number_input("Air Flow Rate (m³/s)", 0.1, 100.0, 1.0, key="air_flow")
554
+
555
+ # Calculate mass flow rate
556
+ density = start_props["density"] # kg/m³
557
+ mass_flow = air_flow * density # kg/s
558
+
559
+ # Calculate energy rate
560
+ energy_rate = mass_flow * delta_h # W
561
+
562
+ st.write(f"Air Density: {density:.2f} kg/m³")
563
+ st.write(f"Mass Flow Rate: {mass_flow:.2f} kg/s")
564
+ st.write(f"Enthalpy Change: {delta_h/1000:.2f} kJ/kg")
565
+ st.write(f"Energy Rate: {energy_rate/1000:.2f} kW")
566
+
567
+ with tab3:
568
+ st.subheader("Comfort Zone Analysis")
569
+
570
+ # Add controls for comfort zone
571
+ st.write("Define comfort zone parameters:")
572
+
573
+ col1, col2 = st.columns(2)
574
+
575
+ with col1:
576
+ temp_min = st.number_input("Minimum Temperature (°C)", 10.0, 30.0, 20.0, key="temp_min")
577
+ temp_max = st.number_input("Maximum Temperature (°C)", 10.0, 30.0, 26.0, key="temp_max")
578
+
579
+ with col2:
580
+ rh_min = st.number_input("Minimum RH (%)", 0.0, 100.0, 30.0, key="rh_min")
581
+ rh_max = st.number_input("Maximum RH (%)", 0.0, 100.0, 60.0, key="rh_max")
582
+
583
+ # Create comfort zone
584
+ comfort_zone = {
585
+ "temp_min": temp_min,
586
+ "temp_max": temp_max,
587
+ "rh_min": rh_min,
588
+ "rh_max": rh_max
589
+ }
590
+
591
+ # Add point to check if it's in comfort zone
592
+ st.write("Check if a point is within the comfort zone:")
593
+
594
+ col1, col2 = st.columns(2)
595
+
596
+ with col1:
597
+ check_temp = st.number_input("Temperature (°C)", -10.0, 50.0, 22.0, key="check_temp")
598
+ check_rh = st.number_input("RH (%)", 0.0, 100.0, 45.0, key="check_rh")
599
+
600
+ # Check if point is in comfort zone
601
+ in_comfort_zone = (
602
+ temp_min <= check_temp <= temp_max and
603
+ rh_min <= check_rh <= rh_max
604
+ )
605
+
606
+ with col2:
607
+ if in_comfort_zone:
608
+ st.success("✅ Point is within the comfort zone")
609
+ else:
610
+ st.error("❌ Point is outside the comfort zone")
611
+
612
+ # Calculate properties
613
+ check_props = self.psychrometrics.moist_air_properties(check_temp, check_rh, self.pressure)
614
+ st.write(f"Humidity Ratio: {check_props['humidity_ratio']*1000:.2f} g/kg")
615
+ st.write(f"Enthalpy: {check_props['enthalpy']/1000:.2f} kJ/kg")
616
+ st.write(f"Wet-Bulb Temperature: {check_props['wet_bulb_temperature']:.2f} °C")
617
+
618
+ # Create and display chart with comfort zone
619
+ fig = self.create_psychrometric_chart(
620
+ points=[{"temp": check_temp, "rh": check_rh, "name": "Test Point", "color": "purple"}],
621
+ comfort_zone=comfort_zone
622
+ )
623
+
624
+ st.plotly_chart(fig, use_container_width=True)
625
+
626
+
627
+ # Create a singleton instance
628
+ psychrometric_visualization = PsychrometricVisualization()
629
+
630
+ # Example usage
631
+ if __name__ == "__main__":
632
+ import streamlit as st
633
+
634
+ # Display psychrometric visualization
635
+ psychrometric_visualization.display_psychrometric_visualization()
utils/psychrometrics.py ADDED
@@ -0,0 +1,581 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Psychrometric module for HVAC Load Calculator.
3
+ This module implements psychrometric calculations for air properties.
4
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
5
+ """
6
+
7
+ from typing import Dict, List, Any, Optional, Tuple
8
+ import math
9
+ import numpy as np
10
+
11
+ # Constants
12
+ ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure in Pa
13
+ WATER_MOLECULAR_WEIGHT = 18.01534 # kg/kmol
14
+ DRY_AIR_MOLECULAR_WEIGHT = 28.9645 # kg/kmol
15
+ UNIVERSAL_GAS_CONSTANT = 8314.462618 # J/(kmol·K)
16
+ GAS_CONSTANT_DRY_AIR = UNIVERSAL_GAS_CONSTANT / DRY_AIR_MOLECULAR_WEIGHT # J/(kg·K)
17
+ GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K)
18
+
19
+
20
+ class Psychrometrics:
21
+ """Class for psychrometric calculations."""
22
+
23
+ @staticmethod
24
+ def validate_inputs(t_db: float, rh: Optional[float] = None, p_atm: Optional[float] = None) -> None:
25
+ """
26
+ Validate input parameters for psychrometric calculations.
27
+
28
+ Args:
29
+ t_db: Dry-bulb temperature in °C
30
+ rh: Relative humidity in % (0-100), optional
31
+ p_atm: Atmospheric pressure in Pa, optional
32
+
33
+ Raises:
34
+ ValueError: If inputs are invalid
35
+ """
36
+ if not -50 <= t_db <= 60:
37
+ raise ValueError(f"Temperature {t_db}°C must be between -50°C and 60°C")
38
+ if rh is not None and not 0 <= rh <= 100:
39
+ raise ValueError(f"Relative humidity {rh}% must be between 0 and 100%")
40
+ if p_atm is not None and p_atm <= 0:
41
+ raise ValueError(f"Atmospheric pressure {p_atm} Pa must be positive")
42
+
43
+ @staticmethod
44
+ def saturation_pressure(t_db: float) -> float:
45
+ """
46
+ Calculate saturation pressure of water vapor.
47
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5 and 6.
48
+
49
+ Args:
50
+ t_db: Dry-bulb temperature in °C
51
+
52
+ Returns:
53
+ Saturation pressure in Pa
54
+ """
55
+ Psychrometrics.validate_inputs(t_db)
56
+
57
+ # Convert temperature to Kelvin
58
+ t_k = t_db + 273.15
59
+
60
+ # ASHRAE Fundamentals 2017 Chapter 1, Equation 5 & 6
61
+ if t_db >= 0:
62
+ # Equation 5 for temperatures above freezing
63
+ c1 = -5.8002206e3
64
+ c2 = 1.3914993
65
+ c3 = -4.8640239e-2
66
+ c4 = 4.1764768e-5
67
+ c5 = -1.4452093e-8
68
+ c6 = 6.5459673
69
+ else:
70
+ # Equation 6 for temperatures below freezing
71
+ c1 = -5.6745359e3
72
+ c2 = 6.3925247
73
+ c3 = -9.6778430e-3
74
+ c4 = 6.2215701e-7
75
+ c5 = 2.0747825e-9
76
+ c6 = -9.4840240e-13
77
+ c7 = 4.1635019
78
+
79
+ # Calculate natural log of saturation pressure in Pa
80
+ if t_db >= 0:
81
+ ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * math.log(t_k)
82
+ else:
83
+ ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * t_k**4 + c7 * math.log(t_k)
84
+
85
+ # Convert from natural log to actual pressure in Pa
86
+ p_ws = math.exp(ln_p_ws)
87
+
88
+ return p_ws
89
+
90
+ @staticmethod
91
+ def humidity_ratio(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
92
+ """
93
+ Calculate humidity ratio (mass of water vapor per unit mass of dry air).
94
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20.
95
+
96
+ Args:
97
+ t_db: Dry-bulb temperature in °C
98
+ rh: Relative humidity (0-100)
99
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
100
+
101
+ Returns:
102
+ Humidity ratio in kg water vapor / kg dry air
103
+ """
104
+ Psychrometrics.validate_inputs(t_db, rh, p_atm)
105
+
106
+ # Convert relative humidity to decimal
107
+ rh_decimal = rh / 100.0
108
+
109
+ # Calculate saturation pressure
110
+ p_ws = Psychrometrics.saturation_pressure(t_db)
111
+
112
+ # Calculate partial pressure of water vapor
113
+ p_w = rh_decimal * p_ws
114
+
115
+ if p_w >= p_atm:
116
+ raise ValueError("Partial pressure of water vapor exceeds atmospheric pressure")
117
+
118
+ # Calculate humidity ratio
119
+ w = 0.621945 * p_w / (p_atm - p_w)
120
+
121
+ return w
122
+
123
+ @staticmethod
124
+ def relative_humidity(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
125
+ """
126
+ Calculate relative humidity from humidity ratio.
127
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20 (rearranged).
128
+
129
+ Args:
130
+ t_db: Dry-bulb temperature in °C
131
+ w: Humidity ratio in kg water vapor / kg dry air
132
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
133
+
134
+ Returns:
135
+ Relative humidity (0-100)
136
+ """
137
+ Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
138
+ if w < 0:
139
+ raise ValueError("Humidity ratio cannot be negative")
140
+
141
+ # Calculate saturation pressure
142
+ p_ws = Psychrometrics.saturation_pressure(t_db)
143
+
144
+ # Calculate partial pressure of water vapor
145
+ p_w = p_atm * w / (0.621945 + w)
146
+
147
+ # Calculate relative humidity
148
+ rh = 100.0 * p_w / p_ws
149
+
150
+ return rh
151
+
152
+ @staticmethod
153
+ def wet_bulb_temperature(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
154
+ """
155
+ Calculate wet-bulb temperature using iterative method.
156
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 35.
157
+
158
+ Args:
159
+ t_db: Dry-bulb temperature in °C
160
+ rh: Relative humidity (0-100)
161
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
162
+
163
+ Returns:
164
+ Wet-bulb temperature in °C
165
+ """
166
+ Psychrometrics.validate_inputs(t_db, rh, p_atm)
167
+
168
+ # Calculate humidity ratio at given conditions
169
+ w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
170
+
171
+ # Initial guess for wet-bulb temperature
172
+ t_wb = t_db
173
+
174
+ # Iterative solution
175
+ max_iterations = 100
176
+ tolerance = 0.001 # °C
177
+
178
+ for i in range(max_iterations):
179
+ # Validate wet-bulb temperature
180
+ Psychrometrics.validate_inputs(t_wb)
181
+
182
+ # Calculate saturation pressure at wet-bulb temperature
183
+ p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
184
+
185
+ # Calculate saturation humidity ratio at wet-bulb temperature
186
+ w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
187
+
188
+ # Calculate humidity ratio from wet-bulb temperature
189
+ h_fg = 2501000 + 1840 * t_wb # Latent heat of vaporization at t_wb in J/kg
190
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
191
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
192
+
193
+ w_calc = ((h_fg - c_pw * (t_db - t_wb)) * w_s_wb - c_pa * (t_db - t_wb)) / (h_fg + c_pw * t_db - c_pw * t_wb)
194
+
195
+ # Check convergence
196
+ if abs(w - w_calc) < tolerance:
197
+ break
198
+
199
+ # Adjust wet-bulb temperature
200
+ if w_calc > w:
201
+ t_wb -= 0.1
202
+ else:
203
+ t_wb += 0.1
204
+
205
+ return t_wb
206
+
207
+ @staticmethod
208
+ def dew_point_temperature(t_db: float, rh: float) -> float:
209
+ """
210
+ Calculate dew point temperature.
211
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 39 and 40.
212
+
213
+ Args:
214
+ t_db: Dry-bulb temperature in °C
215
+ rh: Relative humidity (0-100)
216
+
217
+ Returns:
218
+ Dew point temperature in °C
219
+ """
220
+ Psychrometrics.validate_inputs(t_db, rh)
221
+
222
+ # Convert relative humidity to decimal
223
+ rh_decimal = rh / 100.0
224
+
225
+ # Calculate saturation pressure
226
+ p_ws = Psychrometrics.saturation_pressure(t_db)
227
+
228
+ # Calculate partial pressure of water vapor
229
+ p_w = rh_decimal * p_ws
230
+
231
+ # Calculate dew point temperature
232
+ alpha = math.log(p_w / 1000.0) # Convert to kPa for the formula
233
+
234
+ if t_db >= 0:
235
+ # For temperatures above freezing
236
+ c14 = 6.54
237
+ c15 = 14.526
238
+ c16 = 0.7389
239
+ c17 = 0.09486
240
+ c18 = 0.4569
241
+
242
+ t_dp = c14 + c15 * alpha + c16 * alpha**2 + c17 * alpha**3 + c18 * p_w**(0.1984)
243
+ else:
244
+ # For temperatures below freezing
245
+ c14 = 6.09
246
+ c15 = 12.608
247
+ c16 = 0.4959
248
+
249
+ t_dp = c14 + c15 * alpha + c16 * alpha**2
250
+
251
+ return t_dp
252
+
253
+ @staticmethod
254
+ def enthalpy(t_db: float, w: float) -> float:
255
+ """
256
+ Calculate specific enthalpy of moist air.
257
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30.
258
+
259
+ Args:
260
+ t_db: Dry-bulb temperature in °C
261
+ w: Humidity ratio in kg water vapor / kg dry air
262
+
263
+ Returns:
264
+ Specific enthalpy in J/kg dry air
265
+ """
266
+ Psychrometrics.validate_inputs(t_db)
267
+ if w < 0:
268
+ raise ValueError("Humidity ratio cannot be negative")
269
+
270
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
271
+ h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
272
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
273
+
274
+ h = c_pa * t_db + w * (h_fg + c_pw * t_db)
275
+
276
+ return h
277
+
278
+ @staticmethod
279
+ def specific_volume(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
280
+ """
281
+ Calculate specific volume of moist air.
282
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 28.
283
+
284
+ Args:
285
+ t_db: Dry-bulb temperature in °C
286
+ w: Humidity ratio in kg water vapor / kg dry air
287
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
288
+
289
+ Returns:
290
+ Specific volume in m³/kg dry air
291
+ """
292
+ Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
293
+ if w < 0:
294
+ raise ValueError("Humidity ratio cannot be negative")
295
+
296
+ # Convert temperature to Kelvin
297
+ t_k = t_db + 273.15
298
+
299
+ r_da = GAS_CONSTANT_DRY_AIR # Gas constant for dry air in J/(kg·K)
300
+
301
+ v = r_da * t_k * (1 + 1.607858 * w) / p_atm
302
+
303
+ return v
304
+
305
+ @staticmethod
306
+ def density(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
307
+ """
308
+ Calculate density of moist air.
309
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, derived from Equation 28.
310
+
311
+ Args:
312
+ t_db: Dry-bulb temperature in °C
313
+ w: Humidity ratio in kg water vapor / kg dry air
314
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
315
+
316
+ Returns:
317
+ Density in kg/m³
318
+ """
319
+ Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
320
+ if w < 0:
321
+ raise ValueError("Humidity ratio cannot be negative")
322
+
323
+ # Calculate specific volume
324
+ v = Psychrometrics.specific_volume(t_db, w, p_atm)
325
+
326
+ # Density is the reciprocal of specific volume
327
+ rho = (1 + w) / v
328
+
329
+ return rho
330
+
331
+ @staticmethod
332
+ def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
333
+ """
334
+ Calculate all psychrometric properties of moist air.
335
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
336
+
337
+ Args:
338
+ t_db: Dry-bulb temperature in °C
339
+ rh: Relative humidity (0-100)
340
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
341
+
342
+ Returns:
343
+ Dictionary with all psychrometric properties
344
+ """
345
+ Psychrometrics.validate_inputs(t_db, rh, p_atm)
346
+
347
+ # Calculate humidity ratio
348
+ w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
349
+
350
+ # Calculate wet-bulb temperature
351
+ t_wb = Psychrometrics.wet_bulb_temperature(t_db, rh, p_atm)
352
+
353
+ # Calculate dew point temperature
354
+ t_dp = Psychrometrics.dew_point_temperature(t_db, rh)
355
+
356
+ # Calculate enthalpy
357
+ h = Psychrometrics.enthalpy(t_db, w)
358
+
359
+ # Calculate specific volume
360
+ v = Psychrometrics.specific_volume(t_db, w, p_atm)
361
+
362
+ # Calculate density
363
+ rho = Psychrometrics.density(t_db, w, p_atm)
364
+
365
+ # Calculate saturation pressure
366
+ p_ws = Psychrometrics.saturation_pressure(t_db)
367
+
368
+ # Calculate partial pressure of water vapor
369
+ p_w = rh / 100.0 * p_ws
370
+
371
+ # Return all properties
372
+ return {
373
+ "dry_bulb_temperature": t_db,
374
+ "wet_bulb_temperature": t_wb,
375
+ "dew_point_temperature": t_dp,
376
+ "relative_humidity": rh,
377
+ "humidity_ratio": w,
378
+ "enthalpy": h,
379
+ "specific_volume": v,
380
+ "density": rho,
381
+ "saturation_pressure": p_ws,
382
+ "partial_pressure": p_w,
383
+ "atmospheric_pressure": p_atm
384
+ }
385
+
386
+ @staticmethod
387
+ def find_humidity_ratio_for_enthalpy(t_db: float, h: float) -> float:
388
+ """
389
+ Find humidity ratio for a given dry-bulb temperature and enthalpy.
390
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
391
+
392
+ Args:
393
+ t_db: Dry-bulb temperature in °C
394
+ h: Specific enthalpy in J/kg dry air
395
+
396
+ Returns:
397
+ Humidity ratio in kg water vapor / kg dry air
398
+ """
399
+ Psychrometrics.validate_inputs(t_db)
400
+ if h < 0:
401
+ raise ValueError("Enthalpy cannot be negative")
402
+
403
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
404
+ h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
405
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
406
+
407
+ w = (h - c_pa * t_db) / (h_fg + c_pw * t_db)
408
+
409
+ return max(0, w)
410
+
411
+ @staticmethod
412
+ def find_temperature_for_enthalpy(w: float, h: float) -> float:
413
+ """
414
+ Find dry-bulb temperature for a given humidity ratio and enthalpy.
415
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
416
+
417
+ Args:
418
+ w: Humidity ratio in kg water vapor / kg dry air
419
+ h: Specific enthalpy in J/kg dry air
420
+
421
+ Returns:
422
+ Dry-bulb temperature in °C
423
+ """
424
+ if w < 0:
425
+ raise ValueError("Humidity ratio cannot be negative")
426
+ if h < 0:
427
+ raise ValueError("Enthalpy cannot be negative")
428
+
429
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
430
+ h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
431
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
432
+
433
+ t_db = (h - w * h_fg) / (c_pa + w * c_pw)
434
+
435
+ Psychrometrics.validate_inputs(t_db)
436
+ return t_db
437
+
438
+ @staticmethod
439
+ def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
440
+ """
441
+ Calculate sensible heat ratio.
442
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.5.
443
+
444
+ Args:
445
+ q_sensible: Sensible heat load in W
446
+ q_total: Total heat load in W
447
+
448
+ Returns:
449
+ Sensible heat ratio (0-1)
450
+ """
451
+ if q_total == 0:
452
+ return 1.0
453
+ if q_sensible < 0 or q_total < 0:
454
+ raise ValueError("Heat loads cannot be negative")
455
+
456
+ return q_sensible / q_total
457
+
458
+ @staticmethod
459
+ def air_flow_rate_for_load(q_sensible: float, t_supply: float, t_return: float,
460
+ rh_return: float = 50.0, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
461
+ """
462
+ Calculate required air flow rate for a given sensible load.
463
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.6.
464
+
465
+ Args:
466
+ q_sensible: Sensible heat load in W
467
+ t_supply: Supply air temperature in °C
468
+ t_return: Return air temperature in °C
469
+ rh_return: Return air relative humidity in % (default: 50%)
470
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
471
+
472
+ Returns:
473
+ Dictionary with air flow rate in different units
474
+ """
475
+ Psychrometrics.validate_inputs(t_return, rh_return, p_atm)
476
+ Psychrometrics.validate_inputs(t_supply)
477
+
478
+ # Calculate return air properties
479
+ w_return = Psychrometrics.humidity_ratio(t_return, rh_return, p_atm)
480
+ rho_return = Psychrometrics.density(t_return, w_return, p_atm)
481
+
482
+ # Calculate specific heat of moist air
483
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
484
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
485
+ c_p_moist = c_pa + w_return * c_pw
486
+
487
+ # Calculate mass flow rate
488
+ delta_t = t_return - t_supply
489
+ if delta_t == 0:
490
+ raise ValueError("Supply and return temperatures cannot be equal")
491
+
492
+ m_dot = q_sensible / (c_p_moist * delta_t)
493
+
494
+ # Calculate volumetric flow rate
495
+ v_dot = m_dot / rho_return
496
+
497
+ # Convert to different units
498
+ v_dot_m3_s = v_dot
499
+ v_dot_m3_h = v_dot * 3600
500
+ v_dot_cfm = v_dot * 2118.88
501
+ v_dot_l_s = v_dot * 1000
502
+
503
+ return {
504
+ "mass_flow_rate_kg_s": m_dot,
505
+ "volumetric_flow_rate_m3_s": v_dot_m3_s,
506
+ "volumetric_flow_rate_m3_h": v_dot_m3_h,
507
+ "volumetric_flow_rate_cfm": v_dot_cfm,
508
+ "volumetric_flow_rate_l_s": v_dot_l_s
509
+ }
510
+
511
+ @staticmethod
512
+ def mixing_air_properties(m1: float, t_db1: float, rh1: float,
513
+ m2: float, t_db2: float, rh2: float,
514
+ p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
515
+ """
516
+ Calculate properties of mixed airstreams.
517
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.7.
518
+
519
+ Args:
520
+ m1: Mass flow rate of airstream 1 in kg/s
521
+ t_db1: Dry-bulb temperature of airstream 1 in °C
522
+ rh1: Relative humidity of airstream 1 in %
523
+ m2: Mass flow rate of airstream 2 in kg/s
524
+ t_db2: Dry-bulb temperature of airstream 2 in °C
525
+ rh2: Relative humidity of airstream 2 in %
526
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
527
+
528
+ Returns:
529
+ Dictionary with mixed air properties
530
+ """
531
+ Psychrometrics.validate_inputs(t_db1, rh1, p_atm)
532
+ Psychrometrics.validate_inputs(t_db2, rh2, p_atm)
533
+ if m1 < 0 or m2 < 0:
534
+ raise ValueError("Mass flow rates cannot be negative")
535
+
536
+ # Calculate humidity ratios
537
+ w1 = Psychrometrics.humidity_ratio(t_db1, rh1, p_atm)
538
+ w2 = Psychrometrics.humidity_ratio(t_db2, rh2, p_atm)
539
+
540
+ # Calculate enthalpies
541
+ h1 = Psychrometrics.enthalpy(t_db1, w1)
542
+ h2 = Psychrometrics.enthalpy(t_db2, w2)
543
+
544
+ # Calculate mixed air properties
545
+ m_total = m1 + m2
546
+
547
+ if m_total == 0:
548
+ raise ValueError("Total mass flow rate cannot be zero")
549
+
550
+ w_mix = (m1 * w1 + m2 * w2) / m_total
551
+ h_mix = (m1 * h1 + m2 * h2) / m_total
552
+
553
+ # Find dry-bulb temperature for the mixed air
554
+ t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
555
+
556
+ # Calculate relative humidity for the mixed air
557
+ rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
558
+
559
+ # Return mixed air properties
560
+ return Psychrometrics.moist_air_properties(t_db_mix, rh_mix, p_atm)
561
+
562
+
563
+ # Create a singleton instance
564
+ psychrometrics = Psychrometrics()
565
+
566
+ # Example usage
567
+ if __name__ == "__main__":
568
+ # Calculate properties of air at 25°C and 50% RH
569
+ properties = psychrometrics.moist_air_properties(25, 50)
570
+
571
+ print("Air Properties at 25°C and 50% RH:")
572
+ print(f"Dry-bulb temperature: {properties['dry_bulb_temperature']:.2f} °C")
573
+ print(f"Wet-bulb temperature: {properties['wet_bulb_temperature']:.2f} °C")
574
+ print(f"Dew point temperature: {properties['dew_point_temperature']:.2f} °C")
575
+ print(f"Relative humidity: {properties['relative_humidity']:.2f} %")
576
+ print(f"Humidity ratio: {properties['humidity_ratio']:.6f} kg/kg")
577
+ print(f"Enthalpy: {properties['enthalpy']/1000:.2f} kJ/kg")
578
+ print(f"Specific volume: {properties['specific_volume']:.4f} m³/kg")
579
+ print(f"Density: {properties['density']:.4f} kg/m³")
580
+ print(f"Saturation pressure: {properties['saturation_pressure']/1000:.2f} kPa")
581
+ print(f"Partial pressure: {properties['partial_pressure']/1000:.2f} kPa")
utils/scenario_comparison.py ADDED
@@ -0,0 +1,675 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scenario comparison visualization module for HVAC Load Calculator.
3
+ This module provides visualization tools for comparing different scenarios.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ import plotly.graph_objects as go
10
+ import plotly.express as px
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ import math
13
+
14
+ # Import calculation modules
15
+ from utils.cooling_load import CoolingLoadCalculator
16
+ from utils.heating_load import HeatingLoadCalculator
17
+
18
+
19
+ class ScenarioComparisonVisualization:
20
+ """Class for scenario comparison visualization."""
21
+
22
+ @staticmethod
23
+ def create_scenario_summary_table(scenarios: Dict[str, Dict[str, Any]]) -> pd.DataFrame:
24
+ """
25
+ Create a summary table of different scenarios.
26
+
27
+ Args:
28
+ scenarios: Dictionary with scenario data
29
+
30
+ Returns:
31
+ DataFrame with scenario summary
32
+ """
33
+ # Initialize data
34
+ data = []
35
+
36
+ # Process scenarios
37
+ for scenario_name, scenario_data in scenarios.items():
38
+ # Extract cooling and heating loads
39
+ cooling_loads = scenario_data.get("cooling_loads", {})
40
+ heating_loads = scenario_data.get("heating_loads", {})
41
+
42
+ # Create summary row
43
+ row = {
44
+ "Scenario": scenario_name,
45
+ "Cooling Load (W)": cooling_loads.get("total", 0),
46
+ "Sensible Heat Ratio": cooling_loads.get("sensible_heat_ratio", 0),
47
+ "Heating Load (W)": heating_loads.get("total", 0)
48
+ }
49
+
50
+ # Add to data
51
+ data.append(row)
52
+
53
+ # Create DataFrame
54
+ df = pd.DataFrame(data)
55
+
56
+ return df
57
+
58
+ @staticmethod
59
+ def create_load_comparison_chart(scenarios: Dict[str, Dict[str, Any]], load_type: str = "cooling") -> go.Figure:
60
+ """
61
+ Create a bar chart comparing loads across scenarios.
62
+
63
+ Args:
64
+ scenarios: Dictionary with scenario data
65
+ load_type: Type of load to compare ("cooling" or "heating")
66
+
67
+ Returns:
68
+ Plotly figure with load comparison
69
+ """
70
+ # Initialize data
71
+ scenario_names = []
72
+ total_loads = []
73
+ component_loads = {}
74
+
75
+ # Process scenarios
76
+ for scenario_name, scenario_data in scenarios.items():
77
+ # Extract loads based on load type
78
+ if load_type == "cooling":
79
+ loads = scenario_data.get("cooling_loads", {})
80
+ components = ["walls", "roofs", "floors", "windows_conduction", "windows_solar",
81
+ "doors", "infiltration_sensible", "infiltration_latent",
82
+ "people_sensible", "people_latent", "lights", "equipment_sensible", "equipment_latent"]
83
+ else: # heating
84
+ loads = scenario_data.get("heating_loads", {})
85
+ components = ["walls", "roofs", "floors", "windows", "doors",
86
+ "infiltration_sensible", "infiltration_latent",
87
+ "ventilation_sensible", "ventilation_latent"]
88
+
89
+ # Add scenario name
90
+ scenario_names.append(scenario_name)
91
+
92
+ # Add total load
93
+ total_loads.append(loads.get("total", 0))
94
+
95
+ # Add component loads
96
+ for component in components:
97
+ if component not in component_loads:
98
+ component_loads[component] = []
99
+
100
+ component_loads[component].append(loads.get(component, 0))
101
+
102
+ # Create figure
103
+ fig = go.Figure()
104
+
105
+ # Add total load bars
106
+ fig.add_trace(go.Bar(
107
+ x=scenario_names,
108
+ y=total_loads,
109
+ name="Total Load",
110
+ marker_color="rgba(55, 83, 109, 0.7)",
111
+ opacity=0.7
112
+ ))
113
+
114
+ # Add component load bars
115
+ for component, loads in component_loads.items():
116
+ # Skip components with zero loads
117
+ if sum(loads) == 0:
118
+ continue
119
+
120
+ # Format component name for display
121
+ display_name = component.replace("_", " ").title()
122
+
123
+ fig.add_trace(go.Bar(
124
+ x=scenario_names,
125
+ y=loads,
126
+ name=display_name,
127
+ visible="legendonly"
128
+ ))
129
+
130
+ # Update layout
131
+ title = f"{load_type.title()} Load Comparison"
132
+ y_title = f"{load_type.title()} Load (W)"
133
+
134
+ fig.update_layout(
135
+ title=title,
136
+ xaxis_title="Scenario",
137
+ yaxis_title=y_title,
138
+ barmode="group",
139
+ height=500,
140
+ legend=dict(
141
+ orientation="h",
142
+ yanchor="bottom",
143
+ y=1.02,
144
+ xanchor="right",
145
+ x=1
146
+ )
147
+ )
148
+
149
+ return fig
150
+
151
+ @staticmethod
152
+ def create_percentage_difference_chart(scenarios: Dict[str, Dict[str, Any]],
153
+ baseline_scenario: str,
154
+ load_type: str = "cooling") -> go.Figure:
155
+ """
156
+ Create a bar chart showing percentage differences from a baseline scenario.
157
+
158
+ Args:
159
+ scenarios: Dictionary with scenario data
160
+ baseline_scenario: Name of the baseline scenario
161
+ load_type: Type of load to compare ("cooling" or "heating")
162
+
163
+ Returns:
164
+ Plotly figure with percentage difference chart
165
+ """
166
+ # Check if baseline scenario exists
167
+ if baseline_scenario not in scenarios:
168
+ raise ValueError(f"Baseline scenario '{baseline_scenario}' not found in scenarios")
169
+
170
+ # Get baseline loads
171
+ if load_type == "cooling":
172
+ baseline_loads = scenarios[baseline_scenario].get("cooling_loads", {})
173
+ components = ["walls", "roofs", "floors", "windows_conduction", "windows_solar",
174
+ "doors", "infiltration_sensible", "infiltration_latent",
175
+ "people_sensible", "people_latent", "lights", "equipment_sensible", "equipment_latent"]
176
+ else: # heating
177
+ baseline_loads = scenarios[baseline_scenario].get("heating_loads", {})
178
+ components = ["walls", "roofs", "floors", "windows", "doors",
179
+ "infiltration_sensible", "infiltration_latent",
180
+ "ventilation_sensible", "ventilation_latent"]
181
+
182
+ baseline_total = baseline_loads.get("total", 0)
183
+
184
+ # Initialize data
185
+ scenario_names = []
186
+ percentage_diffs = []
187
+ component_diffs = {}
188
+
189
+ # Process scenarios (excluding baseline)
190
+ for scenario_name, scenario_data in scenarios.items():
191
+ if scenario_name == baseline_scenario:
192
+ continue
193
+
194
+ # Extract loads based on load type
195
+ if load_type == "cooling":
196
+ loads = scenario_data.get("cooling_loads", {})
197
+ else: # heating
198
+ loads = scenario_data.get("heating_loads", {})
199
+
200
+ # Add scenario name
201
+ scenario_names.append(scenario_name)
202
+
203
+ # Calculate percentage difference for total load
204
+ scenario_total = loads.get("total", 0)
205
+ if baseline_total != 0:
206
+ percentage_diff = (scenario_total - baseline_total) / baseline_total * 100
207
+ else:
208
+ percentage_diff = 0
209
+
210
+ percentage_diffs.append(percentage_diff)
211
+
212
+ # Calculate percentage differences for components
213
+ for component in components:
214
+ if component not in component_diffs:
215
+ component_diffs[component] = []
216
+
217
+ baseline_component = baseline_loads.get(component, 0)
218
+ scenario_component = loads.get(component, 0)
219
+
220
+ if baseline_component != 0:
221
+ component_diff = (scenario_component - baseline_component) / baseline_component * 100
222
+ else:
223
+ component_diff = 0
224
+
225
+ component_diffs[component].append(component_diff)
226
+
227
+ # Create figure
228
+ fig = go.Figure()
229
+
230
+ # Add total percentage difference bars
231
+ fig.add_trace(go.Bar(
232
+ x=scenario_names,
233
+ y=percentage_diffs,
234
+ name="Total Load",
235
+ marker_color="rgba(55, 83, 109, 0.7)",
236
+ opacity=0.7
237
+ ))
238
+
239
+ # Add component percentage difference bars
240
+ for component, diffs in component_diffs.items():
241
+ # Skip components with zero differences
242
+ if sum([abs(diff) for diff in diffs]) == 0:
243
+ continue
244
+
245
+ # Format component name for display
246
+ display_name = component.replace("_", " ").title()
247
+
248
+ fig.add_trace(go.Bar(
249
+ x=scenario_names,
250
+ y=diffs,
251
+ name=display_name,
252
+ visible="legendonly"
253
+ ))
254
+
255
+ # Update layout
256
+ title = f"{load_type.title()} Load Percentage Difference from {baseline_scenario}"
257
+ y_title = "Percentage Difference (%)"
258
+
259
+ fig.update_layout(
260
+ title=title,
261
+ xaxis_title="Scenario",
262
+ yaxis_title=y_title,
263
+ barmode="group",
264
+ height=500,
265
+ legend=dict(
266
+ orientation="h",
267
+ yanchor="bottom",
268
+ y=1.02,
269
+ xanchor="right",
270
+ x=1
271
+ )
272
+ )
273
+
274
+ # Add zero line
275
+ fig.add_shape(
276
+ type="line",
277
+ x0=-0.5,
278
+ x1=len(scenario_names) - 0.5,
279
+ y0=0,
280
+ y1=0,
281
+ line=dict(
282
+ color="black",
283
+ width=1,
284
+ dash="dash"
285
+ )
286
+ )
287
+
288
+ return fig
289
+
290
+ @staticmethod
291
+ def create_radar_chart(scenarios: Dict[str, Dict[str, Any]], load_type: str = "cooling") -> go.Figure:
292
+ """
293
+ Create a radar chart comparing key metrics across scenarios.
294
+
295
+ Args:
296
+ scenarios: Dictionary with scenario data
297
+ load_type: Type of load to compare ("cooling" or "heating")
298
+
299
+ Returns:
300
+ Plotly figure with radar chart
301
+ """
302
+ # Define metrics based on load type
303
+ if load_type == "cooling":
304
+ metrics = [
305
+ "total",
306
+ "total_sensible",
307
+ "total_latent",
308
+ "walls",
309
+ "roofs",
310
+ "windows_conduction",
311
+ "windows_solar",
312
+ "infiltration_sensible",
313
+ "people_sensible",
314
+ "lights",
315
+ "equipment_sensible"
316
+ ]
317
+ metric_names = [
318
+ "Total Load",
319
+ "Sensible Load",
320
+ "Latent Load",
321
+ "Walls",
322
+ "Roofs",
323
+ "Windows (Conduction)",
324
+ "Windows (Solar)",
325
+ "Infiltration",
326
+ "People",
327
+ "Lights",
328
+ "Equipment"
329
+ ]
330
+ else: # heating
331
+ metrics = [
332
+ "total",
333
+ "walls",
334
+ "roofs",
335
+ "floors",
336
+ "windows",
337
+ "doors",
338
+ "infiltration_sensible",
339
+ "ventilation_sensible"
340
+ ]
341
+ metric_names = [
342
+ "Total Load",
343
+ "Walls",
344
+ "Roofs",
345
+ "Floors",
346
+ "Windows",
347
+ "Doors",
348
+ "Infiltration",
349
+ "Ventilation"
350
+ ]
351
+
352
+ # Initialize figure
353
+ fig = go.Figure()
354
+
355
+ # Process scenarios
356
+ for scenario_name, scenario_data in scenarios.items():
357
+ # Extract loads based on load type
358
+ if load_type == "cooling":
359
+ loads = scenario_data.get("cooling_loads", {})
360
+ else: # heating
361
+ loads = scenario_data.get("heating_loads", {})
362
+
363
+ # Extract metric values
364
+ values = [loads.get(metric, 0) for metric in metrics]
365
+
366
+ # Add trace
367
+ fig.add_trace(go.Scatterpolar(
368
+ r=values,
369
+ theta=metric_names,
370
+ fill="toself",
371
+ name=scenario_name
372
+ ))
373
+
374
+ # Update layout
375
+ title = f"{load_type.title()} Load Comparison (Radar Chart)"
376
+
377
+ fig.update_layout(
378
+ title=title,
379
+ polar=dict(
380
+ radialaxis=dict(
381
+ visible=True,
382
+ range=[0, max([max([scenarios[s].get(f"{load_type}_loads", {}).get(m, 0) for m in metrics]) for s in scenarios]) * 1.1]
383
+ )
384
+ ),
385
+ height=600,
386
+ showlegend=True
387
+ )
388
+
389
+ return fig
390
+
391
+ @staticmethod
392
+ def create_parallel_coordinates_chart(scenarios: Dict[str, Dict[str, Any]]) -> go.Figure:
393
+ """
394
+ Create a parallel coordinates chart comparing scenarios.
395
+
396
+ Args:
397
+ scenarios: Dictionary with scenario data
398
+
399
+ Returns:
400
+ Plotly figure with parallel coordinates chart
401
+ """
402
+ # Initialize data
403
+ data = []
404
+
405
+ # Process scenarios
406
+ for scenario_name, scenario_data in scenarios.items():
407
+ # Extract cooling and heating loads
408
+ cooling_loads = scenario_data.get("cooling_loads", {})
409
+ heating_loads = scenario_data.get("heating_loads", {})
410
+
411
+ # Create data point
412
+ point = {
413
+ "Scenario": scenario_name,
414
+ "Cooling Load (W)": cooling_loads.get("total", 0),
415
+ "Heating Load (W)": heating_loads.get("total", 0),
416
+ "Sensible Heat Ratio": cooling_loads.get("sensible_heat_ratio", 0),
417
+ "Walls (Cooling)": cooling_loads.get("walls", 0),
418
+ "Windows (Cooling)": cooling_loads.get("windows_conduction", 0) + cooling_loads.get("windows_solar", 0),
419
+ "Internal Gains (Cooling)": cooling_loads.get("people_sensible", 0) + cooling_loads.get("lights", 0) + cooling_loads.get("equipment_sensible", 0),
420
+ "Walls (Heating)": heating_loads.get("walls", 0),
421
+ "Windows (Heating)": heating_loads.get("windows", 0),
422
+ "Infiltration (Heating)": heating_loads.get("infiltration_sensible", 0)
423
+ }
424
+
425
+ # Add to data
426
+ data.append(point)
427
+
428
+ # Create DataFrame
429
+ df = pd.DataFrame(data)
430
+
431
+ # Create figure
432
+ fig = px.parallel_coordinates(
433
+ df,
434
+ color="Cooling Load (W)",
435
+ labels={
436
+ "Scenario": "Scenario",
437
+ "Cooling Load (W)": "Cooling Load (W)",
438
+ "Heating Load (W)": "Heating Load (W)",
439
+ "Sensible Heat Ratio": "Sensible Heat Ratio",
440
+ "Walls (Cooling)": "Walls (Cooling)",
441
+ "Windows (Cooling)": "Windows (Cooling)",
442
+ "Internal Gains (Cooling)": "Internal Gains (Cooling)",
443
+ "Walls (Heating)": "Walls (Heating)",
444
+ "Windows (Heating)": "Windows (Heating)",
445
+ "Infiltration (Heating)": "Infiltration (Heating)"
446
+ },
447
+ color_continuous_scale=px.colors.sequential.Viridis
448
+ )
449
+
450
+ # Update layout
451
+ fig.update_layout(
452
+ title="Scenario Comparison (Parallel Coordinates)",
453
+ height=600
454
+ )
455
+
456
+ return fig
457
+
458
+ @staticmethod
459
+ def display_scenario_comparison(scenarios: Dict[str, Dict[str, Any]]) -> None:
460
+ """
461
+ Display scenario comparison visualization in Streamlit.
462
+
463
+ Args:
464
+ scenarios: Dictionary with scenario data
465
+ """
466
+ st.header("Scenario Comparison Visualization")
467
+
468
+ # Check if scenarios exist
469
+ if not scenarios:
470
+ st.warning("No scenarios available for comparison.")
471
+ return
472
+
473
+ # Create tabs for different visualizations
474
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
475
+ "Scenario Summary",
476
+ "Load Comparison",
477
+ "Percentage Difference",
478
+ "Radar Chart",
479
+ "Parallel Coordinates"
480
+ ])
481
+
482
+ with tab1:
483
+ st.subheader("Scenario Summary")
484
+ df = ScenarioComparisonVisualization.create_scenario_summary_table(scenarios)
485
+ st.dataframe(df, use_container_width=True)
486
+
487
+ # Add download button for CSV
488
+ csv = df.to_csv(index=False).encode('utf-8')
489
+ st.download_button(
490
+ label="Download Scenario Summary as CSV",
491
+ data=csv,
492
+ file_name="scenario_summary.csv",
493
+ mime="text/csv"
494
+ )
495
+
496
+ with tab2:
497
+ st.subheader("Load Comparison")
498
+
499
+ # Add load type selector
500
+ load_type = st.radio(
501
+ "Select Load Type",
502
+ ["cooling", "heating"],
503
+ horizontal=True,
504
+ key="load_comparison_type"
505
+ )
506
+
507
+ # Create and display chart
508
+ fig = ScenarioComparisonVisualization.create_load_comparison_chart(scenarios, load_type)
509
+ st.plotly_chart(fig, use_container_width=True)
510
+
511
+ with tab3:
512
+ st.subheader("Percentage Difference")
513
+
514
+ # Add baseline scenario selector
515
+ baseline_scenario = st.selectbox(
516
+ "Select Baseline Scenario",
517
+ list(scenarios.keys()),
518
+ key="baseline_scenario"
519
+ )
520
+
521
+ # Add load type selector
522
+ load_type = st.radio(
523
+ "Select Load Type",
524
+ ["cooling", "heating"],
525
+ horizontal=True,
526
+ key="percentage_diff_type"
527
+ )
528
+
529
+ # Create and display chart
530
+ try:
531
+ fig = ScenarioComparisonVisualization.create_percentage_difference_chart(
532
+ scenarios, baseline_scenario, load_type
533
+ )
534
+ st.plotly_chart(fig, use_container_width=True)
535
+ except ValueError as e:
536
+ st.error(str(e))
537
+
538
+ with tab4:
539
+ st.subheader("Radar Chart")
540
+
541
+ # Add load type selector
542
+ load_type = st.radio(
543
+ "Select Load Type",
544
+ ["cooling", "heating"],
545
+ horizontal=True,
546
+ key="radar_chart_type"
547
+ )
548
+
549
+ # Create and display chart
550
+ fig = ScenarioComparisonVisualization.create_radar_chart(scenarios, load_type)
551
+ st.plotly_chart(fig, use_container_width=True)
552
+
553
+ with tab5:
554
+ st.subheader("Parallel Coordinates")
555
+
556
+ # Create and display chart
557
+ fig = ScenarioComparisonVisualization.create_parallel_coordinates_chart(scenarios)
558
+ st.plotly_chart(fig, use_container_width=True)
559
+
560
+
561
+ # Create a singleton instance
562
+ scenario_comparison = ScenarioComparisonVisualization()
563
+
564
+ # Example usage
565
+ if __name__ == "__main__":
566
+ import streamlit as st
567
+
568
+ # Create sample scenarios
569
+ scenarios = {
570
+ "Base Case": {
571
+ "cooling_loads": {
572
+ "total": 5000,
573
+ "total_sensible": 4000,
574
+ "total_latent": 1000,
575
+ "sensible_heat_ratio": 0.8,
576
+ "walls": 1000,
577
+ "roofs": 800,
578
+ "floors": 200,
579
+ "windows_conduction": 500,
580
+ "windows_solar": 800,
581
+ "doors": 100,
582
+ "infiltration_sensible": 300,
583
+ "infiltration_latent": 200,
584
+ "people_sensible": 300,
585
+ "people_latent": 200,
586
+ "lights": 400,
587
+ "equipment_sensible": 400,
588
+ "equipment_latent": 600
589
+ },
590
+ "heating_loads": {
591
+ "total": 6000,
592
+ "walls": 1500,
593
+ "roofs": 1000,
594
+ "floors": 500,
595
+ "windows": 1200,
596
+ "doors": 200,
597
+ "infiltration_sensible": 800,
598
+ "infiltration_latent": 0,
599
+ "ventilation_sensible": 800,
600
+ "ventilation_latent": 0,
601
+ "internal_gains_offset": 1000
602
+ }
603
+ },
604
+ "Improved Insulation": {
605
+ "cooling_loads": {
606
+ "total": 4200,
607
+ "total_sensible": 3500,
608
+ "total_latent": 700,
609
+ "sensible_heat_ratio": 0.83,
610
+ "walls": 600,
611
+ "roofs": 500,
612
+ "floors": 150,
613
+ "windows_conduction": 500,
614
+ "windows_solar": 800,
615
+ "doors": 100,
616
+ "infiltration_sensible": 300,
617
+ "infiltration_latent": 200,
618
+ "people_sensible": 300,
619
+ "people_latent": 200,
620
+ "lights": 400,
621
+ "equipment_sensible": 400,
622
+ "equipment_latent": 300
623
+ },
624
+ "heating_loads": {
625
+ "total": 4500,
626
+ "walls": 900,
627
+ "roofs": 600,
628
+ "floors": 300,
629
+ "windows": 1200,
630
+ "doors": 200,
631
+ "infiltration_sensible": 800,
632
+ "infiltration_latent": 0,
633
+ "ventilation_sensible": 800,
634
+ "ventilation_latent": 0,
635
+ "internal_gains_offset": 1000
636
+ }
637
+ },
638
+ "Better Windows": {
639
+ "cooling_loads": {
640
+ "total": 4000,
641
+ "total_sensible": 3300,
642
+ "total_latent": 700,
643
+ "sensible_heat_ratio": 0.83,
644
+ "walls": 1000,
645
+ "roofs": 800,
646
+ "floors": 200,
647
+ "windows_conduction": 250,
648
+ "windows_solar": 400,
649
+ "doors": 100,
650
+ "infiltration_sensible": 300,
651
+ "infiltration_latent": 200,
652
+ "people_sensible": 300,
653
+ "people_latent": 200,
654
+ "lights": 400,
655
+ "equipment_sensible": 400,
656
+ "equipment_latent": 300
657
+ },
658
+ "heating_loads": {
659
+ "total": 5000,
660
+ "walls": 1500,
661
+ "roofs": 1000,
662
+ "floors": 500,
663
+ "windows": 600,
664
+ "doors": 200,
665
+ "infiltration_sensible": 800,
666
+ "infiltration_latent": 0,
667
+ "ventilation_sensible": 800,
668
+ "ventilation_latent": 0,
669
+ "internal_gains_offset": 1000
670
+ }
671
+ }
672
+ }
673
+
674
+ # Display scenario comparison
675
+ scenario_comparison.display_scenario_comparison(scenarios)
utils/shading_system.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shading system module for HVAC Load Calculator.
3
+ This module implements shading type selection and coverage percentage interface.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ from enum import Enum
12
+ from dataclasses import dataclass
13
+
14
+ # Define paths
15
+ DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
16
+
17
+
18
+ class ShadingType(Enum):
19
+ """Enumeration for shading types."""
20
+ NONE = "None"
21
+ INTERNAL = "Internal"
22
+ EXTERNAL = "External"
23
+ BETWEEN_GLASS = "Between-glass"
24
+
25
+
26
+ @dataclass
27
+ class ShadingDevice:
28
+ """Class representing a shading device."""
29
+
30
+ id: str
31
+ name: str
32
+ shading_type: ShadingType
33
+ shading_coefficient: float # 0-1 (1 = no shading)
34
+ coverage_percentage: float = 100.0 # 0-100%
35
+ description: str = ""
36
+
37
+ def __post_init__(self):
38
+ """Validate shading device data after initialization."""
39
+ if self.shading_coefficient < 0 or self.shading_coefficient > 1:
40
+ raise ValueError("Shading coefficient must be between 0 and 1")
41
+ if self.coverage_percentage < 0 or self.coverage_percentage > 100:
42
+ raise ValueError("Coverage percentage must be between 0 and 100")
43
+
44
+ @property
45
+ def effective_shading_coefficient(self) -> float:
46
+ """Calculate the effective shading coefficient considering coverage percentage."""
47
+ # If coverage is less than 100%, the effective coefficient is a weighted average
48
+ # between the device coefficient and 1.0 (no shading)
49
+ coverage_factor = self.coverage_percentage / 100.0
50
+ return self.shading_coefficient * coverage_factor + 1.0 * (1 - coverage_factor)
51
+
52
+ def to_dict(self) -> Dict[str, Any]:
53
+ """Convert the shading device to a dictionary."""
54
+ return {
55
+ "id": self.id,
56
+ "name": self.name,
57
+ "shading_type": self.shading_type.value,
58
+ "shading_coefficient": self.shading_coefficient,
59
+ "coverage_percentage": self.coverage_percentage,
60
+ "description": self.description,
61
+ "effective_shading_coefficient": self.effective_shading_coefficient
62
+ }
63
+
64
+
65
+ class ShadingSystem:
66
+ """Class for managing shading devices and calculations."""
67
+
68
+ def __init__(self):
69
+ """Initialize shading system."""
70
+ self.shading_devices = {}
71
+ self.load_preset_devices()
72
+
73
+ def load_preset_devices(self) -> None:
74
+ """Load preset shading devices."""
75
+ # Internal shading devices
76
+ self.shading_devices["preset_venetian_blinds"] = ShadingDevice(
77
+ id="preset_venetian_blinds",
78
+ name="Venetian Blinds",
79
+ shading_type=ShadingType.INTERNAL,
80
+ shading_coefficient=0.6,
81
+ description="Standard internal venetian blinds"
82
+ )
83
+
84
+ self.shading_devices["preset_roller_shade"] = ShadingDevice(
85
+ id="preset_roller_shade",
86
+ name="Roller Shade",
87
+ shading_type=ShadingType.INTERNAL,
88
+ shading_coefficient=0.7,
89
+ description="Standard internal roller shade"
90
+ )
91
+
92
+ self.shading_devices["preset_drapes_light"] = ShadingDevice(
93
+ id="preset_drapes_light",
94
+ name="Light Drapes",
95
+ shading_type=ShadingType.INTERNAL,
96
+ shading_coefficient=0.8,
97
+ description="Light-colored internal drapes"
98
+ )
99
+
100
+ self.shading_devices["preset_drapes_dark"] = ShadingDevice(
101
+ id="preset_drapes_dark",
102
+ name="Dark Drapes",
103
+ shading_type=ShadingType.INTERNAL,
104
+ shading_coefficient=0.5,
105
+ description="Dark-colored internal drapes"
106
+ )
107
+
108
+ # External shading devices
109
+ self.shading_devices["preset_overhang"] = ShadingDevice(
110
+ id="preset_overhang",
111
+ name="Overhang",
112
+ shading_type=ShadingType.EXTERNAL,
113
+ shading_coefficient=0.4,
114
+ description="External overhang"
115
+ )
116
+
117
+ self.shading_devices["preset_louvers"] = ShadingDevice(
118
+ id="preset_louvers",
119
+ name="Louvers",
120
+ shading_type=ShadingType.EXTERNAL,
121
+ shading_coefficient=0.3,
122
+ description="External louvers"
123
+ )
124
+
125
+ self.shading_devices["preset_exterior_screen"] = ShadingDevice(
126
+ id="preset_exterior_screen",
127
+ name="Exterior Screen",
128
+ shading_type=ShadingType.EXTERNAL,
129
+ shading_coefficient=0.5,
130
+ description="External screen"
131
+ )
132
+
133
+ # Between-glass shading devices
134
+ self.shading_devices["preset_between_glass_blinds"] = ShadingDevice(
135
+ id="preset_between_glass_blinds",
136
+ name="Between-glass Blinds",
137
+ shading_type=ShadingType.BETWEEN_GLASS,
138
+ shading_coefficient=0.5,
139
+ description="Blinds between glass panes"
140
+ )
141
+
142
+ def get_device(self, device_id: str) -> Optional[ShadingDevice]:
143
+ """
144
+ Get a shading device by ID.
145
+
146
+ Args:
147
+ device_id: Device identifier
148
+
149
+ Returns:
150
+ ShadingDevice object or None if not found
151
+ """
152
+ return self.shading_devices.get(device_id)
153
+
154
+ def get_devices_by_type(self, shading_type: ShadingType) -> List[ShadingDevice]:
155
+ """
156
+ Get all shading devices of a specific type.
157
+
158
+ Args:
159
+ shading_type: Shading type
160
+
161
+ Returns:
162
+ List of ShadingDevice objects
163
+ """
164
+ return [device for device in self.shading_devices.values()
165
+ if device.shading_type == shading_type]
166
+
167
+ def get_preset_devices(self) -> List[ShadingDevice]:
168
+ """
169
+ Get all preset shading devices.
170
+
171
+ Returns:
172
+ List of ShadingDevice objects
173
+ """
174
+ return [device for device_id, device in self.shading_devices.items()
175
+ if device_id.startswith("preset_")]
176
+
177
+ def get_custom_devices(self) -> List[ShadingDevice]:
178
+ """
179
+ Get all custom shading devices.
180
+
181
+ Returns:
182
+ List of ShadingDevice objects
183
+ """
184
+ return [device for device_id, device in self.shading_devices.items()
185
+ if device_id.startswith("custom_")]
186
+
187
+ def add_device(self, name: str, shading_type: ShadingType,
188
+ shading_coefficient: float, coverage_percentage: float = 100.0,
189
+ description: str = "") -> str:
190
+ """
191
+ Add a custom shading device.
192
+
193
+ Args:
194
+ name: Device name
195
+ shading_type: Shading type
196
+ shading_coefficient: Shading coefficient (0-1)
197
+ coverage_percentage: Coverage percentage (0-100)
198
+ description: Device description
199
+
200
+ Returns:
201
+ Device ID
202
+ """
203
+ import uuid
204
+
205
+ device_id = f"custom_shading_{str(uuid.uuid4())[:8]}"
206
+ device = ShadingDevice(
207
+ id=device_id,
208
+ name=name,
209
+ shading_type=shading_type,
210
+ shading_coefficient=shading_coefficient,
211
+ coverage_percentage=coverage_percentage,
212
+ description=description
213
+ )
214
+
215
+ self.shading_devices[device_id] = device
216
+ return device_id
217
+
218
+ def update_device(self, device_id: str, name: str = None,
219
+ shading_coefficient: float = None,
220
+ coverage_percentage: float = None,
221
+ description: str = None) -> bool:
222
+ """
223
+ Update a shading device.
224
+
225
+ Args:
226
+ device_id: Device identifier
227
+ name: New device name (optional)
228
+ shading_coefficient: New shading coefficient (optional)
229
+ coverage_percentage: New coverage percentage (optional)
230
+ description: New device description (optional)
231
+
232
+ Returns:
233
+ True if the device was updated, False otherwise
234
+ """
235
+ if device_id not in self.shading_devices:
236
+ return False
237
+
238
+ # Don't allow updating preset devices
239
+ if device_id.startswith("preset_"):
240
+ return False
241
+
242
+ device = self.shading_devices[device_id]
243
+
244
+ if name is not None:
245
+ device.name = name
246
+
247
+ if shading_coefficient is not None:
248
+ if shading_coefficient < 0 or shading_coefficient > 1:
249
+ return False
250
+ device.shading_coefficient = shading_coefficient
251
+
252
+ if coverage_percentage is not None:
253
+ if coverage_percentage < 0 or coverage_percentage > 100:
254
+ return False
255
+ device.coverage_percentage = coverage_percentage
256
+
257
+ if description is not None:
258
+ device.description = description
259
+
260
+ return True
261
+
262
+ def remove_device(self, device_id: str) -> bool:
263
+ """
264
+ Remove a shading device.
265
+
266
+ Args:
267
+ device_id: Device identifier
268
+
269
+ Returns:
270
+ True if the device was removed, False otherwise
271
+ """
272
+ if device_id not in self.shading_devices:
273
+ return False
274
+
275
+ # Don't allow removing preset devices
276
+ if device_id.startswith("preset_"):
277
+ return False
278
+
279
+ del self.shading_devices[device_id]
280
+ return True
281
+
282
+ def calculate_effective_shgc(self, base_shgc: float, device_id: str) -> float:
283
+ """
284
+ Calculate the effective SHGC (Solar Heat Gain Coefficient) with shading.
285
+
286
+ Args:
287
+ base_shgc: Base SHGC of the window
288
+ device_id: Shading device identifier
289
+
290
+ Returns:
291
+ Effective SHGC with shading
292
+ """
293
+ if device_id not in self.shading_devices:
294
+ return base_shgc
295
+
296
+ device = self.shading_devices[device_id]
297
+ return base_shgc * device.effective_shading_coefficient
298
+
299
+ def export_to_json(self, file_path: str) -> None:
300
+ """
301
+ Export all shading devices to a JSON file.
302
+
303
+ Args:
304
+ file_path: Path to the output JSON file
305
+ """
306
+ data = {device_id: device.to_dict() for device_id, device in self.shading_devices.items()}
307
+
308
+ with open(file_path, 'w') as f:
309
+ json.dump(data, f, indent=4)
310
+
311
+ def import_from_json(self, file_path: str) -> int:
312
+ """
313
+ Import shading devices from a JSON file.
314
+
315
+ Args:
316
+ file_path: Path to the input JSON file
317
+
318
+ Returns:
319
+ Number of devices imported
320
+ """
321
+ with open(file_path, 'r') as f:
322
+ data = json.load(f)
323
+
324
+ count = 0
325
+ for device_id, device_data in data.items():
326
+ try:
327
+ shading_type = ShadingType(device_data["shading_type"])
328
+ device = ShadingDevice(
329
+ id=device_id,
330
+ name=device_data["name"],
331
+ shading_type=shading_type,
332
+ shading_coefficient=device_data["shading_coefficient"],
333
+ coverage_percentage=device_data.get("coverage_percentage", 100.0),
334
+ description=device_data.get("description", "")
335
+ )
336
+
337
+ self.shading_devices[device_id] = device
338
+ count += 1
339
+ except Exception as e:
340
+ print(f"Error importing shading device {device_id}: {e}")
341
+
342
+ return count
343
+
344
+
345
+ # Create a singleton instance
346
+ shading_system = ShadingSystem()
347
+
348
+ # Export shading system to JSON if needed
349
+ if __name__ == "__main__":
350
+ shading_system.export_to_json(os.path.join(DATA_DIR, "data", "shading_system.json"))
utils/time_based_visualization.py ADDED
@@ -0,0 +1,745 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Time-based visualization module for HVAC Load Calculator.
3
+ This module provides visualization tools for time-based load analysis.
4
+ """
5
+
6
+ import streamlit as st
7
+ import pandas as pd
8
+ import numpy as np
9
+ import plotly.graph_objects as go
10
+ import plotly.express as px
11
+ from typing import Dict, List, Any, Optional, Tuple
12
+ import math
13
+ import calendar
14
+ from datetime import datetime, timedelta
15
+
16
+
17
+ class TimeBasedVisualization:
18
+ """Class for time-based visualization."""
19
+
20
+ @staticmethod
21
+ def create_hourly_load_profile(hourly_loads: Dict[str, List[float]],
22
+ date: str = "Jul 15") -> go.Figure:
23
+ """
24
+ Create an hourly load profile chart.
25
+
26
+ Args:
27
+ hourly_loads: Dictionary with hourly load data
28
+ date: Date for the profile (e.g., "Jul 15")
29
+
30
+ Returns:
31
+ Plotly figure with hourly load profile
32
+ """
33
+ # Create hour labels
34
+ hours = list(range(24))
35
+ hour_labels = [f"{h}:00" for h in hours]
36
+
37
+ # Create figure
38
+ fig = go.Figure()
39
+
40
+ # Add total load trace
41
+ if "total" in hourly_loads:
42
+ fig.add_trace(go.Scatter(
43
+ x=hour_labels,
44
+ y=hourly_loads["total"],
45
+ mode="lines+markers",
46
+ name="Total Load",
47
+ line=dict(color="rgba(55, 83, 109, 1)", width=3),
48
+ marker=dict(size=8)
49
+ ))
50
+
51
+ # Add component load traces
52
+ for component, loads in hourly_loads.items():
53
+ if component == "total":
54
+ continue
55
+
56
+ # Format component name for display
57
+ display_name = component.replace("_", " ").title()
58
+
59
+ fig.add_trace(go.Scatter(
60
+ x=hour_labels,
61
+ y=loads,
62
+ mode="lines+markers",
63
+ name=display_name,
64
+ marker=dict(size=6),
65
+ line=dict(width=2)
66
+ ))
67
+
68
+ # Update layout
69
+ fig.update_layout(
70
+ title=f"Hourly Load Profile ({date})",
71
+ xaxis_title="Hour of Day",
72
+ yaxis_title="Load (W)",
73
+ height=500,
74
+ legend=dict(
75
+ orientation="h",
76
+ yanchor="bottom",
77
+ y=1.02,
78
+ xanchor="right",
79
+ x=1
80
+ ),
81
+ hovermode="x unified"
82
+ )
83
+
84
+ return fig
85
+
86
+ @staticmethod
87
+ def create_daily_load_profile(daily_loads: Dict[str, List[float]],
88
+ month: str = "July") -> go.Figure:
89
+ """
90
+ Create a daily load profile chart for a month.
91
+
92
+ Args:
93
+ daily_loads: Dictionary with daily load data
94
+ month: Month name
95
+
96
+ Returns:
97
+ Plotly figure with daily load profile
98
+ """
99
+ # Get number of days in month
100
+ month_num = list(calendar.month_name).index(month)
101
+ year = datetime.now().year
102
+ num_days = calendar.monthrange(year, month_num)[1]
103
+
104
+ # Create day labels
105
+ days = list(range(1, num_days + 1))
106
+ day_labels = [f"{d}" for d in days]
107
+
108
+ # Create figure
109
+ fig = go.Figure()
110
+
111
+ # Add total load trace
112
+ if "total" in daily_loads:
113
+ fig.add_trace(go.Scatter(
114
+ x=day_labels,
115
+ y=daily_loads["total"][:num_days],
116
+ mode="lines+markers",
117
+ name="Total Load",
118
+ line=dict(color="rgba(55, 83, 109, 1)", width=3),
119
+ marker=dict(size=8)
120
+ ))
121
+
122
+ # Add component load traces
123
+ for component, loads in daily_loads.items():
124
+ if component == "total":
125
+ continue
126
+
127
+ # Format component name for display
128
+ display_name = component.replace("_", " ").title()
129
+
130
+ fig.add_trace(go.Scatter(
131
+ x=day_labels,
132
+ y=loads[:num_days],
133
+ mode="lines+markers",
134
+ name=display_name,
135
+ marker=dict(size=6),
136
+ line=dict(width=2)
137
+ ))
138
+
139
+ # Update layout
140
+ fig.update_layout(
141
+ title=f"Daily Load Profile ({month})",
142
+ xaxis_title="Day of Month",
143
+ yaxis_title="Load (W)",
144
+ height=500,
145
+ legend=dict(
146
+ orientation="h",
147
+ yanchor="bottom",
148
+ y=1.02,
149
+ xanchor="right",
150
+ x=1
151
+ ),
152
+ hovermode="x unified"
153
+ )
154
+
155
+ return fig
156
+
157
+ @staticmethod
158
+ def create_monthly_load_comparison(monthly_loads: Dict[str, List[float]],
159
+ load_type: str = "cooling") -> go.Figure:
160
+ """
161
+ Create a monthly load comparison chart.
162
+
163
+ Args:
164
+ monthly_loads: Dictionary with monthly load data
165
+ load_type: Type of load ("cooling" or "heating")
166
+
167
+ Returns:
168
+ Plotly figure with monthly load comparison
169
+ """
170
+ # Create month labels
171
+ months = list(calendar.month_name)[1:]
172
+
173
+ # Create figure
174
+ fig = go.Figure()
175
+
176
+ # Add total load bars
177
+ if "total" in monthly_loads:
178
+ fig.add_trace(go.Bar(
179
+ x=months,
180
+ y=monthly_loads["total"],
181
+ name="Total Load",
182
+ marker_color="rgba(55, 83, 109, 0.7)",
183
+ opacity=0.7
184
+ ))
185
+
186
+ # Add component load bars
187
+ for component, loads in monthly_loads.items():
188
+ if component == "total":
189
+ continue
190
+
191
+ # Format component name for display
192
+ display_name = component.replace("_", " ").title()
193
+
194
+ fig.add_trace(go.Bar(
195
+ x=months,
196
+ y=loads,
197
+ name=display_name,
198
+ visible="legendonly"
199
+ ))
200
+
201
+ # Update layout
202
+ title = f"Monthly {load_type.title()} Load Comparison"
203
+ y_title = f"{load_type.title()} Load (kWh)"
204
+
205
+ fig.update_layout(
206
+ title=title,
207
+ xaxis_title="Month",
208
+ yaxis_title=y_title,
209
+ height=500,
210
+ legend=dict(
211
+ orientation="h",
212
+ yanchor="bottom",
213
+ y=1.02,
214
+ xanchor="right",
215
+ x=1
216
+ ),
217
+ hovermode="x unified"
218
+ )
219
+
220
+ return fig
221
+
222
+ @staticmethod
223
+ def create_annual_load_distribution(annual_loads: Dict[str, float],
224
+ load_type: str = "cooling") -> go.Figure:
225
+ """
226
+ Create an annual load distribution pie chart.
227
+
228
+ Args:
229
+ annual_loads: Dictionary with annual load data by component
230
+ load_type: Type of load ("cooling" or "heating")
231
+
232
+ Returns:
233
+ Plotly figure with annual load distribution
234
+ """
235
+ # Extract components and values
236
+ components = []
237
+ values = []
238
+
239
+ for component, load in annual_loads.items():
240
+ if component == "total":
241
+ continue
242
+
243
+ # Format component name for display
244
+ display_name = component.replace("_", " ").title()
245
+ components.append(display_name)
246
+ values.append(load)
247
+
248
+ # Create pie chart
249
+ fig = go.Figure(data=[go.Pie(
250
+ labels=components,
251
+ values=values,
252
+ hole=0.3,
253
+ textinfo="label+percent",
254
+ insidetextorientation="radial"
255
+ )])
256
+
257
+ # Update layout
258
+ title = f"Annual {load_type.title()} Load Distribution"
259
+
260
+ fig.update_layout(
261
+ title=title,
262
+ height=500,
263
+ legend=dict(
264
+ orientation="h",
265
+ yanchor="bottom",
266
+ y=1.02,
267
+ xanchor="right",
268
+ x=1
269
+ )
270
+ )
271
+
272
+ return fig
273
+
274
+ @staticmethod
275
+ def create_peak_load_analysis(peak_loads: Dict[str, Dict[str, Any]],
276
+ load_type: str = "cooling") -> go.Figure:
277
+ """
278
+ Create a peak load analysis chart.
279
+
280
+ Args:
281
+ peak_loads: Dictionary with peak load data
282
+ load_type: Type of load ("cooling" or "heating")
283
+
284
+ Returns:
285
+ Plotly figure with peak load analysis
286
+ """
287
+ # Extract peak load data
288
+ components = []
289
+ values = []
290
+ times = []
291
+
292
+ for component, data in peak_loads.items():
293
+ if component == "total":
294
+ continue
295
+
296
+ # Format component name for display
297
+ display_name = component.replace("_", " ").title()
298
+ components.append(display_name)
299
+ values.append(data["value"])
300
+ times.append(data["time"])
301
+
302
+ # Create bar chart
303
+ fig = go.Figure(data=[go.Bar(
304
+ x=components,
305
+ y=values,
306
+ text=times,
307
+ textposition="auto",
308
+ hovertemplate="<b>%{x}</b><br>Peak Load: %{y:.0f} W<br>Time: %{text}<extra></extra>"
309
+ )])
310
+
311
+ # Update layout
312
+ title = f"Peak {load_type.title()} Load Analysis"
313
+ y_title = f"Peak {load_type.title()} Load (W)"
314
+
315
+ fig.update_layout(
316
+ title=title,
317
+ xaxis_title="Component",
318
+ yaxis_title=y_title,
319
+ height=500
320
+ )
321
+
322
+ return fig
323
+
324
+ @staticmethod
325
+ def create_load_duration_curve(hourly_loads: List[float],
326
+ load_type: str = "cooling") -> go.Figure:
327
+ """
328
+ Create a load duration curve.
329
+
330
+ Args:
331
+ hourly_loads: List of hourly loads for the year
332
+ load_type: Type of load ("cooling" or "heating")
333
+
334
+ Returns:
335
+ Plotly figure with load duration curve
336
+ """
337
+ # Sort loads in descending order
338
+ sorted_loads = sorted(hourly_loads, reverse=True)
339
+
340
+ # Create hour indices
341
+ hours = list(range(1, len(sorted_loads) + 1))
342
+
343
+ # Create figure
344
+ fig = go.Figure(data=[go.Scatter(
345
+ x=hours,
346
+ y=sorted_loads,
347
+ mode="lines",
348
+ line=dict(color="rgba(55, 83, 109, 1)", width=2),
349
+ fill="tozeroy",
350
+ fillcolor="rgba(55, 83, 109, 0.2)"
351
+ )])
352
+
353
+ # Update layout
354
+ title = f"{load_type.title()} Load Duration Curve"
355
+ x_title = "Hours"
356
+ y_title = f"{load_type.title()} Load (W)"
357
+
358
+ fig.update_layout(
359
+ title=title,
360
+ xaxis_title=x_title,
361
+ yaxis_title=y_title,
362
+ height=500,
363
+ xaxis=dict(
364
+ type="log",
365
+ range=[0, math.log10(len(hours))]
366
+ )
367
+ )
368
+
369
+ return fig
370
+
371
+ @staticmethod
372
+ def create_heat_map(hourly_data: List[List[float]],
373
+ x_labels: List[str],
374
+ y_labels: List[str],
375
+ title: str,
376
+ colorscale: str = "Viridis") -> go.Figure:
377
+ """
378
+ Create a heat map visualization.
379
+
380
+ Args:
381
+ hourly_data: 2D list of hourly data
382
+ x_labels: Labels for x-axis
383
+ y_labels: Labels for y-axis
384
+ title: Chart title
385
+ colorscale: Colorscale for the heatmap
386
+
387
+ Returns:
388
+ Plotly figure with heat map
389
+ """
390
+ # Create figure
391
+ fig = go.Figure(data=go.Heatmap(
392
+ z=hourly_data,
393
+ x=x_labels,
394
+ y=y_labels,
395
+ colorscale=colorscale,
396
+ colorbar=dict(title="Load (W)")
397
+ ))
398
+
399
+ # Update layout
400
+ fig.update_layout(
401
+ title=title,
402
+ height=600,
403
+ xaxis=dict(
404
+ title="Hour of Day",
405
+ tickmode="array",
406
+ tickvals=list(range(0, 24, 2)),
407
+ ticktext=[f"{h}:00" for h in range(0, 24, 2)]
408
+ ),
409
+ yaxis=dict(
410
+ title="Day",
411
+ autorange="reversed"
412
+ )
413
+ )
414
+
415
+ return fig
416
+
417
+ @staticmethod
418
+ def display_time_based_visualization(cooling_loads: Dict[str, Any] = None,
419
+ heating_loads: Dict[str, Any] = None) -> None:
420
+ """
421
+ Display time-based visualization in Streamlit.
422
+
423
+ Args:
424
+ cooling_loads: Dictionary with cooling load data
425
+ heating_loads: Dictionary with heating load data
426
+ """
427
+ st.header("Time-Based Visualization")
428
+
429
+ # Check if load data exists
430
+ if cooling_loads is None and heating_loads is None:
431
+ st.warning("No load data available for visualization.")
432
+
433
+ # Create sample data for demonstration
434
+ st.info("Using sample data for demonstration.")
435
+
436
+ # Generate sample cooling loads
437
+ cooling_loads = {
438
+ "hourly": {
439
+ "total": [1000 + 500 * math.sin(h * math.pi / 12) + 1000 * math.sin(h * math.pi / 6) for h in range(24)],
440
+ "walls": [300 + 150 * math.sin(h * math.pi / 12) for h in range(24)],
441
+ "roofs": [400 + 200 * math.sin(h * math.pi / 12) for h in range(24)],
442
+ "windows": [500 + 300 * math.sin(h * math.pi / 6) for h in range(24)],
443
+ "internal": [200 + 100 * math.sin(h * math.pi / 8) for h in range(24)]
444
+ },
445
+ "daily": {
446
+ "total": [2000 + 1000 * math.sin(d * math.pi / 15) for d in range(1, 32)],
447
+ "walls": [600 + 300 * math.sin(d * math.pi / 15) for d in range(1, 32)],
448
+ "roofs": [800 + 400 * math.sin(d * math.pi / 15) for d in range(1, 32)],
449
+ "windows": [1000 + 500 * math.sin(d * math.pi / 15) for d in range(1, 32)]
450
+ },
451
+ "monthly": {
452
+ "total": [1000, 1200, 1500, 2000, 2500, 3000, 3500, 3200, 2800, 2000, 1500, 1200],
453
+ "walls": [300, 350, 400, 500, 600, 700, 800, 750, 650, 500, 400, 350],
454
+ "roofs": [400, 450, 500, 600, 700, 800, 900, 850, 750, 600, 500, 450],
455
+ "windows": [500, 550, 600, 700, 800, 900, 1000, 950, 850, 700, 600, 550]
456
+ },
457
+ "annual": {
458
+ "total": 25000,
459
+ "walls": 6000,
460
+ "roofs": 8000,
461
+ "windows": 9000,
462
+ "internal": 2000
463
+ },
464
+ "peak": {
465
+ "total": {"value": 3500, "time": "Jul 15, 15:00"},
466
+ "walls": {"value": 800, "time": "Jul 15, 16:00"},
467
+ "roofs": {"value": 900, "time": "Jul 15, 14:00"},
468
+ "windows": {"value": 1000, "time": "Jul 15, 15:00"},
469
+ "internal": {"value": 200, "time": "Jul 15, 17:00"}
470
+ }
471
+ }
472
+
473
+ # Generate sample heating loads
474
+ heating_loads = {
475
+ "hourly": {
476
+ "total": [3000 - 1000 * math.sin(h * math.pi / 12) for h in range(24)],
477
+ "walls": [900 - 300 * math.sin(h * math.pi / 12) for h in range(24)],
478
+ "roofs": [1200 - 400 * math.sin(h * math.pi / 12) for h in range(24)],
479
+ "windows": [1500 - 500 * math.sin(h * math.pi / 12) for h in range(24)]
480
+ },
481
+ "daily": {
482
+ "total": [3000 - 1000 * math.sin(d * math.pi / 15) for d in range(1, 32)],
483
+ "walls": [900 - 300 * math.sin(d * math.pi / 15) for d in range(1, 32)],
484
+ "roofs": [1200 - 400 * math.sin(d * math.pi / 15) for d in range(1, 32)],
485
+ "windows": [1500 - 500 * math.sin(d * math.pi / 15) for d in range(1, 32)]
486
+ },
487
+ "monthly": {
488
+ "total": [3500, 3200, 2800, 2000, 1500, 1000, 800, 1000, 1500, 2000, 2800, 3500],
489
+ "walls": [1050, 960, 840, 600, 450, 300, 240, 300, 450, 600, 840, 1050],
490
+ "roofs": [1400, 1280, 1120, 800, 600, 400, 320, 400, 600, 800, 1120, 1400],
491
+ "windows": [1750, 1600, 1400, 1000, 750, 500, 400, 500, 750, 1000, 1400, 1750]
492
+ },
493
+ "annual": {
494
+ "total": 25000,
495
+ "walls": 7500,
496
+ "roofs": 10000,
497
+ "windows": 12500,
498
+ "infiltration": 5000
499
+ },
500
+ "peak": {
501
+ "total": {"value": 3500, "time": "Jan 15, 06:00"},
502
+ "walls": {"value": 1050, "time": "Jan 15, 06:00"},
503
+ "roofs": {"value": 1400, "time": "Jan 15, 06:00"},
504
+ "windows": {"value": 1750, "time": "Jan 15, 06:00"},
505
+ "infiltration": {"value": 500, "time": "Jan 15, 06:00"}
506
+ }
507
+ }
508
+
509
+ # Create tabs for different visualizations
510
+ tab1, tab2, tab3, tab4, tab5 = st.tabs([
511
+ "Hourly Profiles",
512
+ "Monthly Comparison",
513
+ "Annual Distribution",
514
+ "Peak Load Analysis",
515
+ "Heat Maps"
516
+ ])
517
+
518
+ with tab1:
519
+ st.subheader("Hourly Load Profiles")
520
+
521
+ # Add load type selector
522
+ load_type = st.radio(
523
+ "Select Load Type",
524
+ ["cooling", "heating"],
525
+ horizontal=True,
526
+ key="hourly_profile_type"
527
+ )
528
+
529
+ # Add date selector
530
+ date = st.selectbox(
531
+ "Select Date",
532
+ ["Jan 15", "Apr 15", "Jul 15", "Oct 15"],
533
+ index=2,
534
+ key="hourly_profile_date"
535
+ )
536
+
537
+ # Get appropriate load data
538
+ if load_type == "cooling":
539
+ hourly_data = cooling_loads.get("hourly", {})
540
+ else:
541
+ hourly_data = heating_loads.get("hourly", {})
542
+
543
+ # Create and display chart
544
+ fig = TimeBasedVisualization.create_hourly_load_profile(hourly_data, date)
545
+ st.plotly_chart(fig, use_container_width=True)
546
+
547
+ # Add daily profile option
548
+ st.subheader("Daily Load Profiles")
549
+
550
+ # Add month selector
551
+ month = st.selectbox(
552
+ "Select Month",
553
+ list(calendar.month_name)[1:],
554
+ index=6, # July
555
+ key="daily_profile_month"
556
+ )
557
+
558
+ # Get appropriate load data
559
+ if load_type == "cooling":
560
+ daily_data = cooling_loads.get("daily", {})
561
+ else:
562
+ daily_data = heating_loads.get("daily", {})
563
+
564
+ # Create and display chart
565
+ fig = TimeBasedVisualization.create_daily_load_profile(daily_data, month)
566
+ st.plotly_chart(fig, use_container_width=True)
567
+
568
+ with tab2:
569
+ st.subheader("Monthly Load Comparison")
570
+
571
+ # Add load type selector
572
+ load_type = st.radio(
573
+ "Select Load Type",
574
+ ["cooling", "heating"],
575
+ horizontal=True,
576
+ key="monthly_comparison_type"
577
+ )
578
+
579
+ # Get appropriate load data
580
+ if load_type == "cooling":
581
+ monthly_data = cooling_loads.get("monthly", {})
582
+ else:
583
+ monthly_data = heating_loads.get("monthly", {})
584
+
585
+ # Create and display chart
586
+ fig = TimeBasedVisualization.create_monthly_load_comparison(monthly_data, load_type)
587
+ st.plotly_chart(fig, use_container_width=True)
588
+
589
+ # Add download button for CSV
590
+ monthly_df = pd.DataFrame(monthly_data)
591
+ monthly_df.index = list(calendar.month_name)[1:]
592
+
593
+ csv = monthly_df.to_csv().encode('utf-8')
594
+ st.download_button(
595
+ label=f"Download Monthly {load_type.title()} Loads as CSV",
596
+ data=csv,
597
+ file_name=f"monthly_{load_type}_loads.csv",
598
+ mime="text/csv"
599
+ )
600
+
601
+ with tab3:
602
+ st.subheader("Annual Load Distribution")
603
+
604
+ # Add load type selector
605
+ load_type = st.radio(
606
+ "Select Load Type",
607
+ ["cooling", "heating"],
608
+ horizontal=True,
609
+ key="annual_distribution_type"
610
+ )
611
+
612
+ # Get appropriate load data
613
+ if load_type == "cooling":
614
+ annual_data = cooling_loads.get("annual", {})
615
+ else:
616
+ annual_data = heating_loads.get("annual", {})
617
+
618
+ # Create and display chart
619
+ fig = TimeBasedVisualization.create_annual_load_distribution(annual_data, load_type)
620
+ st.plotly_chart(fig, use_container_width=True)
621
+
622
+ # Display annual total
623
+ total = annual_data.get("total", 0)
624
+ st.metric(f"Total Annual {load_type.title()} Load", f"{total:,.0f} kWh")
625
+
626
+ # Add download button for CSV
627
+ annual_df = pd.DataFrame({"Component": list(annual_data.keys()), "Load (kWh)": list(annual_data.values())})
628
+
629
+ csv = annual_df.to_csv(index=False).encode('utf-8')
630
+ st.download_button(
631
+ label=f"Download Annual {load_type.title()} Loads as CSV",
632
+ data=csv,
633
+ file_name=f"annual_{load_type}_loads.csv",
634
+ mime="text/csv"
635
+ )
636
+
637
+ with tab4:
638
+ st.subheader("Peak Load Analysis")
639
+
640
+ # Add load type selector
641
+ load_type = st.radio(
642
+ "Select Load Type",
643
+ ["cooling", "heating"],
644
+ horizontal=True,
645
+ key="peak_load_type"
646
+ )
647
+
648
+ # Get appropriate load data
649
+ if load_type == "cooling":
650
+ peak_data = cooling_loads.get("peak", {})
651
+ else:
652
+ peak_data = heating_loads.get("peak", {})
653
+
654
+ # Create and display chart
655
+ fig = TimeBasedVisualization.create_peak_load_analysis(peak_data, load_type)
656
+ st.plotly_chart(fig, use_container_width=True)
657
+
658
+ # Display peak total
659
+ peak_total = peak_data.get("total", {}).get("value", 0)
660
+ peak_time = peak_data.get("total", {}).get("time", "")
661
+
662
+ st.metric(f"Peak {load_type.title()} Load", f"{peak_total:,.0f} W")
663
+ st.write(f"Peak Time: {peak_time}")
664
+
665
+ # Add download button for CSV
666
+ peak_df = pd.DataFrame({
667
+ "Component": list(peak_data.keys()),
668
+ "Peak Load (W)": [data.get("value", 0) for data in peak_data.values()],
669
+ "Time": [data.get("time", "") for data in peak_data.values()]
670
+ })
671
+
672
+ csv = peak_df.to_csv(index=False).encode('utf-8')
673
+ st.download_button(
674
+ label=f"Download Peak {load_type.title()} Loads as CSV",
675
+ data=csv,
676
+ file_name=f"peak_{load_type}_loads.csv",
677
+ mime="text/csv"
678
+ )
679
+
680
+ with tab5:
681
+ st.subheader("Heat Maps")
682
+
683
+ # Add load type selector
684
+ load_type = st.radio(
685
+ "Select Load Type",
686
+ ["cooling", "heating"],
687
+ horizontal=True,
688
+ key="heat_map_type"
689
+ )
690
+
691
+ # Add month selector
692
+ month = st.selectbox(
693
+ "Select Month",
694
+ list(calendar.month_name)[1:],
695
+ index=6, # July
696
+ key="heat_map_month"
697
+ )
698
+
699
+ # Generate heat map data
700
+ month_num = list(calendar.month_name).index(month)
701
+ year = datetime.now().year
702
+ num_days = calendar.monthrange(year, month_num)[1]
703
+
704
+ # Get appropriate hourly data
705
+ if load_type == "cooling":
706
+ hourly_data = cooling_loads.get("hourly", {}).get("total", [])
707
+ else:
708
+ hourly_data = heating_loads.get("hourly", {}).get("total", [])
709
+
710
+ # Create 2D array for heat map
711
+ heat_map_data = []
712
+ for day in range(1, num_days + 1):
713
+ # Generate hourly data with day-to-day variation
714
+ day_factor = 1 + 0.2 * math.sin(day * math.pi / 15)
715
+ day_data = [load * day_factor for load in hourly_data]
716
+ heat_map_data.append(day_data)
717
+
718
+ # Create hour and day labels
719
+ hour_labels = list(range(24))
720
+ day_labels = list(range(1, num_days + 1))
721
+
722
+ # Create and display heat map
723
+ title = f"{load_type.title()} Load Heat Map ({month})"
724
+ colorscale = "Hot" if load_type == "cooling" else "Ice"
725
+
726
+ fig = TimeBasedVisualization.create_heat_map(heat_map_data, hour_labels, day_labels, title, colorscale)
727
+ st.plotly_chart(fig, use_container_width=True)
728
+
729
+ # Add explanation
730
+ st.info(
731
+ "The heat map shows the hourly load pattern for each day of the selected month. "
732
+ "Darker colors indicate higher loads. This visualization helps identify peak load periods "
733
+ "and daily/weekly patterns."
734
+ )
735
+
736
+
737
+ # Create a singleton instance
738
+ time_based_visualization = TimeBasedVisualization()
739
+
740
+ # Example usage
741
+ if __name__ == "__main__":
742
+ import streamlit as st
743
+
744
+ # Display time-based visualization with sample data
745
+ time_based_visualization.display_time_based_visualization()
utils/u_value_calculator.py ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ U-Value calculator module for HVAC Load Calculator.
3
+ This module implements the layer-by-layer assembly builder and U-value calculation functions.
4
+ """
5
+
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+ import pandas as pd
8
+ import numpy as np
9
+ import os
10
+ import json
11
+ from dataclasses import dataclass, field
12
+
13
+ # Import data models
14
+ from data.building_components import MaterialLayer
15
+ from data.reference_data import reference_data
16
+
17
+ # Define paths
18
+ DATA_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
+
20
+
21
+ @dataclass
22
+ class MaterialAssembly:
23
+ """Class representing a material assembly for U-value calculation."""
24
+
25
+ name: str
26
+ description: str = ""
27
+ layers: List[MaterialLayer] = field(default_factory=list)
28
+
29
+ # Surface resistances (m²·K/W)
30
+ r_si: float = 0.13 # Interior surface resistance
31
+ r_se: float = 0.04 # Exterior surface resistance
32
+
33
+ def add_layer(self, layer: MaterialLayer) -> None:
34
+ """
35
+ Add a material layer to the assembly.
36
+
37
+ Args:
38
+ layer: MaterialLayer object
39
+ """
40
+ self.layers.append(layer)
41
+
42
+ def remove_layer(self, index: int) -> bool:
43
+ """
44
+ Remove a material layer from the assembly.
45
+
46
+ Args:
47
+ index: Index of the layer to remove
48
+
49
+ Returns:
50
+ True if the layer was removed, False otherwise
51
+ """
52
+ if index < 0 or index >= len(self.layers):
53
+ return False
54
+
55
+ self.layers.pop(index)
56
+ return True
57
+
58
+ def move_layer(self, from_index: int, to_index: int) -> bool:
59
+ """
60
+ Move a material layer within the assembly.
61
+
62
+ Args:
63
+ from_index: Current index of the layer
64
+ to_index: New index for the layer
65
+
66
+ Returns:
67
+ True if the layer was moved, False otherwise
68
+ """
69
+ if (from_index < 0 or from_index >= len(self.layers) or
70
+ to_index < 0 or to_index >= len(self.layers)):
71
+ return False
72
+
73
+ layer = self.layers.pop(from_index)
74
+ self.layers.insert(to_index, layer)
75
+ return True
76
+
77
+ @property
78
+ def total_thickness(self) -> float:
79
+ """Calculate the total thickness of the assembly in meters."""
80
+ return sum(layer.thickness for layer in self.layers)
81
+
82
+ @property
83
+ def r_value_layers(self) -> float:
84
+ """Calculate the total thermal resistance of all layers in m²·K/W."""
85
+ return sum(layer.r_value for layer in self.layers)
86
+
87
+ @property
88
+ def r_value_total(self) -> float:
89
+ """Calculate the total thermal resistance including surface resistances in m²·K/W."""
90
+ return self.r_si + self.r_value_layers + self.r_se
91
+
92
+ @property
93
+ def u_value(self) -> float:
94
+ """Calculate the U-value of the assembly in W/(m²·K)."""
95
+ if self.r_value_total == 0:
96
+ return float('inf')
97
+ return 1 / self.r_value_total
98
+
99
+ @property
100
+ def thermal_mass(self) -> Optional[float]:
101
+ """Calculate the total thermal mass of the assembly in J/(m²·K)."""
102
+ masses = [layer.thermal_mass for layer in self.layers]
103
+ if None in masses:
104
+ return None
105
+ return sum(masses)
106
+
107
+ def to_dict(self) -> Dict[str, Any]:
108
+ """Convert the material assembly to a dictionary."""
109
+ return {
110
+ "name": self.name,
111
+ "description": self.description,
112
+ "layers": [layer.to_dict() for layer in self.layers],
113
+ "r_si": self.r_si,
114
+ "r_se": self.r_se,
115
+ "total_thickness": self.total_thickness,
116
+ "r_value_layers": self.r_value_layers,
117
+ "r_value_total": self.r_value_total,
118
+ "u_value": self.u_value,
119
+ "thermal_mass": self.thermal_mass
120
+ }
121
+
122
+
123
+ class UValueCalculator:
124
+ """Class for calculating U-values of material assemblies."""
125
+
126
+ def __init__(self):
127
+ """Initialize U-value calculator."""
128
+ self.assemblies = {}
129
+ self.load_preset_assemblies()
130
+
131
+ def load_preset_assemblies(self) -> None:
132
+ """Load preset material assemblies."""
133
+ # Create preset assemblies from reference data
134
+
135
+ # Wall assemblies
136
+ for wall_id, wall_data in reference_data.wall_types.items():
137
+ # Create material layers
138
+ layers = []
139
+ for layer_data in wall_data.get("layers", []):
140
+ material_id = layer_data.get("material")
141
+ thickness = layer_data.get("thickness")
142
+
143
+ material = reference_data.get_material(material_id)
144
+ if material:
145
+ layer = MaterialLayer(
146
+ name=material["name"],
147
+ thickness=thickness,
148
+ conductivity=material["conductivity"],
149
+ density=material.get("density"),
150
+ specific_heat=material.get("specific_heat")
151
+ )
152
+ layers.append(layer)
153
+
154
+ # Create assembly
155
+ assembly_id = f"preset_wall_{wall_id}"
156
+ assembly = MaterialAssembly(
157
+ name=wall_data["name"],
158
+ description=wall_data["description"],
159
+ layers=layers
160
+ )
161
+
162
+ self.assemblies[assembly_id] = assembly
163
+
164
+ # Roof assemblies
165
+ for roof_id, roof_data in reference_data.roof_types.items():
166
+ # Create material layers
167
+ layers = []
168
+ for layer_data in roof_data.get("layers", []):
169
+ material_id = layer_data.get("material")
170
+ thickness = layer_data.get("thickness")
171
+
172
+ material = reference_data.get_material(material_id)
173
+ if material:
174
+ layer = MaterialLayer(
175
+ name=material["name"],
176
+ thickness=thickness,
177
+ conductivity=material["conductivity"],
178
+ density=material.get("density"),
179
+ specific_heat=material.get("specific_heat")
180
+ )
181
+ layers.append(layer)
182
+
183
+ # Create assembly
184
+ assembly_id = f"preset_roof_{roof_id}"
185
+ assembly = MaterialAssembly(
186
+ name=roof_data["name"],
187
+ description=roof_data["description"],
188
+ layers=layers
189
+ )
190
+
191
+ self.assemblies[assembly_id] = assembly
192
+
193
+ # Floor assemblies
194
+ for floor_id, floor_data in reference_data.floor_types.items():
195
+ # Create material layers
196
+ layers = []
197
+ for layer_data in floor_data.get("layers", []):
198
+ material_id = layer_data.get("material")
199
+ thickness = layer_data.get("thickness")
200
+
201
+ material = reference_data.get_material(material_id)
202
+ if material:
203
+ layer = MaterialLayer(
204
+ name=material["name"],
205
+ thickness=thickness,
206
+ conductivity=material["conductivity"],
207
+ density=material.get("density"),
208
+ specific_heat=material.get("specific_heat")
209
+ )
210
+ layers.append(layer)
211
+
212
+ # Create assembly
213
+ assembly_id = f"preset_floor_{floor_id}"
214
+ assembly = MaterialAssembly(
215
+ name=floor_data["name"],
216
+ description=floor_data["description"],
217
+ layers=layers
218
+ )
219
+
220
+ self.assemblies[assembly_id] = assembly
221
+
222
+ def get_assembly(self, assembly_id: str) -> Optional[MaterialAssembly]:
223
+ """
224
+ Get a material assembly by ID.
225
+
226
+ Args:
227
+ assembly_id: Assembly identifier
228
+
229
+ Returns:
230
+ MaterialAssembly object or None if not found
231
+ """
232
+ return self.assemblies.get(assembly_id)
233
+
234
+ def get_preset_assemblies(self) -> Dict[str, MaterialAssembly]:
235
+ """
236
+ Get all preset material assemblies.
237
+
238
+ Returns:
239
+ Dictionary of preset MaterialAssembly objects
240
+ """
241
+ return {assembly_id: assembly for assembly_id, assembly in self.assemblies.items()
242
+ if assembly_id.startswith("preset_")}
243
+
244
+ def get_custom_assemblies(self) -> Dict[str, MaterialAssembly]:
245
+ """
246
+ Get all custom material assemblies.
247
+
248
+ Returns:
249
+ Dictionary of custom MaterialAssembly objects
250
+ """
251
+ return {assembly_id: assembly for assembly_id, assembly in self.assemblies.items()
252
+ if assembly_id.startswith("custom_")}
253
+
254
+ def create_assembly(self, name: str, description: str = "") -> str:
255
+ """
256
+ Create a new material assembly.
257
+
258
+ Args:
259
+ name: Assembly name
260
+ description: Assembly description
261
+
262
+ Returns:
263
+ Assembly ID
264
+ """
265
+ import uuid
266
+
267
+ assembly_id = f"custom_assembly_{str(uuid.uuid4())[:8]}"
268
+ assembly = MaterialAssembly(name=name, description=description)
269
+
270
+ self.assemblies[assembly_id] = assembly
271
+ return assembly_id
272
+
273
+ def add_layer_to_assembly(self, assembly_id: str, material_id: str, thickness: float) -> bool:
274
+ """
275
+ Add a material layer to an assembly.
276
+
277
+ Args:
278
+ assembly_id: Assembly identifier
279
+ material_id: Material identifier
280
+ thickness: Layer thickness in meters
281
+
282
+ Returns:
283
+ True if the layer was added, False otherwise
284
+ """
285
+ if assembly_id not in self.assemblies:
286
+ return False
287
+
288
+ material = reference_data.get_material(material_id)
289
+ if not material:
290
+ return False
291
+
292
+ layer = MaterialLayer(
293
+ name=material["name"],
294
+ thickness=thickness,
295
+ conductivity=material["conductivity"],
296
+ density=material.get("density"),
297
+ specific_heat=material.get("specific_heat")
298
+ )
299
+
300
+ self.assemblies[assembly_id].add_layer(layer)
301
+ return True
302
+
303
+ def add_custom_layer_to_assembly(self, assembly_id: str, name: str, thickness: float,
304
+ conductivity: float, density: float = None,
305
+ specific_heat: float = None) -> bool:
306
+ """
307
+ Add a custom material layer to an assembly.
308
+
309
+ Args:
310
+ assembly_id: Assembly identifier
311
+ name: Layer name
312
+ thickness: Layer thickness in meters
313
+ conductivity: Thermal conductivity in W/(m·K)
314
+ density: Density in kg/m³ (optional)
315
+ specific_heat: Specific heat capacity in J/(kg·K) (optional)
316
+
317
+ Returns:
318
+ True if the layer was added, False otherwise
319
+ """
320
+ if assembly_id not in self.assemblies:
321
+ return False
322
+
323
+ layer = MaterialLayer(
324
+ name=name,
325
+ thickness=thickness,
326
+ conductivity=conductivity,
327
+ density=density,
328
+ specific_heat=specific_heat
329
+ )
330
+
331
+ self.assemblies[assembly_id].add_layer(layer)
332
+ return True
333
+
334
+ def remove_layer_from_assembly(self, assembly_id: str, layer_index: int) -> bool:
335
+ """
336
+ Remove a material layer from an assembly.
337
+
338
+ Args:
339
+ assembly_id: Assembly identifier
340
+ layer_index: Index of the layer to remove
341
+
342
+ Returns:
343
+ True if the layer was removed, False otherwise
344
+ """
345
+ if assembly_id not in self.assemblies:
346
+ return False
347
+
348
+ return self.assemblies[assembly_id].remove_layer(layer_index)
349
+
350
+ def move_layer_in_assembly(self, assembly_id: str, from_index: int, to_index: int) -> bool:
351
+ """
352
+ Move a material layer within an assembly.
353
+
354
+ Args:
355
+ assembly_id: Assembly identifier
356
+ from_index: Current index of the layer
357
+ to_index: New index for the layer
358
+
359
+ Returns:
360
+ True if the layer was moved, False otherwise
361
+ """
362
+ if assembly_id not in self.assemblies:
363
+ return False
364
+
365
+ return self.assemblies[assembly_id].move_layer(from_index, to_index)
366
+
367
+ def calculate_u_value(self, assembly_id: str) -> Optional[float]:
368
+ """
369
+ Calculate the U-value of an assembly.
370
+
371
+ Args:
372
+ assembly_id: Assembly identifier
373
+
374
+ Returns:
375
+ U-value in W/(m²·K) or None if the assembly was not found
376
+ """
377
+ if assembly_id not in self.assemblies:
378
+ return None
379
+
380
+ return self.assemblies[assembly_id].u_value
381
+
382
+ def calculate_r_value(self, assembly_id: str) -> Optional[float]:
383
+ """
384
+ Calculate the R-value of an assembly.
385
+
386
+ Args:
387
+ assembly_id: Assembly identifier
388
+
389
+ Returns:
390
+ R-value in m²·K/W or None if the assembly was not found
391
+ """
392
+ if assembly_id not in self.assemblies:
393
+ return None
394
+
395
+ return self.assemblies[assembly_id].r_value_total
396
+
397
+ def export_to_json(self, file_path: str) -> None:
398
+ """
399
+ Export all assemblies to a JSON file.
400
+
401
+ Args:
402
+ file_path: Path to the output JSON file
403
+ """
404
+ data = {assembly_id: assembly.to_dict() for assembly_id, assembly in self.assemblies.items()}
405
+
406
+ with open(file_path, 'w') as f:
407
+ json.dump(data, f, indent=4)
408
+
409
+ def import_from_json(self, file_path: str) -> int:
410
+ """
411
+ Import assemblies from a JSON file.
412
+
413
+ Args:
414
+ file_path: Path to the input JSON file
415
+
416
+ Returns:
417
+ Number of assemblies imported
418
+ """
419
+ with open(file_path, 'r') as f:
420
+ data = json.load(f)
421
+
422
+ count = 0
423
+ for assembly_id, assembly_data in data.items():
424
+ try:
425
+ # Create assembly
426
+ assembly = MaterialAssembly(
427
+ name=assembly_data["name"],
428
+ description=assembly_data.get("description", ""),
429
+ r_si=assembly_data.get("r_si", 0.13),
430
+ r_se=assembly_data.get("r_se", 0.04)
431
+ )
432
+
433
+ # Add layers
434
+ for layer_data in assembly_data.get("layers", []):
435
+ layer = MaterialLayer(
436
+ name=layer_data["name"],
437
+ thickness=layer_data["thickness"],
438
+ conductivity=layer_data["conductivity"],
439
+ density=layer_data.get("density"),
440
+ specific_heat=layer_data.get("specific_heat")
441
+ )
442
+ assembly.add_layer(layer)
443
+
444
+ self.assemblies[assembly_id] = assembly
445
+ count += 1
446
+ except Exception as e:
447
+ print(f"Error importing assembly {assembly_id}: {e}")
448
+
449
+ return count
450
+
451
+
452
+ # Create a singleton instance
453
+ u_value_calculator = UValueCalculator()
454
+
455
+ # Export U-value calculator to JSON if needed
456
+ if __name__ == "__main__":
457
+ u_value_calculator.export_to_json(os.path.join(DATA_DIR, "data", "u_value_calculator.json"))