mabuseif commited on
Commit
1fc4d66
·
verified ·
1 Parent(s): 3a6ce93

Update data/climate_data.py

Browse files
Files changed (1) hide show
  1. data/climate_data.py +489 -478
data/climate_data.py CHANGED
@@ -1,479 +1,490 @@
1
- """
2
- ASHRAE 169 climate data module for HVAC Load Calculator.
3
- This module provides access to climate data for various locations based on ASHRAE 169 standard.
4
-
5
- Author: Dr Majed Abuseif
6
- Date: March 2025
7
- Version: 1.0.0
8
- """
9
-
10
- from typing import Dict, List, Any, Optional, Tuple
11
- import pandas as pd
12
- import numpy as np
13
- import os
14
- import json
15
- from dataclasses import dataclass
16
- import streamlit as st
17
- import plotly.graph_objects as go
18
- from io import StringIO
19
-
20
- # Define paths
21
- DATA_DIR = os.path.dirname(os.path.abspath(__file__))
22
-
23
-
24
- @dataclass
25
- class ClimateLocation:
26
- """Class representing a climate location with ASHRAE 169 data."""
27
-
28
- id: str
29
- country: str
30
- state_province: str
31
- city: str
32
- latitude: float
33
- longitude: float
34
- elevation: float # meters
35
- climate_zone: str
36
- heating_degree_days: float # base 18°C
37
- cooling_degree_days: float # base 18°C
38
- winter_design_temp: float # 99.6% heating design temperature (°C)
39
- summer_design_temp_db: float # 0.4% cooling design dry-bulb temperature (°C)
40
- summer_design_temp_wb: float # 0.4% cooling design wet-bulb temperature (°C)
41
- summer_daily_range: float # Mean daily temperature range in summer (°C)
42
- monthly_temps: Dict[str, float] # Average monthly temperatures (°C)
43
- monthly_humidity: Dict[str, float] # Average monthly relative humidity (%)
44
-
45
- def to_dict(self) -> Dict[str, Any]:
46
- """Convert the climate location to a dictionary."""
47
- return {
48
- "id": self.id,
49
- "country": self.country,
50
- "state_province": self.state_province,
51
- "city": self.city,
52
- "latitude": self.latitude,
53
- "longitude": self.longitude,
54
- "elevation": self.elevation,
55
- "climate_zone": self.climate_zone,
56
- "heating_degree_days": self.heating_degree_days,
57
- "cooling_degree_days": self.cooling_degree_days,
58
- "winter_design_temp": self.winter_design_temp,
59
- "summer_design_temp_db": self.summer_design_temp_db,
60
- "summer_design_temp_wb": self.summer_design_temp_wb,
61
- "summer_daily_range": self.summer_daily_range,
62
- "monthly_temps": self.monthly_temps,
63
- "monthly_humidity": self.monthly_humidity
64
- }
65
-
66
-
67
- class ClimateData:
68
- """Class for managing ASHRAE 169 climate data."""
69
-
70
- def __init__(self):
71
- """Initialize climate data."""
72
- self.locations = {}
73
- self.countries = []
74
- self.country_states = {}
75
-
76
- def _group_locations_by_country_state(self) -> Dict[str, Dict[str, List[str]]]:
77
- """Group locations by country and state/province."""
78
- result = {}
79
- for loc in self.locations.values():
80
- if loc.country not in result:
81
- result[loc.country] = {}
82
- if loc.state_province not in result[loc.country]:
83
- result[loc.country][loc.state_province] = []
84
- result[loc.country][loc.state_province].append(loc.city)
85
- for country in result:
86
- for state in result[country]:
87
- result[country][state] = sorted(result[country][state])
88
- return result
89
-
90
- def add_location(self, location: ClimateLocation):
91
- """Add a new location to the dictionary."""
92
- self.locations[location.id] = location
93
- self.countries = sorted(list(set(loc.country for loc in self.locations.values())))
94
- self.country_states = self._group_locations_by_country_state()
95
-
96
- def _infer_epw_columns(self, epw_data: pd.DataFrame) -> Dict[str, int]:
97
- """Infer column indices for key weather parameters based on data ranges."""
98
- column_map = {}
99
- for col in epw_data.columns:
100
- values = epw_data[col].values
101
- mean_val = np.mean(values)
102
- min_val = np.min(values)
103
- max_val = np.max(values)
104
-
105
- # Dry Bulb Temperature (°C): -50 to 50°C
106
- if -50 <= min_val <= max_val <= 50 and col not in column_map:
107
- column_map["dry_bulb"] = col
108
- # Wet Bulb Temperature (°C): -50 to 40°C, usually less than dry bulb
109
- elif -50 <= min_val <= max_val <= 40 and col not in column_map:
110
- column_map["wet_bulb"] = col
111
- # Relative Humidity (%): 0 to 100%
112
- elif 0 <= min_val <= max_val <= 100 and col not in column_map:
113
- column_map["humidity"] = col
114
- # Atmospheric Pressure (Pa): 80000 to 105000 Pa
115
- elif 80000 <= min_val <= max_val <= 105000 and col not in column_map:
116
- column_map["pressure"] = col
117
-
118
- # Standard EPW column indices as fallback
119
- standard_map = {
120
- "dry_bulb": 6,
121
- "wet_bulb": 8,
122
- "humidity": 21,
123
- "pressure": 9
124
- }
125
-
126
- # Validate inferred columns against standard, warn if mismatched
127
- for key in standard_map:
128
- if key not in column_map:
129
- st.warning(f"Could not infer {key} column. Using standard EPW index {standard_map[key]}.")
130
- column_map[key] = standard_map[key]
131
- elif column_map[key] != standard_map[key]:
132
- st.warning(f"Inferred {key} column ({column_map[key]}) differs from standard EPW ({standard_map[key]}). Proceeding with inferred column.")
133
-
134
- return column_map
135
-
136
- def display_climate_input(self, session_state: Dict[str, Any]):
137
- """Display form for manual input or EPW upload in Streamlit."""
138
- st.title("Climate Data")
139
-
140
- if not session_state.building_info.get("country") or not session_state.building_info.get("city"):
141
- st.warning("Please enter country and city in Building Information first.")
142
- st.button("Go to Building Information", on_click=lambda: setattr(session_state, "page", "Building Information"))
143
- return
144
-
145
- st.subheader(f"Location: {session_state.building_info['country']}, {session_state.building_info['city']}")
146
- tab1, tab2 = st.tabs(["Manual Input", "Upload EPW File"])
147
-
148
- # Manual Input Tab
149
- with tab1:
150
- with st.form("manual_climate_form"):
151
- col1, col2 = st.columns(2)
152
- with col1:
153
- id_input = st.text_input("Location ID", value=f"{session_state.building_info['country'][:2].upper()}-{session_state.building_info['city'][:3].upper()}")
154
- state_province = st.text_input("State/Province", value="N/A")
155
- latitude = st.number_input("Latitude", min_value=-90.0, max_value=90.0, value=0.0, step=0.1)
156
- longitude = st.number_input("Longitude", min_value=-180.0, max_value=180.0, value=0.0, step=0.1)
157
- elevation = st.number_input("Elevation (m)", min_value=0.0, value=0.0, step=10.0)
158
- climate_zone = st.selectbox("Climate Zone", ["0A", "0B", "1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"])
159
-
160
- with col2:
161
- hdd = st.number_input("Heating Degree Days (base 18°C)", min_value=0.0, value=0.0, step=100.0)
162
- cdd = st.number_input("Cooling Degree Days (base 18°C)", min_value=0.0, value=0.0, step=100.0)
163
- winter_design_temp = st.number_input("Winter Design Temp (99.6%) (°C)", min_value=-50.0, max_value=20.0, value=0.0, step=0.5)
164
- summer_design_temp_db = st.number_input("Summer Design Temp DB (0.4%) (°C)", min_value=0.0, max_value=50.0, value=35.0, step=0.5)
165
- summer_design_temp_wb = st.number_input("Summer Design Temp WB (0.4%) (°C)", min_value=0.0, max_value=40.0, value=25.0, step=0.5)
166
- summer_daily_range = st.number_input("Summer Daily Range (°C)", min_value=0.0, value=5.0, step=0.5)
167
-
168
- st.subheader("Monthly Data")
169
- monthly_temps = {}
170
- monthly_humidity = {}
171
- month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
172
- col1, col2 = st.columns(2)
173
- with col1:
174
- for month in month_names[:6]:
175
- monthly_temps[month] = st.number_input(f"{month} Temp (°C)", min_value=-50.0, max_value=50.0, value=20.0, step=0.5, key=f"temp_{month}")
176
- with col2:
177
- for month in month_names[6:]:
178
- monthly_temps[month] = st.number_input(f"{month} Temp (°C)", min_value=-50.0, max_value=50.0, value=20.0, step=0.5, key=f"temp_{month}")
179
- col1, col2 = st.columns(2)
180
- with col1:
181
- for month in month_names[:6]:
182
- monthly_humidity[month] = st.number_input(f"{month} Humidity (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key=f"hum_{month}")
183
- with col2:
184
- for month in month_names[6:]:
185
- monthly_humidity[month] = st.number_input(f"{month} Humidity (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key=f"hum_{month}")
186
-
187
- if st.form_submit_button("Save Climate Data"):
188
- location = ClimateLocation(
189
- id=id_input,
190
- country=session_state.building_info["country"],
191
- state_province=state_province,
192
- city=session_state.building_info["city"],
193
- latitude=latitude,
194
- longitude=longitude,
195
- elevation=elevation,
196
- climate_zone=climate_zone,
197
- heating_degree_days=hdd,
198
- cooling_degree_days=cdd,
199
- winter_design_temp=winter_design_temp,
200
- summer_design_temp_db=summer_design_temp_db,
201
- summer_design_temp_wb=summer_design_temp_wb,
202
- summer_daily_range=summer_daily_range,
203
- monthly_temps=monthly_temps,
204
- monthly_humidity=monthly_humidity
205
- )
206
- self.add_location(location)
207
- st.success("Climate data saved manually!")
208
- self.display_design_conditions(location)
209
- self.visualize_data(location, epw_data=None) # No EPW data for manual input
210
-
211
- # EPW Upload Tab
212
- with tab2:
213
- uploaded_file = st.file_uploader("Upload EPW File", type=["epw"])
214
- if uploaded_file:
215
- try:
216
- epw_content = uploaded_file.read().decode("utf-8")
217
- epw_lines = epw_content.splitlines()
218
- header = next(line for line in epw_lines if line.startswith("LOCATION"))
219
- header_parts = header.split(",")
220
- latitude = float(header_parts[6])
221
- longitude = float(header_parts[7])
222
- elevation = float(header_parts[8])
223
-
224
- epw_data = pd.read_csv(StringIO("\n".join(epw_lines[8:])), header=None)
225
- if len(epw_data) != 8760:
226
- raise ValueError("EPW file must contain 8760 hourly records.")
227
-
228
- # Infer column indices
229
- column_map = self._infer_epw_columns(epw_data)
230
-
231
- # Extract data with inferred columns
232
- months = epw_data[1].values # Month column
233
- dry_bulb = epw_data[column_map["dry_bulb"]].values
234
- wet_bulb = epw_data[column_map["wet_bulb"]].values
235
- humidity = epw_data[column_map["humidity"]].values
236
-
237
- # Calculate daily averages for HDD/CDD
238
- daily_temps = dry_bulb.reshape(-1, 24).mean(axis=1)
239
- hdd = round(sum(max(18 - temp, 0) for temp in daily_temps))
240
- cdd = round(sum(max(temp - 18, 0) for temp in daily_temps))
241
-
242
- # Design conditions
243
- winter_design_temp = round(np.percentile(dry_bulb, 0.4), 1)
244
- summer_design_temp_db = round(np.percentile(dry_bulb, 99.6), 1)
245
- summer_idx = np.argmax(dry_bulb >= summer_design_temp_db)
246
- summer_design_temp_wb = round(wet_bulb[summer_idx], 1)
247
- summer_mask = (months >= 6) & (months <= 8)
248
- summer_temps = dry_bulb[summer_mask].reshape(-1, 24)
249
- summer_daily_range = round(np.mean(summer_temps.max(axis=1) - summer_temps.min(axis=1)), 1)
250
-
251
- # Monthly averages, mins, and maxes (stored only averages in ClimateLocation)
252
- monthly_temps = {}
253
- monthly_humidity = {}
254
- month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
255
- for i in range(1, 13):
256
- month_mask = (months == i)
257
- monthly_temps[month_names[i-1]] = round(np.mean(dry_bulb[month_mask]), 1)
258
- monthly_humidity[month_names[i-1]] = round(np.mean(humidity[month_mask]), 1)
259
-
260
- avg_humidity = np.mean(humidity)
261
- climate_zone = self.assign_climate_zone(hdd, cdd, avg_humidity)
262
-
263
- location = ClimateLocation(
264
- id=f"{session_state.building_info['country'][:2].upper()}-{session_state.building_info['city'][:3].upper()}",
265
- country=session_state.building_info["country"],
266
- state_province="N/A",
267
- city=session_state.building_info["city"],
268
- latitude=latitude,
269
- longitude=longitude,
270
- elevation=elevation,
271
- climate_zone=climate_zone,
272
- heating_degree_days=hdd,
273
- cooling_degree_days=cdd,
274
- winter_design_temp=winter_design_temp,
275
- summer_design_temp_db=summer_design_temp_db,
276
- summer_design_temp_wb=summer_design_temp_wb,
277
- summer_daily_range=summer_daily_range,
278
- monthly_temps=monthly_temps,
279
- monthly_humidity=monthly_humidity
280
- )
281
- self.add_location(location)
282
- st.success("Climate data extracted from EPW file!")
283
- self.display_design_conditions(location)
284
- self.visualize_data(location, epw_data=epw_data, column_map=column_map)
285
- except Exception as e:
286
- st.error(f"Error processing EPW file: {str(e)}. Ensure it has 8760 hourly records and correct format.")
287
-
288
- # Navigation buttons
289
- col1, col2 = st.columns(2)
290
- with col1:
291
- st.button("Back to Building Information", on_click=lambda: setattr(session_state, "page", "Building Information"))
292
- with col2:
293
- if self.locations:
294
- st.button("Continue to Building Components", on_click=lambda: setattr(session_state, "page", "Building Components"))
295
- else:
296
- st.button("Continue to Building Components", disabled=True)
297
-
298
- def display_design_conditions(self, location: ClimateLocation):
299
- """Display a table of design conditions for calculations."""
300
- st.subheader("Design Conditions for HVAC Calculations")
301
- design_data = pd.DataFrame({
302
- "Parameter": [
303
- "Climate Zone",
304
- "Heating Degree Days (base 18°C)",
305
- "Cooling Degree Days (base 18°C)",
306
- "Winter Design Temperature (99.6%)",
307
- "Summer Design Dry-Bulb Temp (0.4%)",
308
- "Summer Design Wet-Bulb Temp (0.4%)",
309
- "Summer Daily Temperature Range"
310
- ],
311
- "Value": [
312
- location.climate_zone,
313
- f"{location.heating_degree_days} HDD",
314
- f"{location.cooling_degree_days} CDD",
315
- f"{location.winter_design_temp} °C",
316
- f"{location.summer_design_temp_db} °C",
317
- f"{location.summer_design_temp_wb} °C",
318
- f"{location.summer_daily_range} °C"
319
- ]
320
- })
321
- st.table(design_data)
322
-
323
- @staticmethod
324
- def assign_climate_zone(hdd: float, cdd: float, avg_humidity: float) -> str:
325
- """Assign ASHRAE 169 climate zone based on HDD, CDD, and humidity."""
326
- if cdd > 10000:
327
- return "0A" if avg_humidity > 60 else "0B"
328
- elif cdd > 5000:
329
- return "1A" if avg_humidity > 60 else "1B"
330
- elif cdd > 2500:
331
- return "2A" if avg_humidity > 60 else "2B"
332
- elif hdd < 2000 and cdd > 1000:
333
- return "3A" if avg_humidity > 60 else "3B" if avg_humidity < 40 else "3C"
334
- elif hdd < 3000:
335
- return "4A" if avg_humidity > 60 else "4B" if avg_humidity < 40 else "4C"
336
- elif hdd < 4000:
337
- return "5A" if avg_humidity > 60 else "5B" if avg_humidity < 40 else "5C"
338
- elif hdd < 5000:
339
- return "6A" if avg_humidity > 60 else "6B"
340
- elif hdd < 7000:
341
- return "7"
342
- else:
343
- return "8"
344
-
345
- def visualize_data(self, location: ClimateLocation, epw_data: Optional[pd.DataFrame] = None, column_map: Optional[Dict[str, int]] = None):
346
- """Visualize monthly temperature and humidity data with min, max, and average."""
347
- st.subheader("Monthly Climate Data Visualization")
348
-
349
- months = list(range(1, 13))
350
- month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
351
- temps_avg = [location.monthly_temps[m] for m in month_names]
352
- humidity_avg = [location.monthly_humidity[m] for m in month_names]
353
-
354
- # If EPW data is provided, calculate min/max from raw data
355
- if epw_data is not None and column_map is not None:
356
- dry_bulb = epw_data[column_map["dry_bulb"]].values
357
- humidity = epw_data[column_map["humidity"]].values
358
- month_col = epw_data[1].values
359
-
360
- temps_min = []
361
- temps_max = []
362
- humidity_min = []
363
- humidity_max = []
364
- for i in range(1, 13):
365
- month_mask = (month_col == i)
366
- temps_min.append(round(np.min(dry_bulb[month_mask]), 1))
367
- temps_max.append(round(np.max(dry_bulb[month_mask]), 1))
368
- humidity_min.append(round(np.min(humidity[month_mask]), 1))
369
- humidity_max.append(round(np.max(humidity[month_mask]), 1))
370
- else:
371
- # For manual input, only show averages (no min/max available)
372
- temps_min = temps_avg
373
- temps_max = temps_avg
374
- humidity_min = humidity_avg
375
- humidity_max = humidity_avg
376
-
377
- # Temperature Plot
378
- fig_temp = go.Figure()
379
- fig_temp.add_trace(go.Scatter(
380
- x=months,
381
- y=temps_avg,
382
- mode='lines+markers',
383
- name='Avg Temperature (°C)',
384
- line=dict(color='red'),
385
- marker=dict(size=8)
386
- ))
387
- fig_temp.add_trace(go.Scatter(
388
- x=months,
389
- y=temps_max,
390
- mode='lines',
391
- name='Max Temperature (°C)',
392
- line=dict(color='red', dash='dash'),
393
- opacity=0.5
394
- ))
395
- fig_temp.add_trace(go.Scatter(
396
- x=months,
397
- y=temps_min,
398
- mode='lines',
399
- name='Min Temperature (°C)',
400
- line=dict(color='red', dash='dash'),
401
- opacity=0.5,
402
- fill='tonexty', # Fill area between min and max
403
- fillcolor='rgba(255, 0, 0, 0.1)'
404
- ))
405
- fig_temp.update_layout(
406
- title='Monthly Temperatures',
407
- xaxis_title='Month',
408
- yaxis_title='Temperature (°C)',
409
- xaxis=dict(tickmode='array', tickvals=months, ticktext=month_names),
410
- legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)
411
- )
412
- st.plotly_chart(fig_temp, use_container_width=True)
413
-
414
- # Humidity Plot
415
- fig_hum = go.Figure()
416
- fig_hum.add_trace(go.Scatter(
417
- x=months,
418
- y=humidity_avg,
419
- mode='lines+markers',
420
- name='Avg Humidity (%)',
421
- line=dict(color='blue'),
422
- marker=dict(size=8)
423
- ))
424
- fig_hum.add_trace(go.Scatter(
425
- x=months,
426
- y=humidity_max,
427
- mode='lines',
428
- name='Max Humidity (%)',
429
- line=dict(color='blue', dash='dash'),
430
- opacity=0.5
431
- ))
432
- fig_hum.add_trace(go.Scatter(
433
- x=months,
434
- y=humidity_min,
435
- mode='lines',
436
- name='Min Humidity (%)',
437
- line=dict(color='blue', dash='dash'),
438
- opacity=0.5,
439
- fill='tonexty', # Fill area between min and max
440
- fillcolor='rgba(0, 0, 255, 0.1)'
441
- ))
442
- fig_hum.update_layout(
443
- title='Monthly Relative Humidity',
444
- xaxis_title='Month',
445
- yaxis_title='Relative Humidity (%)',
446
- xaxis=dict(tickmode='array', tickvals=months, ticktext=month_names),
447
- legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)
448
- )
449
- st.plotly_chart(fig_hum, use_container_width=True)
450
-
451
- def export_to_json(self, file_path: str) -> None:
452
- """Export all climate data to a JSON file."""
453
- data = {loc_id: loc.to_dict() for loc_id, loc in self.locations.items()}
454
- with open(file_path, 'w') as f:
455
- json.dump(data, f, indent=4)
456
-
457
- @classmethod
458
- def from_json(cls, file_path: str) -> 'ClimateData':
459
- """Create a ClimateData instance from a JSON file."""
460
- with open(file_path, 'r') as f:
461
- data = json.load(f)
462
- climate_data = cls()
463
- climate_data.locations = {}
464
- for loc_id, loc_dict in data.items():
465
- climate_data.locations[loc_id] = ClimateLocation(**loc_dict)
466
- climate_data.countries = sorted(list(set(loc.country for loc in climate_data.locations.values())))
467
- climate_data.country_states = climate_data._group_locations_by_country_state()
468
- return climate_data
469
-
470
-
471
- if __name__ == "__main__":
472
- # Ensure session_state is initialized for standalone testing
473
- if "building_info" not in st.session_state:
474
- st.session_state.building_info = {"country": "Australia", "city": "Melbourne"}
475
- if "page" not in st.session_state:
476
- st.session_state.page = "Climate Data"
477
-
478
- climate_data = ClimateData()
 
 
 
 
 
 
 
 
 
 
 
479
  climate_data.display_climate_input(st.session_state)
 
1
+ """
2
+ ASHRAE 169 climate data module for HVAC Load Calculator.
3
+ This module provides access to climate data for various locations based on ASHRAE 169 standard.
4
+
5
+ Author: Dr Majed Abuseif
6
+ Date: March 2025
7
+ Version: 1.0.0
8
+ """
9
+
10
+ from typing import Dict, List, Any, Optional, Tuple
11
+ import pandas as pd
12
+ import numpy as np
13
+ import os
14
+ import json
15
+ from dataclasses import dataclass
16
+ import streamlit as st
17
+ import plotly.graph_objects as go
18
+ from io import StringIO
19
+
20
+ # Define paths
21
+ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
22
+
23
+
24
+ @dataclass
25
+ class ClimateLocation:
26
+ """Class representing a climate location with ASHRAE 169 data."""
27
+
28
+ id: str
29
+ country: str
30
+ state_province: str
31
+ city: str
32
+ latitude: float
33
+ longitude: float
34
+ elevation: float # meters
35
+ climate_zone: str
36
+ heating_degree_days: float # base 18°C
37
+ cooling_degree_days: float # base 18°C
38
+ winter_design_temp: float # 99.6% heating design temperature (°C)
39
+ summer_design_temp_db: float # 0.4% cooling design dry-bulb temperature (°C)
40
+ summer_design_temp_wb: float # 0.4% cooling design wet-bulb temperature (°C)
41
+ summer_daily_range: float # Mean daily temperature range in summer (°C)
42
+ monthly_temps: Dict[str, float] # Average monthly temperatures (°C)
43
+ monthly_humidity: Dict[str, float] # Average monthly relative humidity (%)
44
+
45
+ def to_dict(self) -> Dict[str, Any]:
46
+ """Convert the climate location to a dictionary."""
47
+ return {
48
+ "id": self.id,
49
+ "country": self.country,
50
+ "state_province": self.state_province,
51
+ "city": self.city,
52
+ "latitude": self.latitude,
53
+ "longitude": self.longitude,
54
+ "elevation": self.elevation,
55
+ "climate_zone": self.climate_zone,
56
+ "heating_degree_days": self.heating_degree_days,
57
+ "cooling_degree_days": self.cooling_degree_days,
58
+ "winter_design_temp": self.winter_design_temp,
59
+ "summer_design_temp_db": self.summer_design_temp_db,
60
+ "summer_design_temp_wb": self.summer_design_temp_wb,
61
+ "summer_daily_range": self.summer_daily_range,
62
+ "monthly_temps": self.monthly_temps,
63
+ "monthly_humidity": self.monthly_humidity
64
+ }
65
+
66
+
67
+ class ClimateData:
68
+ """Class for managing ASHRAE 169 climate data."""
69
+
70
+ def __init__(self):
71
+ """Initialize climate data."""
72
+ self.locations = {}
73
+ self.countries = []
74
+ self.country_states = {}
75
+
76
+ def _group_locations_by_country_state(self) -> Dict[str, Dict[str, List[str]]]:
77
+ """Group locations by country and state/province."""
78
+ result = {}
79
+ for loc in self.locations.values():
80
+ if loc.country not in result:
81
+ result[loc.country] = {}
82
+ if loc.state_province not in result[loc.country]:
83
+ result[loc.country][loc.state_province] = []
84
+ result[loc.country][loc.state_province].append(loc.city)
85
+ for country in result:
86
+ for state in result[country]:
87
+ result[country][state] = sorted(result[country][state])
88
+ return result
89
+
90
+ def add_location(self, location: ClimateLocation):
91
+ """Add a new location to the dictionary."""
92
+ self.locations[location.id] = location
93
+ self.countries = sorted(list(set(loc.country for loc in self.locations.values())))
94
+ self.country_states = self._group_locations_by_country_state()
95
+
96
+ def _infer_epw_columns(self, epw_data: pd.DataFrame) -> Dict[str, int]:
97
+ """Infer column indices for key weather parameters based on data ranges."""
98
+ column_map = {}
99
+ for col in epw_data.columns:
100
+ values = pd.to_numeric(epw_data[col], errors='coerce')
101
+ if values.isna().all():
102
+ continue # Skip if all values are NaN
103
+ mean_val = np.nanmean(values)
104
+ min_val = np.nanmin(values)
105
+ max_val = np.nanmax(values)
106
+
107
+ # Dry Bulb Temperature (°C): -50 to 50°C
108
+ if -50 <= min_val <= max_val <= 50 and col not in column_map:
109
+ column_map["dry_bulb"] = col
110
+ # Wet Bulb Temperature (°C): -50 to 40°C
111
+ elif -50 <= min_val <= max_val <= 40 and col not in column_map:
112
+ column_map["wet_bulb"] = col
113
+ # Relative Humidity (%): 0 to 100%
114
+ elif 0 <= min_val <= max_val <= 100 and col not in column_map:
115
+ column_map["humidity"] = col
116
+ # Atmospheric Pressure (Pa): 80000 to 105000 Pa
117
+ elif 80000 <= min_val <= max_val <= 105000 and col not in column_map:
118
+ column_map["pressure"] = col
119
+
120
+ # Standard EPW column indices as fallback
121
+ standard_map = {
122
+ "dry_bulb": 6,
123
+ "wet_bulb": 8,
124
+ "humidity": 21,
125
+ "pressure": 9
126
+ }
127
+
128
+ for key in standard_map:
129
+ if key not in column_map:
130
+ st.warning(f"Could not infer {key} column. Using standard EPW index {standard_map[key]}.")
131
+ column_map[key] = standard_map[key]
132
+ elif column_map[key] != standard_map[key]:
133
+ st.warning(f"Inferred {key} column ({column_map[key]}) differs from standard EPW ({standard_map[key]}). Proceeding with inferred column.")
134
+
135
+ return column_map
136
+
137
+ def display_climate_input(self, session_state: Dict[str, Any]):
138
+ """Display form for manual input or EPW upload in Streamlit."""
139
+ st.title("Climate Data")
140
+
141
+ if not session_state.building_info.get("country") or not session_state.building_info.get("city"):
142
+ st.warning("Please enter country and city in Building Information first.")
143
+ st.button("Go to Building Information", on_click=lambda: setattr(session_state, "page", "Building Information"))
144
+ return
145
+
146
+ st.subheader(f"Location: {session_state.building_info['country']}, {session_state.building_info['city']}")
147
+ tab1, tab2 = st.tabs(["Manual Input", "Upload EPW File"])
148
+
149
+ # Manual Input Tab
150
+ with tab1:
151
+ with st.form("manual_climate_form"):
152
+ col1, col2 = st.columns(2)
153
+ with col1:
154
+ id_input = st.text_input("Location ID", value=f"{session_state.building_info['country'][:2].upper()}-{session_state.building_info['city'][:3].upper()}")
155
+ state_province = st.text_input("State/Province", value="N/A")
156
+ latitude = st.number_input("Latitude", min_value=-90.0, max_value=90.0, value=0.0, step=0.1)
157
+ longitude = st.number_input("Longitude", min_value=-180.0, max_value=180.0, value=0.0, step=0.1)
158
+ elevation = st.number_input("Elevation (m)", min_value=0.0, value=0.0, step=10.0)
159
+ climate_zone = st.selectbox("Climate Zone", ["0A", "0B", "1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"])
160
+
161
+ with col2:
162
+ hdd = st.number_input("Heating Degree Days (base 18°C)", min_value=0.0, value=0.0, step=100.0)
163
+ cdd = st.number_input("Cooling Degree Days (base 18°C)", min_value=0.0, value=0.0, step=100.0)
164
+ winter_design_temp = st.number_input("Winter Design Temp (99.6%) (°C)", min_value=-50.0, max_value=20.0, value=0.0, step=0.5)
165
+ summer_design_temp_db = st.number_input("Summer Design Temp DB (0.4%) (°C)", min_value=0.0, max_value=50.0, value=35.0, step=0.5)
166
+ summer_design_temp_wb = st.number_input("Summer Design Temp WB (0.4%) (°C)", min_value=0.0, max_value=40.0, value=25.0, step=0.5)
167
+ summer_daily_range = st.number_input("Summer Daily Range (°C)", min_value=0.0, value=5.0, step=0.5)
168
+
169
+ st.subheader("Monthly Data")
170
+ monthly_temps = {}
171
+ monthly_humidity = {}
172
+ month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
173
+ col1, col2 = st.columns(2)
174
+ with col1:
175
+ for month in month_names[:6]:
176
+ monthly_temps[month] = st.number_input(f"{month} Temp (°C)", min_value=-50.0, max_value=50.0, value=20.0, step=0.5, key=f"temp_{month}")
177
+ with col2:
178
+ for month in month_names[6:]:
179
+ monthly_temps[month] = st.number_input(f"{month} Temp (°C)", min_value=-50.0, max_value=50.0, value=20.0, step=0.5, key=f"temp_{month}")
180
+ col1, col2 = st.columns(2)
181
+ with col1:
182
+ for month in month_names[:6]:
183
+ monthly_humidity[month] = st.number_input(f"{month} Humidity (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key=f"hum_{month}")
184
+ with col2:
185
+ for month in month_names[6:]:
186
+ monthly_humidity[month] = st.number_input(f"{month} Humidity (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key=f"hum_{month}")
187
+
188
+ if st.form_submit_button("Save Climate Data"):
189
+ location = ClimateLocation(
190
+ id=id_input,
191
+ country=session_state.building_info["country"],
192
+ state_province=state_province,
193
+ city=session_state.building_info["city"],
194
+ latitude=latitude,
195
+ longitude=longitude,
196
+ elevation=elevation,
197
+ climate_zone=climate_zone,
198
+ heating_degree_days=hdd,
199
+ cooling_degree_days=cdd,
200
+ winter_design_temp=winter_design_temp,
201
+ summer_design_temp_db=summer_design_temp_db,
202
+ summer_design_temp_wb=summer_design_temp_wb,
203
+ summer_daily_range=summer_daily_range,
204
+ monthly_temps=monthly_temps,
205
+ monthly_humidity=monthly_humidity
206
+ )
207
+ self.add_location(location)
208
+ st.success("Climate data saved manually!")
209
+ self.display_design_conditions(location)
210
+ self.visualize_data(location, epw_data=None)
211
+
212
+ # EPW Upload Tab
213
+ with tab2:
214
+ uploaded_file = st.file_uploader("Upload EPW File", type=["epw"])
215
+ if uploaded_file:
216
+ try:
217
+ epw_content = uploaded_file.read().decode("utf-8")
218
+ epw_lines = epw_content.splitlines()
219
+ header = next(line for line in epw_lines if line.startswith("LOCATION"))
220
+ header_parts = header.split(",")
221
+ latitude = float(header_parts[6])
222
+ longitude = float(header_parts[7])
223
+ elevation = float(header_parts[8])
224
+
225
+ # Load EPW data as strings first, then convert
226
+ epw_data = pd.read_csv(StringIO("\n".join(epw_lines[8:])), header=None, dtype=str)
227
+ if len(epw_data) != 8760:
228
+ raise ValueError(f"EPW file has {len(epw_data)} records, expected 8760.")
229
+
230
+ # Convert all columns to numeric, coercing errors to NaN
231
+ for col in epw_data.columns:
232
+ epw_data[col] = pd.to_numeric(epw_data[col], errors='coerce')
233
+
234
+ # Debugging: Show NaN counts
235
+ nan_counts = epw_data.isna().sum()
236
+ if nan_counts.max() > 0:
237
+ st.warning(f"NaN values detected in columns: {nan_counts[nan_counts > 0].to_dict()}")
238
+
239
+ # Infer column indices
240
+ column_map = self._infer_epw_columns(epw_data)
241
+
242
+ # Extract and validate data
243
+ months = epw_data[1].values.astype(float)
244
+ dry_bulb = epw_data[column_map["dry_bulb"]].values.astype(float)
245
+ wet_bulb = epw_data[column_map["wet_bulb"]].values.astype(float)
246
+ humidity = epw_data[column_map["humidity"]].values.astype(float)
247
+
248
+ if np.all(np.isnan(dry_bulb)) or np.all(np.isnan(humidity)):
249
+ raise ValueError("Critical columns (dry bulb or humidity) are entirely NaN.")
250
+
251
+ # Calculate daily averages for HDD/CDD
252
+ daily_temps = np.nanmean(dry_bulb.reshape(-1, 24), axis=1)
253
+ hdd = round(sum(max(18 - temp, 0) for temp in daily_temps if not np.isnan(temp)))
254
+ cdd = round(sum(max(temp - 18, 0) for temp in daily_temps if not np.isnan(temp)))
255
+
256
+ # Design conditions with NaN handling
257
+ winter_design_temp = round(np.nanpercentile(dry_bulb, 0.4), 1)
258
+ summer_design_temp_db = round(np.nanpercentile(dry_bulb, 99.6), 1)
259
+ summer_idx = np.argmax(dry_bulb >= summer_design_temp_db)
260
+ summer_design_temp_wb = round(wet_bulb[summer_idx], 1) if not np.isnan(wet_bulb[summer_idx]) else 25.0
261
+ summer_mask = (months >= 6) & (months <= 8)
262
+ summer_temps = dry_bulb[summer_mask].reshape(-1, 24)
263
+ summer_daily_range = round(np.nanmean(summer_temps.max(axis=1) - summer_temps.min(axis=1)), 1)
264
+
265
+ # Monthly averages
266
+ monthly_temps = {}
267
+ monthly_humidity = {}
268
+ month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
269
+ for i in range(1, 13):
270
+ month_mask = (months == i)
271
+ monthly_temps[month_names[i-1]] = round(np.nanmean(dry_bulb[month_mask]), 1)
272
+ monthly_humidity[month_names[i-1]] = round(np.nanmean(humidity[month_mask]), 1)
273
+
274
+ avg_humidity = np.nanmean(humidity)
275
+ climate_zone = self.assign_climate_zone(hdd, cdd, avg_humidity)
276
+
277
+ location = ClimateLocation(
278
+ id=f"{session_state.building_info['country'][:2].upper()}-{session_state.building_info['city'][:3].upper()}",
279
+ country=session_state.building_info["country"],
280
+ state_province="N/A",
281
+ city=session_state.building_info["city"],
282
+ latitude=latitude,
283
+ longitude=longitude,
284
+ elevation=elevation,
285
+ climate_zone=climate_zone,
286
+ heating_degree_days=hdd,
287
+ cooling_degree_days=cdd,
288
+ winter_design_temp=winter_design_temp,
289
+ summer_design_temp_db=summer_design_temp_db,
290
+ summer_design_temp_wb=summer_design_temp_wb,
291
+ summer_daily_range=summer_daily_range,
292
+ monthly_temps=monthly_temps,
293
+ monthly_humidity=monthly_humidity
294
+ )
295
+ self.add_location(location)
296
+ st.success("Climate data extracted from EPW file!")
297
+ self.display_design_conditions(location)
298
+ self.visualize_data(location, epw_data=epw_data, column_map=column_map)
299
+ except Exception as e:
300
+ st.error(f"Error processing EPW file: {str(e)}. Ensure it has 8760 hourly records and correct format.")
301
+
302
+ # Navigation buttons
303
+ col1, col2 = st.columns(2)
304
+ with col1:
305
+ st.button("Back to Building Information", on_click=lambda: setattr(session_state, "page", "Building Information"))
306
+ with col2:
307
+ if self.locations:
308
+ st.button("Continue to Building Components", on_click=lambda: setattr(session_state, "page", "Building Components"))
309
+ else:
310
+ st.button("Continue to Building Components", disabled=True)
311
+
312
+ def display_design_conditions(self, location: ClimateLocation):
313
+ """Display a table of design conditions for calculations."""
314
+ st.subheader("Design Conditions for HVAC Calculations")
315
+ design_data = pd.DataFrame({
316
+ "Parameter": [
317
+ "Climate Zone",
318
+ "Heating Degree Days (base 18°C)",
319
+ "Cooling Degree Days (base 18°C)",
320
+ "Winter Design Temperature (99.6%)",
321
+ "Summer Design Dry-Bulb Temp (0.4%)",
322
+ "Summer Design Wet-Bulb Temp (0.4%)",
323
+ "Summer Daily Temperature Range"
324
+ ],
325
+ "Value": [
326
+ location.climate_zone,
327
+ f"{location.heating_degree_days} HDD",
328
+ f"{location.cooling_degree_days} CDD",
329
+ f"{location.winter_design_temp} °C",
330
+ f"{location.summer_design_temp_db} °C",
331
+ f"{location.summer_design_temp_wb} °C",
332
+ f"{location.summer_daily_range} °C"
333
+ ]
334
+ })
335
+ st.table(design_data)
336
+
337
+ @staticmethod
338
+ def assign_climate_zone(hdd: float, cdd: float, avg_humidity: float) -> str:
339
+ """Assign ASHRAE 169 climate zone based on HDD, CDD, and humidity."""
340
+ if cdd > 10000:
341
+ return "0A" if avg_humidity > 60 else "0B"
342
+ elif cdd > 5000:
343
+ return "1A" if avg_humidity > 60 else "1B"
344
+ elif cdd > 2500:
345
+ return "2A" if avg_humidity > 60 else "2B"
346
+ elif hdd < 2000 and cdd > 1000:
347
+ return "3A" if avg_humidity > 60 else "3B" if avg_humidity < 40 else "3C"
348
+ elif hdd < 3000:
349
+ return "4A" if avg_humidity > 60 else "4B" if avg_humidity < 40 else "4C"
350
+ elif hdd < 4000:
351
+ return "5A" if avg_humidity > 60 else "5B" if avg_humidity < 40 else "5C"
352
+ elif hdd < 5000:
353
+ return "6A" if avg_humidity > 60 else "6B"
354
+ elif hdd < 7000:
355
+ return "7"
356
+ else:
357
+ return "8"
358
+
359
+ def visualize_data(self, location: ClimateLocation, epw_data: Optional[pd.DataFrame] = None, column_map: Optional[Dict[str, int]] = None):
360
+ """Visualize monthly temperature and humidity data with min, max, and average."""
361
+ st.subheader("Monthly Climate Data Visualization")
362
+
363
+ months = list(range(1, 13))
364
+ month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
365
+ temps_avg = [location.monthly_temps[m] for m in month_names]
366
+ humidity_avg = [location.monthly_humidity[m] for m in month_names]
367
+
368
+ if epw_data is not None and column_map is not None:
369
+ dry_bulb = epw_data[column_map["dry_bulb"]].values.astype(float)
370
+ humidity = epw_data[column_map["humidity"]].values.astype(float)
371
+ month_col = epw_data[1].values.astype(float)
372
+
373
+ temps_min = []
374
+ temps_max = []
375
+ humidity_min = []
376
+ humidity_max = []
377
+ for i in range(1, 13):
378
+ month_mask = (month_col == i)
379
+ temps_min.append(round(np.nanmin(dry_bulb[month_mask]), 1))
380
+ temps_max.append(round(np.nanmax(dry_bulb[month_mask]), 1))
381
+ humidity_min.append(round(np.nanmin(humidity[month_mask]), 1))
382
+ humidity_max.append(round(np.nanmax(humidity[month_mask]), 1))
383
+ else:
384
+ temps_min = temps_avg
385
+ temps_max = temps_avg
386
+ humidity_min = humidity_avg
387
+ humidity_max = humidity_avg
388
+
389
+ # Temperature Plot
390
+ fig_temp = go.Figure()
391
+ fig_temp.add_trace(go.Scatter(
392
+ x=months,
393
+ y=temps_avg,
394
+ mode='lines+markers',
395
+ name='Avg Temperature (°C)',
396
+ line=dict(color='red'),
397
+ marker=dict(size=8)
398
+ ))
399
+ fig_temp.add_trace(go.Scatter(
400
+ x=months,
401
+ y=temps_max,
402
+ mode='lines',
403
+ name='Max Temperature (°C)',
404
+ line=dict(color='red', dash='dash'),
405
+ opacity=0.5
406
+ ))
407
+ fig_temp.add_trace(go.Scatter(
408
+ x=months,
409
+ y=temps_min,
410
+ mode='lines',
411
+ name='Min Temperature (°C)',
412
+ line=dict(color='red', dash='dash'),
413
+ opacity=0.5,
414
+ fill='tonexty',
415
+ fillcolor='rgba(255, 0, 0, 0.1)'
416
+ ))
417
+ fig_temp.update_layout(
418
+ title='Monthly Temperatures',
419
+ xaxis_title='Month',
420
+ yaxis_title='Temperature (°C)',
421
+ xaxis=dict(tickmode='array', tickvals=months, ticktext=month_names),
422
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)
423
+ )
424
+ st.plotly_chart(fig_temp, use_container_width=True)
425
+
426
+ # Humidity Plot
427
+ fig_hum = go.Figure()
428
+ fig_hum.add_trace(go.Scatter(
429
+ x=months,
430
+ y=humidity_avg,
431
+ mode='lines+markers',
432
+ name='Avg Humidity (%)',
433
+ line=dict(color='blue'),
434
+ marker=dict(size=8)
435
+ ))
436
+ fig_hum.add_trace(go.Scatter(
437
+ x=months,
438
+ y=humidity_max,
439
+ mode='lines',
440
+ name='Max Humidity (%)',
441
+ line=dict(color='blue', dash='dash'),
442
+ opacity=0.5
443
+ ))
444
+ fig_hum.add_trace(go.Scatter(
445
+ x=months,
446
+ y=humidity_min,
447
+ mode='lines',
448
+ name='Min Humidity (%)',
449
+ line=dict(color='blue', dash='dash'),
450
+ opacity=0.5,
451
+ fill='tonexty',
452
+ fillcolor='rgba(0, 0, 255, 0.1)'
453
+ ))
454
+ fig_hum.update_layout(
455
+ title='Monthly Relative Humidity',
456
+ xaxis_title='Month',
457
+ yaxis_title='Relative Humidity (%)',
458
+ xaxis=dict(tickmode='array', tickvals=months, ticktext=month_names),
459
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)
460
+ )
461
+ st.plotly_chart(fig_hum, use_container_width=True)
462
+
463
+ def export_to_json(self, file_path: str) -> None:
464
+ """Export all climate data to a JSON file."""
465
+ data = {loc_id: loc.to_dict() for loc_id, loc in self.locations.items()}
466
+ with open(file_path, 'w') as f:
467
+ json.dump(data, f, indent=4)
468
+
469
+ @classmethod
470
+ def from_json(cls, file_path: str) -> 'ClimateData':
471
+ """Create a ClimateData instance from a JSON file."""
472
+ with open(file_path, 'r') as f:
473
+ data = json.load(f)
474
+ climate_data = cls()
475
+ climate_data.locations = {}
476
+ for loc_id, loc_dict in data.items():
477
+ climate_data.locations[loc_id] = ClimateLocation(**loc_dict)
478
+ climate_data.countries = sorted(list(set(loc.country for loc in climate_data.locations.values())))
479
+ climate_data.country_states = climate_data._group_locations_by_country_state()
480
+ return climate_data
481
+
482
+
483
+ if __name__ == "__main__":
484
+ if "building_info" not in st.session_state:
485
+ st.session_state.building_info = {"country": "Australia", "city": "Melbourne"}
486
+ if "page" not in st.session_state:
487
+ st.session_state.page = "Climate Data"
488
+
489
+ climate_data = ClimateData()
490
  climate_data.display_climate_input(st.session_state)