mabuseif commited on
Commit
39bd750
·
verified ·
1 Parent(s): a9c8feb

Upload 4 files

Browse files
data/calculation.py CHANGED
@@ -1,40 +1,295 @@
1
- import altair as alt
 
 
 
 
 
 
2
  import numpy as np
3
  import pandas as pd
4
- import streamlit as st
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
 
8
 
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
12
 
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Calculator Code Documentation
3
+
4
+ Developed by: Dr Majed Abuseif, Deakin University
5
+ © 2025
6
+ """
7
+
8
  import numpy as np
9
  import pandas as pd
10
+ from typing import Dict, List, Optional, NamedTuple
11
+ from enum import Enum
12
+ from data.material_library import Construction, GlazingMaterial, DoorMaterial
13
+ from data.internal_loads import PEOPLE_ACTIVITY_LEVELS, DIVERSITY_FACTORS, LIGHTING_FIXTURE_TYPES, EQUIPMENT_HEAT_GAINS, VENTILATION_RATES, INFILTRATION_SETTINGS
14
+ from datetime import datetime
15
+ from collections import defaultdict
16
+ import logging
17
+ from utils.ctf_calculations import CTFCalculator, ComponentType, CTFCoefficients
18
 
19
+ # Configure logging
20
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
21
+ logger = logging.getLogger(__name__)
22
 
23
+ class TFMCalculations:
24
+ @staticmethod
25
+ def calculate_conduction_load(component, outdoor_temp: float, indoor_temp: float, hour: int, mode: str = "none") -> tuple[float, float]:
26
+ """Calculate conduction load for heating and cooling in kW based on mode."""
27
+ if mode == "none":
28
+ return 0, 0
29
+ delta_t = outdoor_temp - indoor_temp
30
+ if mode == "cooling" and delta_t <= 0:
31
+ return 0, 0
32
+ if mode == "heating" and delta_t >= 0:
33
+ return 0, 0
34
 
35
+ # Get CTF coefficients using CTFCalculator
36
+ ctf = CTFCalculator.calculate_ctf_coefficients(component)
37
+
38
+ # Initialize history terms (simplified: assume steady-state history for demonstration)
39
+ # In practice, maintain temperature and flux histories
40
+ load = component.u_value * component.area * delta_t
41
+ for i in range(len(ctf.Y)):
42
+ load += component.area * ctf.Y[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
43
+ load -= component.area * ctf.Z[i] * (outdoor_temp - indoor_temp) * np.exp(-i * 3600 / 3600)
44
+ # Note: F terms require flux history, omitted here for simplicity
45
+ cooling_load = load / 1000 if mode == "cooling" else 0
46
+ heating_load = -load / 1000 if mode == "heating" else 0
47
+ return cooling_load, heating_load
48
+
49
+ @staticmethod
50
+ def calculate_solar_load(component, hourly_data: Dict, hour: int, building_orientation: float, mode: str = "none") -> float:
51
+ """Calculate solar load in kW (cooling only) based on mode."""
52
+ if mode != "cooling" or component.component_type not in [ComponentType.WINDOW, ComponentType.SKYLIGHT]:
53
+ return 0
54
+ solar_radiation = hourly_data.get("global_horizontal_radiation", 800)
55
+ shgc = component.shgc or 0.7
56
+ absolute_angle = (building_orientation + component.orientation_angle) % 360
57
+ solar_incidence = np.cos(np.radians(absolute_angle - hour % 24 * 15))
58
+ return component.area * shgc * solar_radiation * max(solar_incidence, 0) * 0.8 / 1000
59
+
60
+ @staticmethod
61
+ def calculate_internal_load(internal_loads: Dict, hour: int, operation_hours: int, area: float) -> float:
62
+ """Calculate total internal load in kW."""
63
+ total_load = 0
64
+ for group in internal_loads.get("people", []):
65
+ activity_data = group["activity_data"]
66
+ sensible = (activity_data["sensible_min_w"] + activity_data["sensible_max_w"]) / 2
67
+ latent = (activity_data["latent_min_w"] + activity_data["latent_max_w"]) / 2
68
+ load_per_person = sensible + latent
69
+ total_load += group["num_people"] * load_per_person * group["diversity_factor"]
70
+ for light in internal_loads.get("lighting", []):
71
+ lpd = light["lpd"]
72
+ lighting_operating_hours = light["operating_hours"]
73
+ fraction = min(lighting_operating_hours, operation_hours) / operation_hours if operation_hours > 0 else 0
74
+ lighting_load = lpd * area * fraction
75
+ total_load += lighting_load
76
+ equipment = internal_loads.get("equipment")
77
+ if equipment:
78
+ total_power_density = equipment.get("total_power_density", 0)
79
+ equipment_load = total_power_density * area
80
+ total_load += equipment_load
81
+ return total_load / 1000
82
+
83
+ @staticmethod
84
+ def calculate_ventilation_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
85
+ """Calculate ventilation load for heating and cooling in kW based on mode."""
86
+ if mode == "none":
87
+ return 0, 0
88
+ ventilation = internal_loads.get("ventilation")
89
+ if not ventilation:
90
+ return 0, 0
91
+ space_rate = ventilation.get("space_rate", 0.3) # L/s/m²
92
+ people_rate = ventilation.get("people_rate", 2.5) # L/s/person
93
+ num_people = sum(group["num_people"] for group in internal_loads.get("people", []))
94
+ ventilation_flow = (space_rate * area + people_rate * num_people) / 1000 # m³/s
95
+ air_density = 1.2 # kg/m³
96
+ specific_heat = 1000 # J/kg·K
97
+ delta_t = outdoor_temp - indoor_temp
98
+ if mode == "cooling" and delta_t <= 0:
99
+ return 0, 0
100
+ if mode == "heating" and delta_t >= 0:
101
+ return 0, 0
102
+ load = ventilation_flow * air_density * specific_heat * delta_t / 1000 # kW
103
+ cooling_load = load if mode == "cooling" else 0
104
+ heating_load = -load if mode == "heating" else 0
105
+ return cooling_load, heating_load
106
+
107
+ @staticmethod
108
+ def calculate_infiltration_load(internal_loads: Dict, outdoor_temp: float, indoor_temp: float, area: float, building_info: Dict, mode: str = "none") -> tuple[float, float]:
109
+ """Calculate infiltration load for heating and cooling in kW based on mode."""
110
+ if mode == "none":
111
+ return 0, 0
112
+ infiltration = internal_loads.get("infiltration")
113
+ if not infiltration:
114
+ return 0, 0
115
+ method = infiltration.get("method", "ACH")
116
+ settings = infiltration.get("settings", {})
117
+ building_height = building_info.get("building_height", 3.0)
118
+ volume = area * building_height # m³
119
+ air_density = 1.2 # kg/m³
120
+ specific_heat = 1000 # J/kg·K
121
+ delta_t = outdoor_temp - indoor_temp
122
+ if mode == "cooling" and delta_t <= 0:
123
+ return 0, 0
124
+ if mode == "heating" and delta_t >= 0:
125
+ return 0, 0
126
+ if method == "ACH":
127
+ ach = settings.get("rate", 0.5)
128
+ infiltration_flow = ach * volume / 3600 # m³/s
129
+ elif method == "Crack Flow":
130
+ ela = settings.get("ela", 0.0001) # m²/m²
131
+ wind_speed = 4.0 # m/s (assumed)
132
+ infiltration_flow = ela * area * wind_speed / 2 # m³/s
133
+ else: # Empirical Equations
134
+ c = settings.get("c", 0.1)
135
+ n = settings.get("n", 0.65)
136
+ delta_t_abs = abs(delta_t)
137
+ infiltration_flow = c * (delta_t_abs ** n) * area / 3600 # m³/s
138
+ load = infiltration_flow * air_density * specific_heat * delta_t / 1000 # kW
139
+ cooling_load = load if mode == "cooling" else 0
140
+ heating_load = -load if mode == "heating" else 0
141
+ return cooling_load, heating_load
142
+
143
+ @staticmethod
144
+ def get_adaptive_comfort_temp(outdoor_temp: float) -> float:
145
+ """Calculate adaptive comfort temperature per ASHRAE 55."""
146
+ if 10 <= outdoor_temp <= 33.5:
147
+ return 0.31 * outdoor_temp + 17.8
148
+ return 24.0 # Default to standard setpoint if outside range
149
+
150
+ @staticmethod
151
+ def filter_hourly_data(hourly_data: List[Dict], sim_period: Dict, climate_data: Dict) -> List[Dict]:
152
+ """Filter hourly data based on simulation period, ignoring year."""
153
+ if sim_period["type"] == "Full Year":
154
+ return hourly_data
155
+ filtered_data = []
156
+ if sim_period["type"] == "From-to":
157
+ start_month = sim_period["start_date"].month
158
+ start_day = sim_period["start_date"].day
159
+ end_month = sim_period["end_date"].month
160
+ end_day = sim_period["end_date"].day
161
+ for data in hourly_data:
162
+ month, day = data["month"], data["day"]
163
+ if (month > start_month or (month == start_month and day >= start_day)) and \
164
+ (month < end_month or (month == end_month and day <= end_day)):
165
+ filtered_data.append(data)
166
+ elif sim_period["type"] in ["HDD", "CDD"]:
167
+ base_temp = sim_period.get("base_temp", 18.3 if sim_period["type"] == "HDD" else 23.9)
168
+ for data in hourly_data:
169
+ temp = data["dry_bulb"]
170
+ if (sim_period["type"] == "HDD" and temp < base_temp) or (sim_period["type"] == "CDD" and temp > base_temp):
171
+ filtered_data.append(data)
172
+ return filtered_data
173
+
174
+ @staticmethod
175
+ def get_indoor_conditions(indoor_conditions: Dict, hour: int, outdoor_temp: float) -> Dict:
176
+ """Determine indoor conditions based on user settings."""
177
+ if indoor_conditions["type"] == "Fixed":
178
+ mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
179
+ if mode == "cooling":
180
+ return {
181
+ "temperature": indoor_conditions.get("cooling_setpoint", {}).get("temperature", 24.0),
182
+ "rh": indoor_conditions.get("cooling_setpoint", {}).get("rh", 50.0)
183
+ }
184
+ elif mode == "heating":
185
+ return {
186
+ "temperature": indoor_conditions.get("heating_setpoint", {}).get("temperature", 22.0),
187
+ "rh": indoor_conditions.get("heating_setpoint", {}).get("rh", 50.0)
188
+ }
189
+ else:
190
+ return {"temperature": 24.0, "rh": 50.0}
191
+ elif indoor_conditions["type"] == "Time-varying":
192
+ schedule = indoor_conditions.get("schedule", [])
193
+ if schedule:
194
+ hour_idx = hour % 24
195
+ for entry in schedule:
196
+ if entry["hour"] == hour_idx:
197
+ return {"temperature": entry["temperature"], "rh": entry["rh"]}
198
+ return {"temperature": 24.0, "rh": 50.0}
199
+ else: # Adaptive
200
+ return {"temperature": TFMCalculations.get_adaptive_comfort_temp(outdoor_temp), "rh": 50.0}
201
+
202
+ @staticmethod
203
+ def calculate_tfm_loads(components: Dict, hourly_data: List[Dict], indoor_conditions: Dict, internal_loads: Dict, building_info: Dict, sim_period: Dict, hvac_settings: Dict) -> List[Dict]:
204
+ """Calculate TFM loads for heating and cooling with user-defined filters and temperature threshold."""
205
+ filtered_data = TFMCalculations.filter_hourly_data(hourly_data, sim_period, building_info)
206
+ temp_loads = []
207
+ building_orientation = building_info.get("orientation_angle", 0.0)
208
+ operating_periods = hvac_settings.get("operating_hours", [{"start": 8, "end": 18}])
209
+ area = building_info.get("floor_area", 100.0)
210
+
211
+ # Pre-calculate CTF coefficients for all components using CTFCalculator
212
+ for comp_list in components.values():
213
+ for comp in comp_list:
214
+ comp.ctf = CTFCalculator.calculate_ctf_coefficients(comp)
215
 
216
+ for hour_data in filtered_data:
217
+ hour = hour_data["hour"]
218
+ outdoor_temp = hour_data["dry_bulb"]
219
+ indoor_cond = TFMCalculations.get_indoor_conditions(indoor_conditions, hour, outdoor_temp)
220
+ indoor_temp = indoor_cond["temperature"]
221
+ # Initialize all loads to 0
222
+ conduction_cooling = conduction_heating = solar = internal = ventilation_cooling = ventilation_heating = infiltration_cooling = infiltration_heating = 0
223
+ # Check if hour is within operating periods
224
+ is_operating = False
225
+ for period in operating_periods:
226
+ start_hour = period.get("start", 8)
227
+ end_hour = period.get("end", 18)
228
+ if start_hour <= hour % 24 <= end_hour:
229
+ is_operating = True
230
+ break
231
+ # Determine mode based on temperature threshold (18°C)
232
+ mode = "none" if abs(outdoor_temp - 18) < 0.01 else "cooling" if outdoor_temp > 18 else "heating"
233
+ if is_operating and mode == "cooling":
234
+ for comp_list in components.values():
235
+ for comp in comp_list:
236
+ cool_load, _ = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="cooling")
237
+ conduction_cooling += cool_load
238
+ solar += TFMCalculations.calculate_solar_load(comp, hour_data, hour, building_orientation, mode="cooling")
239
+ internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
240
+ ventilation_cooling, _ = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
241
+ infiltration_cooling, _ = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="cooling")
242
+ elif is_operating and mode == "heating":
243
+ for comp_list in components.values():
244
+ for comp in comp_list:
245
+ _, heat_load = TFMCalculations.calculate_conduction_load(comp, outdoor_temp, indoor_temp, hour, mode="heating")
246
+ conduction_heating += heat_load
247
+ internal = TFMCalculations.calculate_internal_load(internal_loads, hour, max([p["end"] - p["start"] for p in operating_periods]), area)
248
+ _, ventilation_heating = TFMCalculations.calculate_ventilation_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
249
+ _, infiltration_heating = TFMCalculations.calculate_infiltration_load(internal_loads, outdoor_temp, indoor_temp, area, building_info, mode="heating")
250
+ else: # mode == "none" or not is_operating
251
+ internal = 0 # No internal loads when no heating or cooling is needed or outside operating hours
252
+ # Calculate total loads, subtracting internal load for heating
253
+ total_cooling = conduction_cooling + solar + internal + ventilation_cooling + infiltration_cooling
254
+ total_heating = max(conduction_heating + ventilation_heating + infiltration_heating - internal, 0)
255
+ # Enforce mutual exclusivity within hour
256
+ if mode == "cooling":
257
+ total_heating = 0
258
+ elif mode == "heating":
259
+ total_cooling = 0
260
+ temp_loads.append({
261
+ "hour": hour,
262
+ "month": hour_data["month"],
263
+ "day": hour_data["day"],
264
+ "conduction_cooling": conduction_cooling,
265
+ "conduction_heating": conduction_heating,
266
+ "solar": solar,
267
+ "internal": internal,
268
+ "ventilation_cooling": ventilation_cooling,
269
+ "ventilation_heating": ventilation_heating,
270
+ "infiltration_cooling": infiltration_cooling,
271
+ "infiltration_heating": infiltration_heating,
272
+ "total_cooling": total_cooling,
273
+ "total_heating": total_heating
274
+ })
275
+ # Group loads by day and apply daily control
276
+ loads_by_day = defaultdict(list)
277
+ for load in temp_loads:
278
+ day_key = (load["month"], load["day"])
279
+ loads_by_day[day_key].append(load)
280
+ final_loads = []
281
+ for day_key, day_loads in loads_by_day.items():
282
+ # Count hours with non-zero cooling and heating loads
283
+ cooling_hours = sum(1 for load in day_loads if load["total_cooling"] > 0)
284
+ heating_hours = sum(1 for load in day_loads if load["total_heating"] > 0)
285
+ # Apply daily control
286
+ for load in day_loads:
287
+ if cooling_hours > heating_hours:
288
+ load["total_heating"] = 0 # Keep cooling components, zero heating total
289
+ elif heating_hours > cooling_hours:
290
+ load["total_cooling"] = 0 # Keep heating components, zero cooling total
291
+ else: # Equal hours
292
+ load["total_cooling"] = 0
293
+ load["total_heating"] = 0 # Zero both totals, keep components
294
+ final_loads.append(load)
295
+ return final_loads
data/climate_data.py ADDED
@@ -0,0 +1,859 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Extracts climate data from EPW files
3
+ Includes Solar Analysis tab for solar angle and ground-reflected radiation calculations.
4
+
5
+ Author: Dr Majed Abuseif
6
+ Date: May 2025
7
+ Version: 2.1.6
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
+ import pvlib
20
+ from datetime import datetime, timedelta
21
+ import re
22
+ import logging
23
+ from data.solar_calculations import SolarCalculations
24
+
25
+ # Set up logging
26
+ logging.basicConfig(level=logging.INFO)
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Define paths
30
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
31
+
32
+ # CSS for consistent formatting
33
+ STYLE = """
34
+ <style>
35
+ .markdown-text {
36
+ font-family: Roboto, sans-serif;
37
+ font-size: 14px;
38
+ line-height: 1.5;
39
+ margin-bottom: 20px;
40
+ }
41
+ .markdown-text h3 {
42
+ font-size: 18px;
43
+ font-weight: bold;
44
+ margin-top: 20px;
45
+ margin-bottom: 10px;
46
+ }
47
+ .markdown-text ul {
48
+ list-style-type: disc;
49
+ padding-left: 20px;
50
+ margin: 0;
51
+ }
52
+ .markdown-text li {
53
+ margin-bottom: 8px;
54
+ }
55
+ .markdown-text strong {
56
+ font-weight: bold;
57
+ }
58
+ .two-column {
59
+ display: grid;
60
+ grid-template-columns: 1fr 1fr;
61
+ gap: 20px;
62
+ }
63
+ .column {
64
+ width: 100%;
65
+ }
66
+ </style>
67
+ """
68
+
69
+ @dataclass
70
+ class ClimateLocation:
71
+ """Class representing a climate location with ASHRAE 169 data derived from EPW files."""
72
+
73
+ id: str
74
+ country: str
75
+ state_province: str
76
+ city: str
77
+ latitude: float
78
+ longitude: float
79
+ elevation: float # meters
80
+ timezone: float # hours from UTC
81
+ climate_zone: str
82
+ heating_degree_days: float # base 18°C
83
+ cooling_degree_days: float # base 18°C
84
+ winter_design_temp: float # 99.6% heating design temperature (°C)
85
+ summer_design_temp_db: float # 0.4% cooling design dry-bulb temperature (°C)
86
+ summer_design_temp_wb: float # 0.4% cooling design wet-bulb temperature (°C)
87
+ summer_daily_range: float # Mean daily temperature range in summer (°C)
88
+ wind_speed: float # Mean wind speed (m/s)
89
+ pressure: float # Mean atmospheric pressure (Pa)
90
+ hourly_data: List[Dict] # Hourly data for integration with main.py
91
+ typical_extreme_periods: Dict[str, Dict] # Typical/extreme periods (summer/winter)
92
+ ground_temperatures: Dict[str, List[float]] # Monthly ground temperatures by depth
93
+ solar_calculations: List[Dict] = None # Solar calculation results
94
+
95
+ def __init__(self, epw_file: pd.DataFrame, typical_extreme_periods: Dict, ground_temperatures: Dict, **kwargs):
96
+ """Initialize ClimateLocation with EPW file data and header information."""
97
+ self.id = kwargs.get("id")
98
+ self.country = kwargs.get("country")
99
+ self.state_province = kwargs.get("state_province", "N/A")
100
+ self.city = kwargs.get("city")
101
+ self.latitude = kwargs.get("latitude")
102
+ self.longitude = kwargs.get("longitude")
103
+ self.elevation = kwargs.get("elevation")
104
+ self.timezone = kwargs.get("timezone")
105
+ self.typical_extreme_periods = typical_extreme_periods
106
+ self.ground_temperatures = ground_temperatures
107
+ self.solar_calculations = kwargs.get("solar_calculations", [])
108
+
109
+ # Extract columns from EPW data
110
+ months = pd.to_numeric(epw_file[1], errors='coerce').values
111
+ days = pd.to_numeric(epw_file[2], errors='coerce').values
112
+ hours = pd.to_numeric(epw_file[3], errors='coerce').values
113
+ dry_bulb = pd.to_numeric(epw_file[6], errors='coerce').values
114
+ humidity = pd.to_numeric(epw_file[8], errors='coerce').values
115
+ pressure = pd.to_numeric(epw_file[9], errors='coerce').values
116
+ global_radiation = pd.to_numeric(epw_file[13], errors='coerce').values
117
+ direct_normal_radiation = pd.to_numeric(epw_file[14], errors='coerce').values
118
+ diffuse_horizontal_radiation = pd.to_numeric(epw_file[15], errors='coerce').values
119
+ wind_direction = pd.to_numeric(epw_file[20], errors='coerce').values
120
+ wind_speed = pd.to_numeric(epw_file[21], errors='coerce')
121
+
122
+ # Filter wind speed outliers and log high values
123
+ wind_speed = wind_speed[wind_speed <= 50] # Remove extreme outliers
124
+ if (wind_speed > 15).any():
125
+ logger.warning(f"High wind speeds detected: {wind_speed[wind_speed > 15].tolist()}")
126
+
127
+ # Calculate wet-bulb temperature
128
+ wet_bulb = ClimateData.calculate_wet_bulb(dry_bulb, humidity)
129
+
130
+ # Calculate design conditions
131
+ self.winter_design_temp = round(np.nanpercentile(dry_bulb, 0.4), 1)
132
+ self.summer_design_temp_db = round(np.nanpercentile(dry_bulb, 99.6), 1)
133
+ self.summer_design_temp_wb = round(np.nanpercentile(wet_bulb, 99.6), 1)
134
+
135
+ # Calculate degree days using (T_max + T_min)/2
136
+ daily_temps = dry_bulb.reshape(-1, 24)
137
+ daily_max = np.nanmax(daily_temps, axis=1)
138
+ daily_min = np.nanmin(daily_temps, axis=1)
139
+ daily_avg = (daily_max + daily_min) / 2
140
+ self.heating_degree_days = round(np.nansum(np.where(daily_avg < 18, 18 - daily_avg, 0)))
141
+ self.cooling_degree_days = round(np.nansum(np.where(daily_avg > 18, daily_avg - 18, 0)))
142
+
143
+ # Calculate summer daily temperature range (June–August, Southern Hemisphere)
144
+ summer_mask = (months >= 6) & (months <= 8)
145
+ summer_temps = dry_bulb[summer_mask].reshape(-1, 24)
146
+ self.summer_daily_range = round(np.nanmean(np.nanmax(summer_temps, axis=1) - np.nanmin(summer_temps, axis=1)), 1)
147
+
148
+ # Calculate mean wind speed and pressure
149
+ self.wind_speed = round(np.nanmean(wind_speed), 1)
150
+ self.pressure = round(np.nanmean(pressure), 1)
151
+
152
+ # Log wind speed diagnostics
153
+ logger.info(f"Wind speed stats: min={wind_speed.min():.1f}, max={wind_speed.max():.1f}, mean={self.wind_speed:.1f}")
154
+
155
+ # Assign climate zone
156
+ self.climate_zone = ClimateData.assign_climate_zone(self.heating_degree_days, self.cooling_degree_days, np.nanmean(humidity))
157
+
158
+ # Store hourly data with enhanced fields
159
+ self.hourly_data = []
160
+ for i in range(len(months)):
161
+ if np.isnan(months[i]) or np.isnan(days[i]) or np.isnan(hours[i]) or np.isnan(dry_bulb[i]):
162
+ continue # Skip records with missing critical fields
163
+ record = {
164
+ "month": int(months[i]),
165
+ "day": int(days[i]),
166
+ "hour": int(hours[i]),
167
+ "dry_bulb": float(dry_bulb[i]),
168
+ "relative_humidity": float(humidity[i]) if not np.isnan(humidity[i]) else 0.0,
169
+ "atmospheric_pressure": float(pressure[i]) if not np.isnan(pressure[i]) else self.pressure,
170
+ "global_horizontal_radiation": float(global_radiation[i]) if not np.isnan(global_radiation[i]) else 0.0,
171
+ "direct_normal_radiation": float(direct_normal_radiation[i]) if not np.isnan(direct_normal_radiation[i]) else 0.0,
172
+ "diffuse_horizontal_radiation": float(diffuse_horizontal_radiation[i]) if not np.isnan(diffuse_horizontal_radiation[i]) else 0.0,
173
+ "wind_speed": float(wind_speed[i]) if not np.isnan(wind_speed[i]) else 0.0,
174
+ "wind_direction": float(wind_direction[i]) if not np.isnan(wind_direction[i]) else 0.0
175
+ }
176
+ self.hourly_data.append(record)
177
+
178
+ if len(self.hourly_data) != 8760:
179
+ st.warning(f"Hourly data has {len(self.hourly_data)} records instead of 8760. Some records may have been excluded due to missing data.")
180
+
181
+ def to_dict(self) -> Dict[str, Any]:
182
+ """Convert the climate location to a dictionary."""
183
+ return {
184
+ "id": self.id,
185
+ "country": self.country,
186
+ "state_province": self.state_province,
187
+ "city": self.city,
188
+ "latitude": self.latitude,
189
+ "longitude": self.longitude,
190
+ "elevation": self.elevation,
191
+ "timezone": self.timezone,
192
+ "climate_zone": self.climate_zone,
193
+ "heating_degree_days": self.heating_degree_days,
194
+ "cooling_degree_days": self.cooling_degree_days,
195
+ "winter_design_temp": self.winter_design_temp,
196
+ "summer_design_temp_db": self.summer_design_temp_db,
197
+ "summer_design_temp_wb": self.summer_design_temp_wb,
198
+ "summer_daily_range": self.summer_daily_range,
199
+ "wind_speed": self.wind_speed,
200
+ "pressure": self.pressure,
201
+ "hourly_data": self.hourly_data,
202
+ "typical_extreme_periods": self.typical_extreme_periods,
203
+ "ground_temperatures": self.ground_temperatures,
204
+ "solar_calculations": self.solar_calculations
205
+ }
206
+
207
+ class ClimateData:
208
+ """Class for managing ASHRAE 169 climate data from EPW files."""
209
+
210
+ def __init__(self):
211
+ """Initialize climate data."""
212
+ self.locations = {}
213
+ self.countries = []
214
+ self.country_states = {}
215
+
216
+ def add_location(self, location: ClimateLocation):
217
+ """Add a new location to the dictionary."""
218
+ self.locations[location.id] = location
219
+ self.countries = sorted(list(set(loc.country for loc in self.locations.values())))
220
+ self.country_states = self._group_locations_by_country_state()
221
+
222
+ def _group_locations_by_country_state(self) -> Dict[str, Dict[str, List[str]]]:
223
+ """Group locations by country and state/province."""
224
+ result = {}
225
+ for loc in self.locations.values():
226
+ if loc.country not in result:
227
+ result[loc.country] = {}
228
+ if loc.state_province not in result[loc.country]:
229
+ result[loc.country][loc.state_province] = []
230
+ result[loc.country][loc.state_province].append(loc.city)
231
+ for country in result:
232
+ for state in result[country]:
233
+ result[country][state] = sorted(result[country][state])
234
+ return result
235
+
236
+ def get_location_by_id(self, location_id: str, session_state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
237
+ """Retrieve climate data by ID from session state or locations."""
238
+ if "climate_data" in session_state and session_state["climate_data"].get("id") == location_id:
239
+ return session_state["climate_data"]
240
+ if location_id in self.locations:
241
+ return self.locations[location_id].to_dict()
242
+ return None
243
+
244
+ @staticmethod
245
+ def validate_climate_data(data: Dict[str, Any]) -> bool:
246
+ """Validate climate data for required fields and ranges."""
247
+ required_fields = [
248
+ "id", "country", "city", "latitude", "longitude", "elevation", "timezone",
249
+ "climate_zone", "heating_degree_days", "cooling_degree_days",
250
+ "winter_design_temp", "summer_design_temp_db", "summer_design_temp_wb",
251
+ "summer_daily_range", "wind_speed", "pressure", "hourly_data"
252
+ ]
253
+
254
+ for field in required_fields:
255
+ if field not in data:
256
+ st.error(f"Validation failed: Missing required field '{field}'")
257
+ return False
258
+
259
+ if not (-90 <= data["latitude"] <= 90 and -180 <= data["longitude"] <= 180):
260
+ st.error("Validation failed: Invalid latitude or longitude")
261
+ return False
262
+ if data["elevation"] < 0:
263
+ st.error("Validation failed: Negative elevation")
264
+ return False
265
+ if not (-24 <= data["timezone"] <= 24):
266
+ st.error(f"Validation failed: Timezone {data['timezone']} outside range")
267
+ return False
268
+ 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"]:
269
+ st.error(f"Validation failed: Invalid climate zone '{data['climate_zone']}'")
270
+ return False
271
+ if not (data["heating_degree_days"] >= 0 and data["cooling_degree_days"] >= 0):
272
+ st.error("Validation failed: Negative degree days")
273
+ return False
274
+ if not (-50 <= data["winter_design_temp"] <= 20):
275
+ st.error(f"Validation failed: Winter design temp {data['winter_design_temp']} outside range")
276
+ return False
277
+ if not (0 <= data["summer_design_temp_db"] <= 50 and 0 <= data["summer_design_temp_wb"] <= 40):
278
+ st.error("Validation failed: Invalid summer design temperatures")
279
+ return False
280
+ if data["summer_daily_range"] < 0:
281
+ st.error("Validation failed: Negative summer daily range")
282
+ return False
283
+ if not (0 <= data["wind_speed"] <= 30):
284
+ st.error(f"Validation failed: Wind speed {data['wind_speed']} outside range")
285
+ return False
286
+ if not (80000 <= data["pressure"] <= 110000):
287
+ st.error(f"Validation failed: Pressure {data['pressure']} outside range")
288
+ return False
289
+
290
+ if not data["hourly_data"] or len(data["hourly_data"]) < 8700:
291
+ st.error(f"Validation failed: Hourly data has {len(data['hourly_data'])} records, expected ~8760")
292
+ return False
293
+ for record in data["hourly_data"]:
294
+ if not (1 <= record["month"] <= 12):
295
+ st.error(f"Validation failed: Invalid month {record['month']}")
296
+ return False
297
+ if not (1 <= record["day"] <= 31):
298
+ st.error(f"Validation failed: Invalid day {record['day']}")
299
+ return False
300
+ if not (1 <= record["hour"] <= 24):
301
+ st.error(f"Validation failed: Invalid hour {record['hour']}")
302
+ return False
303
+ if not (-50 <= record["dry_bulb"] <= 50):
304
+ st.error(f"Validation failed: Dry bulb {record['dry_bulb']} outside range")
305
+ return False
306
+ if not (0 <= record["relative_humidity"] <= 100):
307
+ st.error(f"Validation failed: Relative humidity {record['relative_humidity']} outside range")
308
+ return False
309
+ if not (80000 <= record["atmospheric_pressure"] <= 110000):
310
+ st.error(f"Validation failed: Atmospheric pressure {record['atmospheric_pressure']} outside range")
311
+ return False
312
+ if not (0 <= record["global_horizontal_radiation"] <= 1200):
313
+ st.error(f"Validation failed: Global radiation {record['global_horizontal_radiation']} outside range")
314
+ return False
315
+ if not (0 <= record["direct_normal_radiation"] <= 1200):
316
+ st.error(f"Validation failed: Direct normal radiation {record['direct_normal_radiation']} outside range")
317
+ return False
318
+ if not (0 <= record["diffuse_horizontal_radiation"] <= 1200):
319
+ st.error(f"Validation failed: Diffuse horizontal radiation {record['diffuse_horizontal_radiation']} outside range")
320
+ return False
321
+ if not (0 <= record["wind_speed"] <= 30):
322
+ st.error(f"Validation failed: Wind speed {record['wind_speed']} outside range")
323
+ return False
324
+ if not (0 <= record["wind_direction"] <= 360):
325
+ st.error(f"Validation failed: Wind direction {record['wind_direction']} outside range")
326
+ return False
327
+
328
+ # Validate typical/extreme periods (optional)
329
+ if "typical_extreme_periods" in data and data["typical_extreme_periods"]:
330
+ expected_periods = ["summer_extreme", "summer_typical", "winter_extreme", "winter_typical"]
331
+ missing_periods = [p for p in expected_periods if p not in data["typical_extreme_periods"]]
332
+ if missing_periods:
333
+ st.warning(f"Validation warning: Missing typical/extreme periods: {', '.join(missing_periods)}")
334
+ for period in data["typical_extreme_periods"].values():
335
+ for date in ["start", "end"]:
336
+ if not (1 <= period[date]["month"] <= 12 and 1 <= period[date]["day"] <= 31):
337
+ st.error(f"Validation failed: Invalid date in typical/extreme periods: {period[date]}")
338
+ return False
339
+
340
+ # Validate ground temperatures (optional)
341
+ if "ground_temperatures" in data and data["ground_temperatures"]:
342
+ for depth, temps in data["ground_temperatures"].items():
343
+ if len(temps) != 12 or not all(0 <= t <= 50 for t in temps):
344
+ st.error(f"Validation failed: Invalid ground temperatures for depth {depth}")
345
+ return False
346
+
347
+ # Validate solar calculations (optional)
348
+ if "solar_calculations" in data and data["solar_calculations"]:
349
+ for calc in data["solar_calculations"]:
350
+ if not (1 <= calc["month"] <= 12 and 1 <= calc["day"] <= 31 and 1 <= calc["hour"] <= 24):
351
+ st.error(f"Validation failed: Invalid date/time in solar calculations: {calc}")
352
+ return False
353
+ if not (-23.45 <= calc["declination"] <= 23.45):
354
+ st.error(f"Validation failed: Declination {calc['declination']} outside range")
355
+ return False
356
+ if not (0 <= calc["LST"] <= 24):
357
+ st.error(f"Validation failed: LST {calc['LST']} outside range")
358
+ return False
359
+ if not (-180 <= calc["HRA"] <= 180):
360
+ st.error(f"Validation failed: HRA {calc['HRA']} outside range")
361
+ return False
362
+ if not (0 <= calc["altitude"] <= 90):
363
+ st.error(f"Validation failed: Altitude {calc['altitude']} outside range")
364
+ return False
365
+ if not (0 <= calc["azimuth"] <= 360):
366
+ st.error(f"Validation failed: Azimuth {calc['azimuth']} outside range")
367
+ return False
368
+ if not (0 <= calc["ground_reflected"] <= 1200):
369
+ st.error(f"Validation failed: Ground-reflected radiation {calc['ground_reflected']} outside range")
370
+ return False
371
+
372
+ return True
373
+
374
+ @staticmethod
375
+ def calculate_wet_bulb(dry_bulb: np.ndarray, relative_humidity: np.ndarray) -> np.ndarray:
376
+ """Calculate Wet Bulb Temperature using Stull (2011) approximation."""
377
+ db = np.array(dry_bulb, dtype=float)
378
+ rh = np.array(relative_humidity, dtype=float)
379
+
380
+ term1 = db * np.arctan(0.151977 * (rh + 8.313659)**0.5)
381
+ term2 = np.arctan(db + rh)
382
+ term3 = np.arctan(rh - 1.676331)
383
+ term4 = 0.00391838 * rh**1.5 * np.arctan(0.023101 * rh)
384
+ term5 = -4.686035
385
+
386
+ wet_bulb = term1 + term2 - term3 + term4 + term5
387
+
388
+ invalid_mask = (rh < 5) | (rh > 99) | (db < -20) | (db > 50) | np.isnan(db) | np.isnan(rh)
389
+ wet_bulb[invalid_mask] = np.nan
390
+
391
+ return wet_bulb
392
+
393
+ @staticmethod
394
+ def is_numeric(value: str) -> bool:
395
+ """Check if a string can be converted to a number."""
396
+ try:
397
+ float(value)
398
+ return True
399
+ except ValueError:
400
+ return False
401
+
402
+ def display_climate_input(self, session_state: Dict[str, Any]):
403
+ """Display Streamlit interface for EPW upload, visualizations, and solar analysis."""
404
+ st.title("Climate Data Analysis")
405
+
406
+ # Apply consistent styling
407
+ st.markdown(STYLE, unsafe_allow_html=True)
408
+
409
+ # Clear invalid session_state["climate_data"] without warning
410
+ if "climate_data" in session_state and not all(key in session_state["climate_data"] for key in ["id", "country", "city", "timezone"]):
411
+ del session_state["climate_data"]
412
+
413
+ uploaded_file = st.file_uploader("Upload EPW File", type=["epw"])
414
+
415
+ # Initialize location and epw_data for display
416
+ location = None
417
+ epw_data = None
418
+
419
+ if uploaded_file:
420
+ try:
421
+ # Process new EPW file
422
+ epw_content = uploaded_file.read().decode("utf-8")
423
+ epw_lines = epw_content.splitlines()
424
+
425
+ # Parse header
426
+ header = next(line for line in epw_lines if line.startswith("LOCATION"))
427
+ header_parts = header.split(",")
428
+ city = header_parts[1].strip() or "Unknown"
429
+ # Clean city name by removing suffixes like '.Racecourse'
430
+ city = re.sub(r'\..*', '', city)
431
+ state_province = header_parts[2].strip() or "Unknown"
432
+ country = header_parts[3].strip() or "Unknown"
433
+
434
+ latitude = float(header_parts[6])
435
+ longitude = float(header_parts[7])
436
+ elevation = float(header_parts[9])
437
+ timezone = float(header_parts[8]) # Time zone from EPW header
438
+
439
+ # Parse TYPICAL/EXTREME PERIODS
440
+ typical_extreme_periods = {}
441
+ date_pattern = r'^\d{1,2}\s*/\s*\d{1,2}$'
442
+ for line in epw_lines:
443
+ if line.startswith("TYPICAL/EXTREME PERIODS"):
444
+ parts = line.strip().split(',')
445
+ try:
446
+ num_periods = int(parts[1])
447
+ except ValueError:
448
+ st.warning("Invalid number of periods in TYPICAL/EXTREME PERIODS, skipping parsing.")
449
+ break
450
+ for i in range(num_periods):
451
+ try:
452
+ if len(parts) < 2 + i*4 + 4:
453
+ st.warning(f"Insufficient fields for period {i+1}, skipping.")
454
+ continue
455
+ period_name = parts[2 + i*4]
456
+ period_type = parts[3 + i*4]
457
+ start_date = parts[4 + i*4].strip()
458
+ end_date = parts[5 + i*4].strip()
459
+ if period_name in [
460
+ "Summer - Week Nearest Max Temperature For Period",
461
+ "Summer - Week Nearest Average Temperature For Period",
462
+ "Winter - Week Nearest Min Temperature For Period",
463
+ "Winter - Week Nearest Average Temperature For Period"
464
+ ]:
465
+ season = 'summer' if 'Summer' in period_name else 'winter'
466
+ period_type = ('extreme' if 'Max' in period_name or 'Min' in period_name else 'typical')
467
+ key = f"{season}_{period_type}"
468
+ # Clean dates to remove non-standard whitespace
469
+ start_date_clean = re.sub(r'\s+', '', start_date)
470
+ end_date_clean = re.sub(r'\s+', '', end_date)
471
+ if not re.match(date_pattern, start_date) or not re.match(date_pattern, end_date):
472
+ st.warning(f"Invalid date format for period {period_name}: {start_date} to {end_date}, skipping.")
473
+ continue
474
+ start_month, start_day = map(int, start_date_clean.split('/'))
475
+ end_month, end_day = map(int, end_date_clean.split('/'))
476
+ typical_extreme_periods[key] = {
477
+ "start": {"month": start_month, "day": start_day},
478
+ "end": {"month": end_month, "day": end_day}
479
+ }
480
+ except (IndexError, ValueError) as e:
481
+ st.warning(f"Error parsing period {i+1}: {str(e)}, skipping.")
482
+ continue
483
+ break
484
+
485
+ # Parse GROUND TEMPERATURES
486
+ ground_temperatures = {}
487
+ for line in epw_lines:
488
+ if line.startswith("GROUND TEMPERATURES"):
489
+ parts = line.strip().split(',')
490
+ try:
491
+ num_depths = int(parts[1])
492
+ except ValueError:
493
+ st.warning("Invalid number of depths in GROUND TEMPERATURES, skipping parsing.")
494
+ break
495
+ for i in range(num_depths):
496
+ try:
497
+ if len(parts) < 2 + i*16 + 16:
498
+ st.warning(f"Insufficient fields for ground temperature depth {i+1}, skipping.")
499
+ continue
500
+ depth = parts[2 + i*16]
501
+ temps = [float(t) for t in parts[6 + i*16:18 + i*16] if t.strip()]
502
+ if len(temps) != 12:
503
+ st.warning(f"Invalid number of temperatures for depth {depth}m, expected 12, got {len(temps)}, skipping.")
504
+ continue
505
+ ground_temperatures[depth] = temps
506
+ except (ValueError, IndexError) as e:
507
+ st.warning(f"Error parsing ground temperatures for depth {i+1}: {str(e)}, skipping.")
508
+ continue
509
+ break
510
+
511
+ # Read data section
512
+ data_start_idx = next(i for i, line in enumerate(epw_lines) if line.startswith("DATA PERIODS")) + 1
513
+ epw_data = pd.read_csv(StringIO("\n".join(epw_lines[data_start_idx:])), header=None, dtype=str)
514
+
515
+ if len(epw_data) != 8760:
516
+ raise ValueError(f"EPW file has {len(epw_data)} records, expected 8760.")
517
+ if len(epw_data.columns) != 35:
518
+ raise ValueError(f"EPW file has {len(epw_data.columns)} columns, expected 35.")
519
+
520
+ for col in [1, 2, 3, 6, 8, 9, 13, 14, 15, 20, 21]:
521
+ epw_data[col] = pd.to_numeric(epw_data[col], errors='coerce')
522
+ if epw_data[col].isna().all():
523
+ raise ValueError(f"Column {col} contains only non-numeric or missing data.")
524
+
525
+ # Create ClimateLocation
526
+ location = ClimateLocation(
527
+ epw_file=epw_data,
528
+ typical_extreme_periods=typical_extreme_periods,
529
+ ground_temperatures=ground_temperatures,
530
+ id=f"{country[:1].upper()}{city[:3].upper()}",
531
+ country=country,
532
+ state_province=state_province,
533
+ city=city,
534
+ latitude=latitude,
535
+ longitude=longitude,
536
+ elevation=elevation,
537
+ timezone=timezone
538
+ )
539
+ self.add_location(location)
540
+ climate_data_dict = location.to_dict()
541
+ if not self.validate_climate_data(climate_data_dict):
542
+ raise ValueError("Invalid climate data extracted from EPW file.")
543
+ session_state["climate_data"] = climate_data_dict
544
+ st.success("Climate data extracted from EPW file!")
545
+
546
+ except Exception as e:
547
+ st.error(f"Error processing EPW file: {str(e)}. Ensure it has 8760 hourly records and correct format.")
548
+
549
+ elif "climate_data" in session_state and self.validate_climate_data(session_state["climate_data"]):
550
+ # Reconstruct from session_state
551
+ climate_data_dict = session_state["climate_data"]
552
+
553
+ # Rebuild epw_data from hourly_data
554
+ hourly_data = climate_data_dict["hourly_data"]
555
+ epw_data = pd.DataFrame({
556
+ 1: [d["month"] for d in hourly_data], # Month
557
+ 2: [d["day"] for d in hourly_data], # Day
558
+ 3: [d["hour"] for d in hourly_data], # Hour
559
+ 6: [d["dry_bulb"] for d in hourly_data], # Dry-bulb temperature
560
+ 8: [d["relative_humidity"] for d in hourly_data], # Relative humidity
561
+ 9: [d["atmospheric_pressure"] for d in hourly_data], # Pressure
562
+ 13: [d["global_horizontal_radiation"] for d in hourly_data], # Global horizontal radiation
563
+ 14: [d["direct_normal_radiation"] for d in hourly_data], # Direct normal radiation
564
+ 15: [d["diffuse_horizontal_radiation"] for d in hourly_data], # Diffuse horizontal radiation
565
+ 20: [d["wind_direction"] for d in hourly_data], # Wind direction
566
+ 21: [d["wind_speed"] for d in hourly_data], # Wind speed
567
+ })
568
+
569
+ # Create ClimateLocation with reconstructed epw_data
570
+ location = ClimateLocation(
571
+ epw_file=epw_data,
572
+ typical_extreme_periods=climate_data_dict["typical_extreme_periods"],
573
+ ground_temperatures=climate_data_dict["ground_temperatures"],
574
+ id=climate_data_dict["id"],
575
+ country=climate_data_dict["country"],
576
+ state_province=climate_data_dict["state_province"],
577
+ city=climate_data_dict["city"],
578
+ latitude=climate_data_dict["latitude"],
579
+ longitude=climate_data_dict["longitude"],
580
+ elevation=climate_data_dict["elevation"],
581
+ timezone=climate_data_dict["timezone"],
582
+ solar_calculations=climate_data_dict.get("solar_calculations", [])
583
+ )
584
+ # Override hourly_data to ensure consistency
585
+ location.hourly_data = climate_data_dict["hourly_data"]
586
+ self.add_location(location)
587
+ st.info("Displaying previously extracted climate data.")
588
+
589
+ # Display tabs if location and epw_data are available
590
+ if location and epw_data is not None:
591
+ tab1, tab2 = st.tabs(["General Information", "Solar Analysis"])
592
+
593
+ with tab1:
594
+ self.display_design_conditions(location)
595
+
596
+ with tab2:
597
+ self.display_solar_analysis(location, session_state)
598
+
599
+ else:
600
+ st.info("No climate data available. Please upload an EPW file to proceed.")
601
+
602
+ def display_solar_analysis(self, location: ClimateLocation, session_state: Dict[str, Any]):
603
+ """Display solar analysis tab with input fields and calculation results."""
604
+ st.subheader("Solar Analysis")
605
+
606
+ # Input fields with help text
607
+ col1, col2 = st.columns(2)
608
+ with col1:
609
+ ground_reflectivity = st.number_input(
610
+ "Ground Reflectivity (ρg)",
611
+ min_value=0.0,
612
+ max_value=1.0,
613
+ value=0.2,
614
+ step=0.01,
615
+ help="Enter the albedo of the ground surface (0 to 1). Common values: 0.2 (grass), 0.3 (concrete), 0.8 (snow). Default: 0.2."
616
+ )
617
+ with col2:
618
+ surface_tilt = st.number_input(
619
+ "Surface Tilt (β, degrees)",
620
+ min_value=0.0,
621
+ max_value=180.0,
622
+ value=0.0,
623
+ step=1.0,
624
+ help="Enter the tilt angle of the surface in degrees (0° for horizontal, 90° for vertical, up to 180° for downward-facing). Default: 0°."
625
+ )
626
+
627
+ # Calculate button
628
+ if st.button("Calculate Solar Parameters"):
629
+ try:
630
+ solar_results = SolarCalculations.calculate_solar_parameters(
631
+ hourly_data=location.hourly_data,
632
+ latitude=location.latitude,
633
+ longitude=location.longitude,
634
+ timezone=session_state["climate_data"].get("timezone", 0),
635
+ ground_reflectivity=ground_reflectivity,
636
+ surface_tilt=surface_tilt
637
+ )
638
+ session_state["climate_data"]["solar_calculations"] = solar_results
639
+ location.solar_calculations = solar_results
640
+ st.success("Solar calculations completed!")
641
+ except Exception as e:
642
+ st.error(f"Error in solar calculations: {str(e)}")
643
+
644
+ # Display results table
645
+ if "solar_calculations" in session_state["climate_data"] and session_state["climate_data"]["solar_calculations"]:
646
+ st.markdown('<div class="markdown-text"><h3>Solar Analysis Results</h3></div>', unsafe_allow_html=True)
647
+ table_data = []
648
+ solar_data = {f"{r['month']}-{r['day']}-{r['hour']}": r for r in session_state["climate_data"]["solar_calculations"]}
649
+
650
+ for record in location.hourly_data:
651
+ key = f"{record['month']}-{record['day']}-{record['hour']}"
652
+ row = {
653
+ "Month": record["month"],
654
+ "Day": record["day"],
655
+ "Hour": record["hour"],
656
+ "Dry Bulb Temperature (°C)": f"{record['dry_bulb']:.1f}",
657
+ "Relative Humidity (%)": f"{record['relative_humidity']:.1f}",
658
+ "Wind Speed (m/s)": f"{record['wind_speed']:.1f}",
659
+ "Wind Direction (°)": f"{record['wind_direction']:.1f}",
660
+ "Global Horizontal Radiation (W/m²)": f"{record['global_horizontal_radiation']:.1f}",
661
+ "Direct Normal Radiation (W/m²)": f"{record['direct_normal_radiation']:.1f}",
662
+ "Diffuse Horizontal Radiation (W/m²)": f"{record['diffuse_horizontal_radiation']:.1f}",
663
+ "Declination (°)": "",
664
+ "Local Solar Time (h)": "",
665
+ "Hour Angle (°)": "",
666
+ "Solar Altitude (°)": "",
667
+ "Solar Azimuth (°)": "",
668
+ "Ground-Reflected Radiation (W/m²)": ""
669
+ }
670
+ if key in solar_data:
671
+ solar = solar_data[key]
672
+ row.update({
673
+ "Declination (°)": f"{solar['declination']:.2f}",
674
+ "Local Solar Time (h)": f"{solar['LST']:.2f}",
675
+ "Hour Angle (°)": f"{solar['HRA']:.2f}",
676
+ "Solar Altitude (°)": f"{solar['altitude']:.2f}",
677
+ "Solar Azimuth (°)": f"{solar['azimuth']:.2f}",
678
+ "Ground-Reflected Radiation (W/m²)": f"{solar['ground_reflected']:.2f}"
679
+ })
680
+ table_data.append(row)
681
+
682
+ df = pd.DataFrame(table_data)
683
+ st.dataframe(df, use_container_width=True)
684
+ else:
685
+ st.info("No solar calculation results available. Click 'Calculate Solar Parameters' to generate results.")
686
+
687
+ def display_design_conditions(self, location: ClimateLocation):
688
+ """Display design conditions for HVAC calculations using styled HTML."""
689
+ st.subheader("Design Conditions")
690
+
691
+ col1, col2 = st.columns(2)
692
+
693
+ # Location Details (First Column)
694
+ with col1:
695
+ st.markdown(f"""
696
+ <div class="column">
697
+ <div class="markdown-text">
698
+ <h3>Location Details</h3>
699
+ <ul>
700
+ <li><strong>Country:</strong> {location.country}</li>
701
+ <li><strong>City:</strong> {location.city}</li>
702
+ <li><strong>State/Province:</strong> {location.state_province}</li>
703
+ <li><strong>Latitude:</strong> {location.latitude}°</li>
704
+ <li><strong>Longitude:</strong> {location.longitude}°</li>
705
+ <li><strong>Elevation:</strong> {location.elevation} m</li>
706
+ <li><strong>Timezone:</strong> {location.timezone:+.1f} hours</li>
707
+ </ul>
708
+ </div>
709
+ </div>
710
+ """, unsafe_allow_html=True)
711
+
712
+ # Typical/Extreme Periods (Second Column)
713
+ with col2:
714
+ if location.typical_extreme_periods:
715
+ period_items = [
716
+ f"<li><strong>{key.replace('_', ' ').title()}:</strong> {period['start']['month']}/{period['start']['day']} to {period['end']['month']}/{period['end']['day']}</li>"
717
+ for key, period in location.typical_extreme_periods.items()
718
+ ]
719
+ period_content = f"""
720
+ <div class="markdown-text">
721
+ <h3>Typical/Extreme Periods</h3>
722
+ <ul>
723
+ {''.join(period_items)}
724
+ </ul>
725
+ </div>
726
+ """
727
+ else:
728
+ period_content = """
729
+ <div class="markdown-text">
730
+ <h3>Typical/Extreme Periods</h3>
731
+ <p>No typical/extreme period data available.</p>
732
+ </div>
733
+ """
734
+ st.markdown(period_content, unsafe_allow_html=True)
735
+
736
+ # Calculated Climate Parameters
737
+ st.markdown(f"""
738
+ <div class="markdown-text">
739
+ <h3>Calculated Climate Parameters</h3>
740
+ <ul>
741
+ <li><strong>Climate Zone:</strong> {location.climate_zone}</li>
742
+ <li><strong>Heating Degree Days (base 18°C):</strong> {location.heating_degree_days} HDD</li>
743
+ <li><strong>Cooling Degree Days (base 18°C):</strong> {location.cooling_degree_days} CDD</li>
744
+ <li><strong>Winter Design Temperature (99.6%):</strong> {location.winter_design_temp} °C</li>
745
+ <li><strong>Summer Design Dry-Bulb Temp (0.4%):</strong> {location.summer_design_temp_db} °C</li>
746
+ <li><strong>Summer Design Wet-Bulb Temp (0.4%):</strong> {location.summer_design_temp_wb} °C</li>
747
+ <li><strong>Summer Daily Temperature Range:</strong> {location.summer_daily_range} °C</li>
748
+ <li><strong>Mean Wind Speed:</strong> {location.wind_speed} m/s</li>
749
+ <li><strong>Mean Atmospheric Pressure:</strong> {location.pressure} Pa</li>
750
+ </ul>
751
+ </div>
752
+ """, unsafe_allow_html=True)
753
+
754
+ # Ground Temperatures (Table)
755
+ if location.ground_temperatures:
756
+ st.markdown('<div class="markdown-text"><h3>Ground Temperatures</h3></div>', unsafe_allow_html=True)
757
+ month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
758
+ table_data = []
759
+ for depth, temps in location.ground_temperatures.items():
760
+ row = {"Depth (m)": float(depth)}
761
+ row.update({month: f"{temp:.2f}" for month, temp in zip(month_names, temps)})
762
+ table_data.append(row)
763
+ df = pd.DataFrame(table_data)
764
+ st.dataframe(df, use_container_width=True)
765
+
766
+ # Hourly Climate Data (Table)
767
+ if location.hourly_data:
768
+ st.markdown('<div class="markdown-text"><h3>Hourly Climate Data</h3></div>', unsafe_allow_html=True)
769
+ hourly_table_data = []
770
+ for record in location.hourly_data:
771
+ row = {
772
+ "Month": record["month"],
773
+ "Day": record["day"],
774
+ "Hour": record["hour"],
775
+ "Dry Bulb Temperature (°C)": f"{record['dry_bulb']:.1f}",
776
+ "Relative Humidity (%)": f"{record['relative_humidity']:.1f}",
777
+ "Atmospheric Pressure (Pa)": f"{record['atmospheric_pressure']:.1f}",
778
+ "Global Horizontal Radiation (W/m²)": f"{record['global_horizontal_radiation']:.1f}",
779
+ "Direct Normal Radiation (W/m²)": f"{record['direct_normal_radiation']:.1f}",
780
+ "Diffuse Horizontal Radiation (W/m²)": f"{record['diffuse_horizontal_radiation']:.1f}",
781
+ "Wind Speed (m/s)": f"{record['wind_speed']:.1f}",
782
+ "Wind Direction (°)": f"{record['wind_direction']:.1f}"
783
+ }
784
+ hourly_table_data.append(row)
785
+ hourly_df = pd.DataFrame(hourly_table_data)
786
+ st.dataframe(hourly_df, use_container_width=True)
787
+
788
+ @staticmethod
789
+ def assign_climate_zone(hdd: float, cdd: float, avg_humidity: float) -> str:
790
+ """Assign ASHRAE 169 climate zone based on HDD, CDD, and humidity."""
791
+ if cdd > 10000:
792
+ return "0A" if avg_humidity > 60 else "0B"
793
+ elif cdd > 5000:
794
+ return "1A" if avg_humidity > 60 else "1B"
795
+ elif cdd > 2500:
796
+ return "2A" if avg_humidity > 60 else "2B"
797
+ elif hdd < 2000 and cdd > 1000:
798
+ return "3A" if avg_humidity > 60 else "3B" if avg_humidity < 40 else "3C"
799
+ elif hdd < 3000:
800
+ return "4A" if avg_humidity > 60 else "4B" if avg_humidity < 40 else "4C"
801
+ elif hdd < 4000:
802
+ return "5A" if avg_humidity > 60 else "5B" if avg_humidity < 40 else "5C"
803
+ elif hdd < 5000:
804
+ return "6A" if avg_humidity > 60 else "6B"
805
+ elif hdd < 7000:
806
+ return "7"
807
+ else:
808
+ return "8"
809
+
810
+ def export_to_json(self, file_path: str) -> None:
811
+ """Export all climate data to a JSON file."""
812
+ data = {loc_id: loc.to_dict() for loc_id, loc in self.locations.items()}
813
+ with open(file_path, 'w') as f:
814
+ json.dump(data, f, indent=4)
815
+
816
+ @classmethod
817
+ def from_json(cls, file_path: str) -> 'ClimateData':
818
+ """Load climate data from a JSON file."""
819
+ with open(file_path, 'r') as f:
820
+ data = json.load(f)
821
+ climate_data = cls()
822
+ for loc_id, loc_dict in data.items():
823
+ # Rebuild epw_data from hourly_data
824
+ hourly_data = loc_dict["hourly_data"]
825
+ epw_data = pd.DataFrame({
826
+ 1: [d["month"] for d in hourly_data],
827
+ 2: [d["day"] for d in hourly_data],
828
+ 3: [d["hour"] for d in hourly_data],
829
+ 6: [d["dry_bulb"] for d in hourly_data],
830
+ 8: [d["relative_humidity"] for d in hourly_data],
831
+ 9: [d["atmospheric_pressure"] for d in hourly_data],
832
+ 13: [d["global_horizontal_radiation"] for d in hourly_data],
833
+ 14: [d["direct_normal_radiation"] for d in hourly_data],
834
+ 15: [d["diffuse_horizontal_radiation"] for d in hourly_data],
835
+ 20: [d["wind_direction"] for d in hourly_data],
836
+ 21: [d["wind_speed"] for d in hourly_data],
837
+ })
838
+ location = ClimateLocation(
839
+ epw_file=epw_data,
840
+ typical_extreme_periods=loc_dict["typical_extreme_periods"],
841
+ ground_temperatures=loc_dict["ground_temperatures"],
842
+ id=loc_dict["id"],
843
+ country=loc_dict["country"],
844
+ state_province=loc_dict["state_province"],
845
+ city=loc_dict["city"],
846
+ latitude=loc_dict["latitude"],
847
+ longitude=loc_dict["longitude"],
848
+ elevation=loc_dict["elevation"],
849
+ timezone=loc_dict["timezone"],
850
+ solar_calculations=loc_dict.get("solar_calculations", [])
851
+ )
852
+ location.hourly_data = loc_dict["hourly_data"] # Ensure consistency
853
+ climate_data.add_location(location)
854
+ return climate_data
855
+
856
+ if __name__ == "__main__":
857
+ climate_data = ClimateData()
858
+ session_state = {"building_info": {"country": "Australia", "city": "Geelong"}, "page": "Climate Data"}
859
+ climate_data.display_climate_input(session_state)
data/internal_loads.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # internal_loads.py
2
+
3
+ # People Activity Levels
4
+ # Each activity level includes metabolic rate (met), metabolic rate in W/person,
5
+ # and ranges for sensible and latent heat gains in W.
6
+ PEOPLE_ACTIVITY_LEVELS = {
7
+ "Seated, at Rest (Quiet, Reading, Writing)": {
8
+ "metabolic_rate_met": 1.0,
9
+ "metabolic_rate_w": 110,
10
+ "sensible_min_w": 20,
11
+ "sensible_max_w": 24,
12
+ "latent_min_w": 9,
13
+ "latent_max_w": 12
14
+ },
15
+ "Seated, Light Office Work (Typing, Filing)": {
16
+ "metabolic_rate_met": 1.1,
17
+ "metabolic_rate_w": 125,
18
+ "sensible_min_w": 24,
19
+ "sensible_max_w": 27,
20
+ "latent_min_w": 12,
21
+ "latent_max_w": 15
22
+ },
23
+ "Standing, Light Work (Filing, Walking Slowly)": {
24
+ "metabolic_rate_met": 1.35,
25
+ "metabolic_rate_w": 155,
26
+ "sensible_min_w": 30,
27
+ "sensible_max_w": 35,
28
+ "latent_min_w": 18,
29
+ "latent_max_w": 24
30
+ },
31
+ "Walking, Moderate Pace (2–3 mph / 3.2–4.8 km/h)": {
32
+ "metabolic_rate_met": 2.0,
33
+ "metabolic_rate_w": 210,
34
+ "sensible_min_w": 41,
35
+ "sensible_max_w": 47,
36
+ "latent_min_w": 30,
37
+ "latent_max_w": 35
38
+ },
39
+ "Light Machine Work (Assembly, Small Tools)": {
40
+ "metabolic_rate_met": 2.35,
41
+ "metabolic_rate_w": 250,
42
+ "sensible_min_w": 47,
43
+ "sensible_max_w": 56,
44
+ "latent_min_w": 35,
45
+ "latent_max_w": 44
46
+ },
47
+ "Moderate Work (Walking with Loads, Lifting)": {
48
+ "metabolic_rate_met": 3.0,
49
+ "metabolic_rate_w": 310,
50
+ "sensible_min_w": 59,
51
+ "sensible_max_w": 68,
52
+ "latent_min_w": 50,
53
+ "latent_max_w": 59
54
+ },
55
+ "Heavy Work (Carrying Heavy Loads, Shoveling)": {
56
+ "metabolic_rate_met": 4.0,
57
+ "metabolic_rate_w": 425,
58
+ "sensible_min_w": 73,
59
+ "sensible_max_w": 88,
60
+ "latent_min_w": 73,
61
+ "latent_max_w": 88
62
+ },
63
+ "Dancing (Moderate to Vigorous)": {
64
+ "metabolic_rate_met": 3.5,
65
+ "metabolic_rate_w": 400,
66
+ "sensible_min_w": 59,
67
+ "sensible_max_w": 88,
68
+ "latent_min_w": 59,
69
+ "latent_max_w": 73
70
+ },
71
+ "Athletics/Exercise (Vigorous)": {
72
+ "metabolic_rate_met": 6.0,
73
+ "metabolic_rate_w": 600,
74
+ "sensible_min_w": 88,
75
+ "sensible_max_w": 117,
76
+ "latent_min_w": 88,
77
+ "latent_max_w": 117
78
+ }
79
+ }
80
+
81
+ # Diversity Factors by Building Type
82
+ DIVERSITY_FACTORS = {
83
+ "Office": 0.80,
84
+ "Classroom": 1.00,
85
+ "Retail": 0.80,
86
+ "Restaurant Dining": 1.00,
87
+ "Restaurant Kitchen": 1.00,
88
+ "Hotel Guest Room": 0.50,
89
+ "Hotel Lobby": 0.75,
90
+ "Hospital Patient Room": 0.60,
91
+ "Hospital Operating Room": 1.00,
92
+ "Library": 0.75,
93
+ "Museum": 0.75,
94
+ "Courthouse": 1.00,
95
+ "Gymnasium": 1.00,
96
+ "Warehouse": 0.50,
97
+ "Residential Single-Family": 0.50,
98
+ "Residential Multi-Family": 0.60,
99
+ "Auditorium": 1.00,
100
+ "Place of Worship": 1.00,
101
+ "Laboratory": 0.80,
102
+ "Data Center": 0.10,
103
+ "Manufacturing Facility": 0.75,
104
+ "School Cafeteria": 1.00,
105
+ "Dormitory": 0.60,
106
+ "Conference Room": 1.00,
107
+ "Bank": 0.80,
108
+ "Post Office": 0.75,
109
+ "Supermarket": 0.70
110
+ }
111
+
112
+ # Lighting Power Density (LPD) by Building Type (W/m²)
113
+ LPD_VALUES = {
114
+ "Office": 8.82,
115
+ "Classroom": 13.34,
116
+ "Retail": 13.56,
117
+ "Restaurant Dining": 9.58,
118
+ "Restaurant Kitchen": 11.42,
119
+ "Hotel Guest Room": 9.47,
120
+ "Hotel Lobby": 13.57,
121
+ "Hospital Patient Room": 11.30,
122
+ "Hospital Operating Room": 17.11,
123
+ "Library": 12.70,
124
+ "Museum": 11.41,
125
+ "Courthouse": 11.30,
126
+ "Gymnasium": 10.76,
127
+ "Warehouse": 4.84,
128
+ "Residential Single-Family": 5.38,
129
+ "Residential Multi-Family": 6.46,
130
+ "Auditorium": 14.96,
131
+ "Place of Worship": 11.30,
132
+ "Laboratory": 15.61,
133
+ "Data Center": 8.61,
134
+ "Manufacturing Facility": 11.95,
135
+ "School Cafeteria": 9.69,
136
+ "Dormitory": 6.57,
137
+ "Conference Room": 13.24,
138
+ "Bank": 10.76,
139
+ "Post Office": 9.37,
140
+ "Supermarket": 13.35
141
+ }
142
+
143
+ # Lighting Fixture Types and Heat Gain Splits
144
+ LIGHTING_FIXTURE_TYPES = {
145
+ "Incandescent": {"radiative": 80, "convective": 20},
146
+ "Fluorescent": {"radiative": 60, "convective": 40},
147
+ "Compact Fluorescent (CFL)": {"radiative": 60, "convective": 40},
148
+ "LED (Light Emitting Diode)": {"radiative": 50, "convective": 50},
149
+ "High-Intensity Discharge (HID)": {"radiative": 70, "convective": 30},
150
+ "Halogen": {"radiative": 80, "convective": 20}
151
+ }
152
+
153
+ # Equipment Heat Gains by Building Type
154
+ # Includes sensible, latent, convective, and radiant splits (all in %)
155
+ EQUIPMENT_HEAT_GAINS = {
156
+ "Office": {"sensible": 75, "latent": 25, "convective": 50, "radiant": 50},
157
+ "Classroom": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
158
+ "Retail": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
159
+ "Restaurant Dining": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
160
+ "Restaurant Kitchen": {"sensible": 50, "latent": 50, "convective": 70, "radiant": 30},
161
+ "Hotel Guest Room": {"sensible": 65, "latent": 35, "convective": 60, "radiant": 40},
162
+ "Hotel Lobby": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
163
+ "Hospital Patient Room": {"sensible": 65, "latent": 35, "convective": 60, "radiant": 40},
164
+ "Hospital Operating Room": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
165
+ "Library": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
166
+ "Museum": {"sensible": 75, "latent": 25, "convective": 40, "radiant": 60},
167
+ "Courthouse": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
168
+ "Gymnasium": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
169
+ "Warehouse": {"sensible": 80, "latent": 20, "convective": 70, "radiant": 30},
170
+ "Residential Single-Family": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
171
+ "Residential Multi-Family": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
172
+ "Auditorium": {"sensible": 65, "latent": 35, "convective": 50, "radiant": 50},
173
+ "Place of Worship": {"sensible": 65, "latent": 35, "convective": 50, "radiant": 50},
174
+ "Laboratory": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
175
+ "Data Center": {"sensible": 90, "latent": 10, "convective": 80, "radiant": 20},
176
+ "Manufacturing Facility": {"sensible": 75, "latent": 25, "convective": 60, "radiant": 40},
177
+ "School Cafeteria": {"sensible": 65, "latent": 35, "convective": 60, "radiant": 40},
178
+ "Dormitory": {"sensible": 60, "latent": 40, "convective": 60, "radiant": 40},
179
+ "Conference Room": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
180
+ "Bank": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
181
+ "Post Office": {"sensible": 70, "latent": 30, "convective": 50, "radiant": 50},
182
+ "Supermarket": {"sensible": 75, "latent": 25, "convective": 60, "radiant": 40}
183
+ }
184
+
185
+ # Ventilation Rates by Building Type
186
+ # Includes people_rate (L/s/person) and area_rate (L/s/m²)
187
+ VENTILATION_RATES = {
188
+ "Office": {"people_rate": 2.5, "area_rate": 0.3},
189
+ "Classroom": {"people_rate": 5.0, "area_rate": 0.9},
190
+ "Retail": {"people_rate": 3.8, "area_rate": 0.9},
191
+ "Restaurant Dining": {"people_rate": 5.0, "area_rate": 1.8},
192
+ "Restaurant Kitchen": {"people_rate": 3.8, "area_rate": 1.0},
193
+ "Hotel Guest Room": {"people_rate": 2.5, "area_rate": 0.3},
194
+ "Hotel Lobby": {"people_rate": 3.8, "area_rate": 0.3},
195
+ "Hospital Patient Room": {"people_rate": 5.0, "area_rate": 0.6},
196
+ "Hospital Operating Room": {"people_rate": 10.0, "area_rate": 0.6},
197
+ "Library": {"people_rate": 2.5, "area_rate": 0.6},
198
+ "Museum": {"people_rate": 3.8, "area_rate": 0.6},
199
+ "Courthouse": {"people_rate": 2.5, "area_rate": 0.3},
200
+ "Gymnasium": {"people_rate": 10.0, "area_rate": 0.9},
201
+ "Warehouse": {"people_rate": 5.0, "area_rate": 0.3},
202
+ "Residential Single-Family": {"people_rate": 2.5, "area_rate": 0.3},
203
+ "Residential Multi-Family": {"people_rate": 2.5, "area_rate": 0.3},
204
+ "Auditorium": {"people_rate": 2.5, "area_rate": 0.3},
205
+ "Place of Worship": {"people_rate": 2.5, "area_rate": 0.3},
206
+ "Laboratory": {"people_rate": 5.0, "area_rate": 0.9},
207
+ "Data Center": {"people_rate": 5.0, "area_rate": 0.6},
208
+ "Manufacturing Facility": {"people_rate": 5.0, "area_rate": 0.9},
209
+ "School Cafeteria": {"people_rate": 5.0, "area_rate": 0.9},
210
+ "Dormitory": {"people_rate": 2.5, "area_rate": 0.3},
211
+ "Conference Room": {"people_rate": 2.5, "area_rate": 0.3},
212
+ "Bank": {"people_rate": 2.5, "area_rate": 0.3},
213
+ "Post Office": {"people_rate": 2.5, "area_rate": 0.3},
214
+ "Supermarket": {"people_rate": 3.8, "area_rate": 0.6},
215
+ "Custom": {"people_rate": 0.0, "area_rate": 0.0}
216
+ }
217
+
218
+ BUILDING_TYPES = list(VENTILATION_RATES.keys())
219
+
220
+ # Infiltration Settings by Building Type and Method
221
+ # Methods: ACH, Crack Flow, Empirical Equations
222
+ # Each method has specific parameters for each building type
223
+ INFILTRATION_SETTINGS = {
224
+ "ACH": {
225
+ "Office": {"rate": 0.5},
226
+ "Classroom": {"rate": 0.3},
227
+ "Retail": {"rate": 0.4},
228
+ "Restaurant Dining": {"rate": 0.6},
229
+ "Restaurant Kitchen": {"rate": 0.8},
230
+ "Hotel Guest Room": {"rate": 0.2},
231
+ "Hotel Lobby": {"rate": 0.5},
232
+ "Hospital Patient Room": {"rate": 0.1},
233
+ "Hospital Operating Room": {"rate": 0.05},
234
+ "Library": {"rate": 0.3},
235
+ "Museum": {"rate": 0.2},
236
+ "Courthouse": {"rate": 0.4},
237
+ "Gymnasium": {"rate": 0.6},
238
+ "Warehouse": {"rate": 1.0},
239
+ "Residential Single-Family": {"rate": 0.5},
240
+ "Residential Multi-Family": {"rate": 0.4},
241
+ "Auditorium": {"rate": 0.3},
242
+ "Place of Worship": {"rate": 0.4},
243
+ "Laboratory": {"rate": 0.2},
244
+ "Data Center": {"rate": 0.1},
245
+ "Manufacturing Facility": {"rate": 0.7},
246
+ "School Cafeteria": {"rate": 0.5},
247
+ "Dormitory": {"rate": 0.4},
248
+ "Conference Room": {"rate": 0.3},
249
+ "Bank": {"rate": 0.4},
250
+ "Post Office": {"rate": 0.5},
251
+ "Supermarket": {"rate": 0.6}
252
+ },
253
+ "Crack Flow": {
254
+ "Office": {"ela": 0.0001}, # Effective Leakage Area (m²/m² of wall)
255
+ "Classroom": {"ela": 0.00008},
256
+ # Additional building types can be added as needed
257
+ },
258
+ "Empirical Equations": {
259
+ "Office": {"c": 0.1, "n": 0.65}, # Example coefficients for Sherman-Grimsrud model
260
+ "Classroom": {"c": 0.08, "n": 0.65},
261
+ # Additional building types can be added as needed
262
+ }
263
+ }
data/material_library.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Material Library for HVAC Load Calculator
3
+ Updated 2025-05-17: Removed original materials and constructions, deleted ASHRAE materials starting with 'R' followed by number (e.g., R01, R25), verified constructions use valid materials.
4
+ Updated 2025-05-17: Added all materials and constructions from ASHRAE_2005_HOF_Materials.idf with Australian-specific embodied carbon and prices.
5
+ Updated 2025-05-17: Added GlazingMaterial and DoorMaterial classes, included library_glazing_materials and library_door_materials with comprehensive data.
6
+ Updated 2025-05-16: Removed mass_per_meter, added thermal_mass method, updated CSV handling.
7
+ Updated 2025-05-16: Fixed U-value calculation in Material.get_u_value.
8
+ Updated 2025-05-16: Updated MaterialCategory to Finishing Materials, Structural Materials, Sub-Structural Materials, Insulation.
9
+ Updated 2025-05-16: Fixed Construction.get_thermal_mass to use avg_specific_heat.
10
+
11
+ Developed by: Dr Majed Abuseif, Deakin University
12
+ © 2025
13
+ """
14
+
15
+ from typing import Dict, List, Optional, Tuple
16
+ from enum import Enum
17
+ import pandas as pd
18
+
19
+ class MaterialCategory(Enum):
20
+ FINISHING_MATERIALS = "Finishing Materials"
21
+ STRUCTURAL_MATERIALS = "Structural Materials"
22
+ SUB_STRUCTURAL_MATERIALS = "Sub-Structural Materials"
23
+ INSULATION = "Insulation"
24
+
25
+ class ThermalMass(Enum):
26
+ HIGH = "High"
27
+ MEDIUM = "Medium"
28
+ LOW = "Low"
29
+ NO_MASS = "No Mass"
30
+
31
+ class Material:
32
+ def __init__(self, name: str, category: MaterialCategory, conductivity: float, density: float,
33
+ specific_heat: float, default_thickness: float, embodied_carbon: float,
34
+ solar_absorption: float, price: float, is_library: bool = True):
35
+ self.name = name
36
+ self.category = category
37
+ self.conductivity = max(0.01, conductivity) # W/m·K
38
+ self.density = max(1.0, density) # kg/m³
39
+ self.specific_heat = max(100.0, specific_heat) # J/kg·K
40
+ self.default_thickness = max(0.01, default_thickness) # m
41
+ self.embodied_carbon = max(0.0, embodied_carbon) # kgCO₂e/kg
42
+ self.solar_absorption = min(max(0.0, solar_absorption), 1.0)
43
+ self.price = max(0.0, price) # USD/m²
44
+ self.is_library = is_library
45
+
46
+ def get_thermal_mass(self) -> ThermalMass:
47
+ if self.density < 100.0 or self.specific_heat < 800.0:
48
+ return ThermalMass.NO_MASS
49
+ elif self.density > 2000.0 and self.specific_heat > 800.0:
50
+ return ThermalMass.HIGH
51
+ elif 1000.0 <= self.density <= 2000.0 and 800.0 <= self.specific_heat <= 1200.0:
52
+ return ThermalMass.MEDIUM
53
+ else:
54
+ return ThermalMass.LOW
55
+
56
+ def get_u_value(self) -> float:
57
+ return self.conductivity / self.default_thickness if self.default_thickness > 0 else 0.1
58
+
59
+ class GlazingMaterial:
60
+ def __init__(self, name: str, u_value: float, shgc: float, embodied_carbon: float, price: float, is_library: bool = True):
61
+ self.name = name
62
+ self.u_value = max(0.1, u_value) # W/m²·K
63
+ self.shgc = min(max(0.0, shgc), 1.0) # Solar Heat Gain Coefficient
64
+ self.embodied_carbon = max(0.0, embodied_carbon) # kgCO₂e/m²
65
+ self.price = max(0.0, price) # USD/m²
66
+ self.is_library = is_library
67
+
68
+ class DoorMaterial:
69
+ def __init__(self, name: str, u_value: float, solar_absorption: float, embodied_carbon: float, price: float, is_library: bool = True):
70
+ self.name = name
71
+ self.u_value = max(0.1, u_value) # W/m²·K
72
+ self.solar_absorption = min(max(0.0, solar_absorption), 1.0)
73
+ self.embodied_carbon = max(0.0, embodied_carbon) # kgCO₂e/m²
74
+ self.price = max(0.0, price) # USD/m²
75
+ self.is_library = is_library
76
+
77
+ class Construction:
78
+ def __init__(self, name: str, component_type: str, layers: List[Dict], is_library: bool = True):
79
+ self.name = name
80
+ self.component_type = component_type
81
+ self.layers = layers or []
82
+ self.is_library = is_library
83
+ self.u_value = self.calculate_u_value()
84
+ self.total_thickness = sum(layer["thickness"] for layer in self.layers)
85
+ self.embodied_carbon = sum(layer["material"].embodied_carbon * layer["material"].density * layer["thickness"]
86
+ for layer in self.layers)
87
+ self.solar_absorption = max(layer["material"].solar_absorption for layer in self.layers) if self.layers else 0.6
88
+ self.price = sum(layer["material"].price * layer["thickness"] / layer["material"].default_thickness
89
+ for layer in self.layers)
90
+
91
+ def calculate_u_value(self) -> float:
92
+ if not self.layers:
93
+ return 0.1
94
+ r_total = sum(layer["thickness"] / layer["material"].conductivity for layer in self.layers)
95
+ return 1 / r_total if r_total > 0 else 0.1
96
+
97
+ def get_thermal_mass(self) -> ThermalMass:
98
+ if not self.layers:
99
+ return ThermalMass.NO_MASS
100
+ total_thickness = self.total_thickness
101
+ if total_thickness == 0:
102
+ return ThermalMass.NO_MASS
103
+ avg_density = sum(layer["material"].density * layer["thickness"] for layer in self.layers) / total_thickness
104
+ avg_specific_heat = sum(layer["material"].specific_heat * layer["thickness"] for layer in self.layers) / total_thickness
105
+ if avg_density < 100.0 or avg_specific_heat < 800.0:
106
+ return ThermalMass.NO_MASS
107
+ elif avg_density > 2000.0 and avg_specific_heat > 800.0:
108
+ return ThermalMass.HIGH
109
+ elif 1000.0 <= avg_density <= 2000.0 and 800.0 <= avg_specific_heat <= 1200.0:
110
+ return ThermalMass.MEDIUM
111
+ else:
112
+ return ThermalMass.LOW
113
+
114
+ class MaterialLibrary:
115
+ def __init__(self):
116
+ self.library_materials = self.initialize_materials()
117
+ self.library_constructions = self.initialize_constructions()
118
+ self.library_glazing_materials = self.initialize_glazing_materials()
119
+ self.library_door_materials = self.initialize_door_materials()
120
+
121
+ def initialize_materials(self) -> Dict[str, Material]:
122
+ materials = [
123
+ # ASHRAE 2005 HOF Materials (excluding 'R'-prefixed materials)
124
+ Material("F04 Wall air space resistance", MaterialCategory.INSULATION, 0.01, 1.0, 1000.0, 0.01, 0.01, 0.5, 0.5),
125
+ Material("F05 Ceiling air space resistance", MaterialCategory.INSULATION, 0.01, 1.0, 1000.0, 0.01, 0.01, 0.5, 0.5),
126
+ Material("F06 EIFS finish", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0095, 0.3, 0.5, 17.6),
127
+ Material("F07 25mm stucco", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0254, 0.2, 0.6, 14.1),
128
+ Material("F08 Metal surface", MaterialCategory.SUB_STRUCTURAL_MATERIALS, 45.28, 7824.0, 500.0, 0.001, 2.2, 0.7, 25.0),
129
+ Material("F09 25mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0254, 0.2, 0.6, 14.1),
130
+ Material("F10 13mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0127, 0.25, 0.4, 5.1),
131
+ Material("F11 16mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0159, 0.25, 0.4, 6.4),
132
+ Material("F12 19mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0191, 0.25, 0.4, 7.6),
133
+ Material("F13 13mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0127, 0.2, 0.6, 7.1),
134
+ Material("F14 13mm lime plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1600.0, 840.0, 0.0127, 0.2, 0.5, 6.1),
135
+ Material("F15 22mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0222, 0.2, 0.6, 12.4),
136
+ Material("F16 Acoustic tile", MaterialCategory.FINISHING_MATERIALS, 0.06, 368.0, 590.0, 0.0191, 1.0, 0.4, 14.0),
137
+ Material("F17 13mm slag", MaterialCategory.FINISHING_MATERIALS, 0.16, 960.0, 1090.0, 0.0127, 0.2, 0.5, 1.0),
138
+ Material("F18 25mm slag", MaterialCategory.FINISHING_MATERIALS, 0.16, 960.0, 1090.0, 0.0254, 0.2, 0.5, 1.9),
139
+ Material("G01 13mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0127, 0.25, 0.4, 5.1),
140
+ Material("G01a 19mm gypsum board", MaterialCategory.FINISHING_MATERIALS, 0.16, 800.0, 1090.0, 0.0191, 0.25, 0.4, 7.6),
141
+ Material("G02 25mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0254, 0.2, 0.6, 14.1),
142
+ Material("G03 13mm lime plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1600.0, 840.0, 0.0127, 0.25, 0.5, 6.1),
143
+ Material("G04 13mm cement plaster", MaterialCategory.FINISHING_MATERIALS, 0.72, 1856.0, 840.0, 0.0127, 0.2, 0.6, 7.1),
144
+ Material("G05 25mm wood", MaterialCategory.SUB_STRUCTURAL_MATERIALS, 0.15, 608.0, 1630.0, 0.0254, 0.3, 0.5, 15.4),
145
+ Material("G06 19mm wood", MaterialCategory.SUB_STRUCTURAL_MATERIALS, 0.15, 608.0, 1630.0, 0.0191, 0.3, 0.5, 11.6),
146
+ Material("I01 25mm insulation board", MaterialCategory.INSULATION, 0.03, 43.0, 1210.0, 0.0254, 2.5, 0.5, 1.1),
147
+ Material("I02 50mm insulation board", MaterialCategory.INSULATION, 0.03, 43.0, 1210.0, 0.0508, 2.5, 0.5, 2.2),
148
+ Material("I03 75mm insulation board", MaterialCategory.INSULATION, 0.03, 43.0, 1210.0, 0.0762, 2.5, 0.5, 3.3),
149
+ Material("M01 100mm brick", MaterialCategory.STRUCTURAL_MATERIALS, 0.89, 1920.0, 790.0, 0.1016, 0.3, 0.7, 19.5),
150
+ Material("M02 100mm face brick", MaterialCategory.STRUCTURAL_MATERIALS, 1.33, 2000.0, 790.0, 0.1016, 0.3, 0.7, 20.3),
151
+ Material("M03 150mm brick", MaterialCategory.STRUCTURAL_MATERIALS, 0.89, 1920.0, 790.0, 0.1524, 0.3, 0.7, 29.3),
152
+ Material("M04 200mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 0.51, 800.0, 920.0, 0.2032, 0.2, 0.65, 13.0),
153
+ Material("M05 200mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 1.11, 1280.0, 920.0, 0.2032, 0.2, 0.65, 20.8),
154
+ Material("M06 150mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 0.51, 800.0, 920.0, 0.1524, 0.2, 0.65, 9.8),
155
+ Material("M07 100mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 0.51, 800.0, 920.0, 0.1016, 0.2, 0.65, 6.5),
156
+ Material("M08 150mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 1.11, 1280.0, 920.0, 0.1524, 0.2, 0.65, 15.6),
157
+ Material("M09 100mm concrete block", MaterialCategory.STRUCTURAL_MATERIALS, 1.11, 1280.0, 920.0, 0.1016, 0.2, 0.65, 10.4),
158
+ Material("M10 100mm lightweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 0.53, 1280.0, 840.0, 0.1016, 0.15, 0.65, 7.8),
159
+ Material("M11 100mm lightweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 0.53, 1280.0, 840.0, 0.1016, 0.15, 0.65, 7.8),
160
+ Material("M12 150mm lightweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 0.53, 1280.0, 840.0, 0.1524, 0.15, 0.65, 11.7),
161
+ Material("M13 200mm lightweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 0.53, 1280.0, 840.0, 0.2032, 0.15, 0.65, 15.6),
162
+ Material("M14 100mm heavyweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 1.95, 2240.0, 900.0, 0.1016, 0.2, 0.65, 18.2),
163
+ Material("M14a 100mm heavyweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 1.95, 2240.0, 900.0, 0.1016, 0.2, 0.65, 18.2),
164
+ Material("M15 200mm heavyweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 1.95, 2240.0, 900.0, 0.2032, 0.2, 0.65, 36.4),
165
+ Material("M16 300mm heavyweight concrete", MaterialCategory.STRUCTURAL_MATERIALS, 1.95, 2240.0, 900.0, 0.3048, 0.2, 0.65, 54.6),
166
+ Material("M17 100mm stone", MaterialCategory.STRUCTURAL_MATERIALS, 2.10, 2240.0, 880.0, 0.1016, 0.2, 0.7, 22.8),
167
+ Material("M18 150mm stone", MaterialCategory.STRUCTURAL_MATERIALS, 2.10, 2240.0, 880.0, 0.1524, 0.2, 0.7, 34.1),
168
+ Material("M19 100mm limestone", MaterialCategory.STRUCTURAL_MATERIALS, 1.80, 2320.0, 880.0, 0.1016, 0.2, 0.6, 23.6),
169
+ Material("M20 150mm limestone", MaterialCategory.STRUCTURAL_MATERIALS, 1.80, 2320.0, 880.0, 0.1524, 0.2, 0.6, 35.4),
170
+ Material("M21 200mm limestone", MaterialCategory.STRUCTURAL_MATERIALS, 1.80, 2320.0, 880.0, 0.2032, 0.2, 0.6, 47.1),
171
+ Material("M22 100mm granite", MaterialCategory.STRUCTURAL_MATERIALS, 2.80, 2640.0, 880.0, 0.1016, 0.2, 0.7, 26.8),
172
+ Material("M23 150mm granite", MaterialCategory.STRUCTURAL_MATERIALS, 2.80, 2640.0, 880.0, 0.1524, 0.2, 0.7, 40.2),
173
+ Material("M24 200mm granite", MaterialCategory.STRUCTURAL_MATERIALS, 2.80, 2640.0, 880.0, 0.2032, 0.2, 0.7, 53.6),
174
+ Material("M25 100mm marble", MaterialCategory.STRUCTURAL_MATERIALS, 2.50, 2720.0, 880.0, 0.1016, 0.2, 0.6, 27.6),
175
+ Material("M26 150mm marble", MaterialCategory.STRUCTURAL_MATERIALS, 2.50, 2720.0, 880.0, 0.1524, 0.2, 0.6, 41.4),
176
+ Material("M27 200mm marble", MaterialCategory.STRUCTURAL_MATERIALS, 2.50, 2720.0, 880.0, 0.2032, 0.2, 0.6, 55.3),
177
+ ]
178
+ return {mat.name: mat for mat in materials}
179
+
180
+ def initialize_glazing_materials(self) -> Dict[str, GlazingMaterial]:
181
+ glazing_materials = [
182
+ # ASHRAE-based glazing materials with Australian pricing
183
+ GlazingMaterial("Single Clear 3mm", 5.8, 0.81, 25.0, 50.0), # High U-value, high SHGC
184
+ GlazingMaterial("Single Clear 6mm", 5.7, 0.78, 28.0, 60.0), # Slightly lower SHGC
185
+ GlazingMaterial("Single Tinted 6mm", 5.7, 0.55, 30.0, 70.0), # Reduced SHGC for solar control
186
+ GlazingMaterial("Double Clear 6mm/13mm Air", 2.7, 0.70, 40.0, 100.0), # Improved insulation
187
+ GlazingMaterial("Double Low-E 6mm/13mm Air", 1.8, 0.60, 45.0, 120.0), # Low-E coating
188
+ GlazingMaterial("Double Tinted 6mm/13mm Air", 2.7, 0.45, 42.0, 110.0), # Tinted for solar control
189
+ GlazingMaterial("Double Low-E 6mm/13mm Argon", 1.5, 0.55, 48.0, 130.0), # Argon-filled, better U-value
190
+ GlazingMaterial("Triple Clear 4mm/12mm Air", 1.8, 0.62, 55.0, 150.0), # Triple glazing
191
+ GlazingMaterial("Triple Low-E 4mm/12mm Argon", 0.9, 0.50, 60.0, 180.0), # High-performance
192
+ GlazingMaterial("Single Low-E Reflective 6mm", 5.6, 0.35, 35.0, 90.0), # Reflective coating
193
+ GlazingMaterial("Double Reflective 6mm/13mm Air", 2.5, 0.30, 50.0, 140.0), # Low SHGC
194
+ GlazingMaterial("Electrochromic 6mm/13mm Air", 2.0, 0.40, 70.0, 200.0), # Dynamic glazing
195
+ ]
196
+ return {mat.name: mat for mat in glazing_materials}
197
+
198
+ def initialize_door_materials(self) -> Dict[str, DoorMaterial]:
199
+ door_materials = [
200
+ # Door materials with ASHRAE-based properties and Australian pricing
201
+ DoorMaterial("Solid Wood 45mm", 2.5, 0.50, 15.0, 200.0), # Standard wooden door
202
+ DoorMaterial("Insulated Wood 50mm", 1.8, 0.45, 18.0, 250.0), # Better insulation
203
+ DoorMaterial("Hollow Core Wood 40mm", 3.5, 0.50, 12.0, 150.0), # Less insulation
204
+ DoorMaterial("Steel Uninsulated 45mm", 5.0, 0.70, 20.0, 180.0), # High U-value
205
+ DoorMaterial("Steel Insulated 50mm", 2.0, 0.65, 25.0, 220.0), # Foam-insulated
206
+ DoorMaterial("Aluminum Uninsulated 45mm", 6.0, 0.75, 22.0, 200.0), # Poor insulation
207
+ DoorMaterial("Aluminum Insulated 50mm", 2.5, 0.70, 28.0, 240.0), # Thermal break
208
+ DoorMaterial("Glass Single 6mm", 5.7, 0.78, 28.0, 100.0), # Same as single glazing
209
+ DoorMaterial("Glass Double 6mm/13mm Air", 2.7, 0.70, 40.0, 150.0), # Double-glazed door
210
+ DoorMaterial("Fiberglass Insulated 50mm", 1.5, 0.60, 20.0, 230.0), # High performance
211
+ DoorMaterial("PVC Insulated 50mm", 1.7, 0.55, 18.0, 210.0), # Durable, insulated
212
+ DoorMaterial("Wood with Glass Insert", 3.0, 0.65, 16.0, 190.0), # Mixed properties
213
+ ]
214
+ return {mat.name: mat for mat in door_materials}
215
+
216
+ def initialize_constructions(self) -> Dict[str, Construction]:
217
+ constructions = [
218
+ Construction("Light Exterior Wall", "Wall", [
219
+ {"material": self.library_materials["F08 Metal surface"], "thickness": 0.001},
220
+ {"material": self.library_materials["I02 50mm insulation board"], "thickness": 0.0508},
221
+ {"material": self.library_materials["F04 Wall air space resistance"], "thickness": 0.01},
222
+ {"material": self.library_materials["G01a 19mm gypsum board"], "thickness": 0.0191}
223
+ ]),
224
+ Construction("Light Roof/Ceiling", "Roof", [
225
+ {"material": self.library_materials["M11 100mm lightweight concrete"], "thickness": 0.1016},
226
+ {"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
227
+ {"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191}
228
+ ]),
229
+ Construction("Light Floor", "Floor", [
230
+ {"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191},
231
+ {"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
232
+ {"material": self.library_materials["M11 100mm lightweight concrete"], "thickness": 0.1016}
233
+ ]),
234
+ Construction("Medium Exterior Wall", "Wall", [
235
+ {"material": self.library_materials["M01 100mm brick"], "thickness": 0.1016},
236
+ {"material": self.library_materials["I02 50mm insulation board"], "thickness": 0.0508},
237
+ {"material": self.library_materials["F04 Wall air space resistance"], "thickness": 0.01},
238
+ {"material": self.library_materials["G01a 19mm gypsum board"], "thickness": 0.0191}
239
+ ]),
240
+ Construction("Medium Roof/Ceiling", "Roof", [
241
+ {"material": self.library_materials["M14a 100mm heavyweight concrete"], "thickness": 0.1016},
242
+ {"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
243
+ {"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191}
244
+ ]),
245
+ Construction("Medium Floor", "Floor", [
246
+ {"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191},
247
+ {"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
248
+ {"material": self.library_materials["M14a 100mm heavyweight concrete"], "thickness": 0.1016}
249
+ ]),
250
+ Construction("Heavy Exterior Wall", "Wall", [
251
+ {"material": self.library_materials["M01 100mm brick"], "thickness": 0.1016},
252
+ {"material": self.library_materials["M15 200mm heavyweight concrete"], "thickness": 0.2032},
253
+ {"material": self.library_materials["I02 50mm insulation board"], "thickness": 0.0508},
254
+ {"material": self.library_materials["F04 Wall air space resistance"], "thickness": 0.01},
255
+ {"material": self.library_materials["G01a 19mm gypsum board"], "thickness": 0.0191}
256
+ ]),
257
+ Construction("Heavy Roof/Ceiling", "Roof", [
258
+ {"material": self.library_materials["M15 200mm heavyweight concrete"], "thickness": 0.2032},
259
+ {"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
260
+ {"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191}
261
+ ]),
262
+ Construction("Heavy Floor", "Floor", [
263
+ {"material": self.library_materials["F16 Acoustic tile"], "thickness": 0.0191},
264
+ {"material": self.library_materials["F05 Ceiling air space resistance"], "thickness": 0.01},
265
+ {"material": self.library_materials["M15 200mm heavyweight concrete"], "thickness": 0.2032}
266
+ ]),
267
+ ]
268
+ return {cons.name: cons for cons in constructions}
269
+
270
+ def get_all_materials(self, project_materials: Optional[Dict[str, Material]] = None) -> List[Material]:
271
+ materials = list(self.library_materials.values())
272
+ if project_materials:
273
+ materials.extend(list(project_materials.values()))
274
+ return materials
275
+
276
+ def get_all_glazing_materials(self, project_glazing_materials: Optional[Dict[str, GlazingMaterial]] = None) -> List[GlazingMaterial]:
277
+ materials = list(self.library_glazing_materials.values())
278
+ if project_glazing_materials:
279
+ materials.extend(list(project_glazing_materials.values()))
280
+ return materials
281
+
282
+ def get_all_door_materials(self, project_door_materials: Optional[Dict[str, DoorMaterial]] = None) -> List[DoorMaterial]:
283
+ materials = list(self.library_door_materials.values())
284
+ if project_door_materials:
285
+ materials.extend(list(project_door_materials.values()))
286
+ return materials
287
+
288
+ def add_project_material(self, material: Material, project_materials: Dict[str, Material]) -> Tuple[bool, str]:
289
+ if len(project_materials) >= 20:
290
+ return False, "Maximum 20 project materials allowed."
291
+ if material.name in project_materials or material.name in self.library_materials:
292
+ return False, f"Material name '{material.name}' already exists."
293
+ project_materials[material.name] = material
294
+ return True, f"Material '{material.name}' added to project materials."
295
+
296
+ def add_project_glazing_material(self, material: GlazingMaterial, project_glazing_materials: Dict[str, GlazingMaterial]) -> Tuple[bool, str]:
297
+ if len(project_glazing_materials) >= 20:
298
+ return False, "Maximum 20 project glazing materials allowed."
299
+ if material.name in project_glazing_materials or material.name in self.library_glazing_materials:
300
+ return False, f"Glazing material name '{material.name}' already exists."
301
+ project_glazing_materials[material.name] = material
302
+ return True, f"Glazing material '{material.name}' added to project glazing materials."
303
+
304
+ def add_project_door_material(self, material: DoorMaterial, project_door_materials: Dict[str, DoorMaterial]) -> Tuple[bool, str]:
305
+ if len(project_door_materials) >= 20:
306
+ return False, "Maximum 20 project door materials allowed."
307
+ if material.name in project_door_materials or material.name in self.library_door_materials:
308
+ return False, f"Door material name '{material.name}' already exists."
309
+ project_door_materials[material.name] = material
310
+ return True, f"Door material '{material.name}' added to project door materials."
311
+
312
+ def edit_project_material(self, old_name: str, new_material: Material, project_materials: Dict[str, Material],
313
+ components: Dict[str, List]) -> Tuple[bool, str]:
314
+ if old_name not in project_materials:
315
+ return False, f"Material '{old_name}' not found in project materials."
316
+ if new_material.name != old_name and (new_material.name in project_materials or new_material.name in self.library_materials):
317
+ return False, f"Material name '{new_material.name}' already exists."
318
+ for comp_list in components.values():
319
+ for comp in comp_list:
320
+ if comp.construction and any(layer["material"].name == old_name for layer in comp.layers):
321
+ comp.layers = [{"material": new_material if layer["material"].name == old_name else layer["material"],
322
+ "thickness": layer["thickness"]} for layer in comp.layers]
323
+ comp.construction = Construction(
324
+ name=comp.construction.name,
325
+ component_type=comp.construction.component_type,
326
+ layers=comp.layers,
327
+ is_library=comp.construction.is_library
328
+ )
329
+ project_materials.pop(old_name)
330
+ project_materials[new_material.name] = new_material
331
+ return True, f"Material '{old_name}' updated to '{new_material.name}'."
332
+
333
+ def edit_project_glazing_material(self, old_name: str, new_material: GlazingMaterial,
334
+ project_glazing_materials: Dict[str, GlazingMaterial],
335
+ components: Dict[str, List]) -> Tuple[bool, str]:
336
+ if old_name not in project_glazing_materials:
337
+ return False, f"Glazing material '{old_name}' not found in project glazing materials."
338
+ if new_material.name != old_name and (new_material.name in project_glazing_materials or new_material.name in self.library_glazing_materials):
339
+ return False, f"Glazing material name '{new_material.name}' already exists."
340
+ for comp_list in components.values():
341
+ for comp in comp_list:
342
+ if comp.glazing_material and comp.glazing_material.name == old_name:
343
+ comp.glazing_material = new_material
344
+ comp.u_value = new_material.u_value
345
+ comp.shgc = new_material.shgc
346
+ project_glazing_materials.pop(old_name)
347
+ project_glazing_materials[new_material.name] = new_material
348
+ return True, f"Glazing material '{old_name}' updated to '{new_material.name}'."
349
+
350
+ def edit_project_door_material(self, old_name: str, new_material: DoorMaterial,
351
+ project_door_materials: Dict[str, DoorMaterial],
352
+ components: Dict[str, List]) -> Tuple[bool, str]:
353
+ if old_name not in project_door_materials:
354
+ return False, f"Door material '{old_name}' not found in project door materials."
355
+ if new_material.name != old_name and (new_material.name in project_door_materials or new_material.name in self.library_door_materials):
356
+ return False, f"Door material name '{new_material.name}' already exists."
357
+ for comp_list in components.values():
358
+ for comp in comp_list:
359
+ if comp.door_material and comp.door_material.name == old_name:
360
+ comp.door_material = new_material
361
+ comp.u_value = new_material.u_value
362
+ comp.solar_absorptivity = new_material.solar_absorption
363
+ project_door_materials.pop(old_name)
364
+ project_door_materials[new_material.name] = new_material
365
+ return True, f"Door material '{old_name}' updated to '{new_material.name}'."
366
+
367
+ def delete_project_material(self, name: str, project_materials: Dict[str, Material], components: Dict[str, List]) -> Tuple[bool, str]:
368
+ if name not in project_materials:
369
+ return False, f"Material '{name}' not found in project materials."
370
+ for cons in self.library_constructions.values():
371
+ if any(layer["material"].name == name for layer in cons.layers):
372
+ return False, f"Cannot delete '{name}' as it is used in library construction '{cons.name}'."
373
+ for comp_type, comp_list in components.items():
374
+ for comp in comp_list:
375
+ if 'layers' in comp and any(layer["material"].name == name for layer in comp["layers"]):
376
+ return False, f"Cannot delete '{name}' as it is used in component '{comp['name']}' ({comp_type})."
377
+ del project_materials[name]
378
+ return True, f"Material '{name}' deleted successfully."
379
+
380
+ def delete_project_glazing_material(self, name: str, project_glazing_materials: Dict[str, GlazingMaterial],
381
+ components: Dict[str, List]) -> Tuple[bool, str]:
382
+ if name not in project_glazing_materials:
383
+ return False, f"Glazing material '{name}' not found in project glazing materials."
384
+ for comp_list in components.values():
385
+ for comp in comp_list:
386
+ if comp.glazing_material and comp.glazing_material.name == name:
387
+ return False, f"Cannot delete '{name}' as it is used in component '{comp.name}'."
388
+ del project_glazing_materials[name]
389
+ return True, f"Glazing material '{name}' deleted successfully."
390
+
391
+ def delete_project_door_material(self, name: str, project_door_materials: Dict[str, DoorMaterial],
392
+ components: Dict[str, List]) -> Tuple[bool, str]:
393
+ if name not in project_door_materials:
394
+ return False, f"Door material '{name}' not found in project door materials."
395
+ for comp_list in components.values():
396
+ for comp in comp_list:
397
+ if comp.door_material and comp.door_material.name == name:
398
+ return False, f"Cannot delete '{name}' as it is used in component '{comp.name}'."
399
+ del project_door_materials[name]
400
+ return True, f"Door material '{name}' deleted successfully."
401
+
402
+ def add_project_construction(self, construction: Construction, project_constructions: Dict[str, Construction]) -> Tuple[bool, str]:
403
+ if len(project_constructions) >= 20:
404
+ return False, "Maximum 20 project constructions allowed."
405
+ if construction.name in project_constructions or construction.name in self.library_constructions:
406
+ return False, f"Construction name '{construction.name}' already exists."
407
+ project_constructions[construction.name] = construction
408
+ return True, f"Construction '{construction.name}' added to project constructions."
409
+
410
+ def edit_project_construction(self, old_name: str, new_construction: Construction,
411
+ project_constructions: Dict[str, Construction],
412
+ components: Dict[str, List]) -> Tuple[bool, str]:
413
+ if old_name not in project_constructions:
414
+ return False, f"Construction '{old_name}' not found in project constructions."
415
+ if new_construction.name != old_name and (new_construction.name in project_constructions or new_construction.name in self.library_constructions):
416
+ return False, f"Construction name '{new_construction.name}' already exists."
417
+ for comp_list in components.values():
418
+ for comp in comp_list:
419
+ if comp.construction and comp.construction.name == old_name:
420
+ comp.construction = new_construction
421
+ comp.layers = new_construction.layers
422
+ comp.u_value = new_construction.u_value
423
+ project_constructions.pop(old_name)
424
+ project_constructions[new_construction.name] = new_construction
425
+ return True, f"Construction '{old_name}' updated to '{new_construction.name}'."
426
+
427
+ def delete_project_construction(self, name: str, project_constructions: Dict[str, Construction],
428
+ components: Dict[str, List]) -> Tuple[bool, str]:
429
+ if name not in project_constructions:
430
+ return False, f"Construction '{name}' not found in project constructions."
431
+ for comp_list in components.values():
432
+ for comp in comp_list:
433
+ if comp.construction and comp.construction.name == name:
434
+ return False, f"Construction '{name}' is used in component '{comp.name}'."
435
+ project_constructions.pop(name)
436
+ return True, f"Construction '{name}' deleted."
437
+
438
+ def to_dataframe(self, data_type: str, project_materials: Optional[Dict[str, Material]] = None,
439
+ project_constructions: Optional[Dict[str, Construction]] = None,
440
+ project_glazing_materials: Optional[Dict[str, GlazingMaterial]] = None,
441
+ project_door_materials: Optional[Dict[str, DoorMaterial]] = None,
442
+ only_project: bool = False) -> pd.DataFrame:
443
+ if data_type == "materials":
444
+ data = []
445
+ materials = project_materials.values() if only_project else self.get_all_materials(project_materials)
446
+ for mat in materials:
447
+ data.append({
448
+ "Name": mat.name,
449
+ "Category": mat.category.value,
450
+ "Conductivity (W/m·K)": mat.conductivity,
451
+ "Density (kg/m³)": mat.density,
452
+ "Specific Heat (J/kg·K)": mat.specific_heat,
453
+ "Default Thickness (m)": mat.default_thickness,
454
+ "Embodied Carbon (kgCO₂e/kg)": mat.embodied_carbon,
455
+ "Solar Absorption": mat.solar_absorption,
456
+ "Price (USD/m²)": mat.price,
457
+ "Source": "Project" if not mat.is_library else "Library"
458
+ })
459
+ return pd.DataFrame(data)
460
+ elif data_type == "constructions":
461
+ data = []
462
+ constructions = project_constructions.values() if only_project else list(self.library_constructions.values())
463
+ if not only_project and project_constructions:
464
+ constructions.extend(list(project_constructions.values()))
465
+ for cons in constructions:
466
+ layers_str = "; ".join(f"{layer['material'].name} ({layer['thickness']}m)" for layer in cons.layers)
467
+ data.append({
468
+ "Name": cons.name,
469
+ "Component Type": cons.component_type,
470
+ "U-Value (W/m²·K)": cons.u_value,
471
+ "Total Thickness (m)": cons.total_thickness,
472
+ "Embodied Carbon (kgCO₂e/m²)": cons.embodied_carbon,
473
+ "Solar Absorption": cons.solar_absorption,
474
+ "Price (USD/m²)": cons.price,
475
+ "Layers": layers_str,
476
+ "Source": "Project" if not cons.is_library else "Library"
477
+ })
478
+ return pd.DataFrame(data)
479
+ elif data_type == "glazing_materials":
480
+ data = []
481
+ glazing_materials = project_glazing_materials.values() if only_project else self.get_all_glazing_materials(project_glazing_materials)
482
+ for mat in glazing_materials:
483
+ data.append({
484
+ "Name": mat.name,
485
+ "U-Value (W/m²·K)": mat.u_value,
486
+ "SHGC": mat.shgc,
487
+ "Embodied Carbon (kgCO₂e/m²)": mat.embodied_carbon,
488
+ "Price (USD/m²)": mat.price,
489
+ "Source": "Project" if not mat.is_library else "Library"
490
+ })
491
+ return pd.DataFrame(data)
492
+ elif data_type == "door_materials":
493
+ data = []
494
+ door_materials = project_door_materials.values() if only_project else self.get_all_door_materials(project_door_materials)
495
+ for mat in door_materials:
496
+ data.append({
497
+ "Name": mat.name,
498
+ "U-Value (W/m²·K)": mat.u_value,
499
+ "Solar Absorption": mat.solar_absorption,
500
+ "Embodied Carbon (kgCO₂e/m²)": mat.embodied_carbon,
501
+ "Price (USD/m²)": mat.price,
502
+ "Source": "Project" if not mat.is_library else "Library"
503
+ })
504
+ return pd.DataFrame(data)
505
+ return pd.DataFrame()