mabuseif commited on
Commit
11fa5f7
·
verified ·
1 Parent(s): 8ca23a3

Update data/climate_data.py

Browse files
Files changed (1) hide show
  1. data/climate_data.py +107 -214
data/climate_data.py CHANGED
@@ -1,11 +1,10 @@
1
  """
2
  ASHRAE 169 climate data module for HVAC Load Calculator.
3
  Extracts climate data from EPW files and provides visualizations inspired by Climate Consultant.
4
- Includes solar radiation and position calculations for heat gain analysis.
5
 
6
  Author: Dr Majed Abuseif
7
  Date: May 2025
8
- Version: 2.1.7
9
  """
10
 
11
  from typing import Dict, List, Any, Optional
@@ -85,9 +84,10 @@ class ClimateLocation:
85
  hourly_data: List[Dict] # Hourly data for integration with main.py
86
  typical_extreme_periods: Dict[str, Dict] # Typical/extreme periods (summer/winter)
87
  ground_temperatures: Dict[str, List[float]] # Monthly ground temperatures by depth
88
- solstice_solar_angles: Dict[str, Dict] # Solar angles for summer and winter solstices
89
-
90
- def __init__(self, epw_file: pd.DataFrame, typical_extreme_periods: Dict, ground_temperatures: Dict, **kwargs):
 
91
  """Initialize ClimateLocation with EPW file data and header information."""
92
  self.id = kwargs.get("id")
93
  self.country = kwargs.get("country")
@@ -99,9 +99,8 @@ class ClimateLocation:
99
  self.time_zone = kwargs.get("time_zone")
100
  self.typical_extreme_periods = typical_extreme_periods
101
  self.ground_temperatures = ground_temperatures
102
-
103
- # Cache for solar angles
104
- self._solar_angle_cache = {}
105
 
106
  # Extract columns from EPW data
107
  months = pd.to_numeric(epw_file[1], errors='coerce').values
@@ -114,16 +113,14 @@ class ClimateLocation:
114
  direct_normal_irradiance = pd.to_numeric(epw_file[14], errors='coerce').values
115
  diffuse_horizontal_irradiance = pd.to_numeric(epw_file[15], errors='coerce').values
116
  wind_direction = pd.to_numeric(epw_file[20], errors='coerce').values
117
- wind_speed = pd.to_numeric(epw_file[21], errors='coerce').values
118
 
119
  # Filter wind speed outliers and log high values
120
  wind_speed = wind_speed[wind_speed <= 50] # Remove extreme outliers
121
  if (wind_speed > 15).any():
122
  logger.warning(f"High wind speeds detected: {wind_speed[wind_speed > 15].tolist()}")
123
 
124
- # Filter and validate irradiance outliers
125
- direct_normal_irradiance = np.where(direct_normal_irradiance < 0, 0, direct_normal_irradiance)
126
- diffuse_horizontal_irradiance = np.where(diffuse_horizontal_irradiance < 0, 0, diffuse_horizontal_irradiance)
127
  direct_normal_irradiance = direct_normal_irradiance[direct_normal_irradiance <= 1200]
128
  diffuse_horizontal_irradiance = diffuse_horizontal_irradiance[diffuse_horizontal_irradiance <= 1200]
129
  if (direct_normal_irradiance > 1000).any():
@@ -166,41 +163,25 @@ class ClimateLocation:
166
  # Assign climate zone
167
  self.climate_zone = ClimateData.assign_climate_zone(self.heating_degree_days, self.cooling_degree_days, np.nanmean(humidity))
168
 
169
- # Calculate solstice solar angles (at solar noon, ~12:00)
170
- self.solstice_solar_angles = self._calculate_solstice_solar_angles()
171
 
172
- # Store hourly data with enhanced fields, including solar angles and ground-reflected radiation
173
  self.hourly_data = []
174
- negative_dni_count = 0
175
- negative_dhi_count = 0
176
  for i in range(len(months)):
177
  if np.isnan(months[i]) or np.isnan(days[i]) or np.isnan(hours[i]) or np.isnan(dry_bulb[i]):
178
  continue # Skip records with missing critical fields
179
-
180
  # Calculate solar time and angles
181
- timestamp = datetime(2025, int(months[i]), int(days[i]), int(hours[i]) - 1) # EPW hours are 1-24
182
  solar_time = self._calculate_solar_time(timestamp)
183
  solar_angles = self._calculate_solar_angles(timestamp)
184
 
185
- # Initialize defaults for solar-related fields
186
- dni = 0.0
187
- dhi = 0.0
188
- ground_radiation = 0.0
189
 
190
- # Perform solar calculations only if sun is above horizon (zenith < 90°)
191
- if solar_angles['zenith'] < 90:
192
- # Validate or fallback DNI/DHI
193
- dni = float(direct_normal_irradiance[i]) if not np.isnan(direct_normal_irradiance[i]) else self._calculate_clear_sky_irradiance(timestamp, 'dni')
194
- dhi = float(diffuse_horizontal_irradiance[i]) if not np.isnan(diffuse_horizontal_irradiance[i]) else self._calculate_clear_sky_irradiance(timestamp, 'dhi')
195
- if dni < 0:
196
- negative_dni_count += 1
197
- dni = self._calculate_clear_sky_irradiance(timestamp, 'dni')
198
- if dhi < 0:
199
- negative_dhi_count += 1
200
- dhi = self._calculate_clear_sky_irradiance(timestamp, 'dhi')
201
-
202
- # Calculate ground-reflected radiation
203
- ground_radiation = self._calculate_ground_reflected_radiation(dni, dhi, solar_angles['zenith'])
204
 
205
  record = {
206
  "month": int(months[i]),
@@ -212,99 +193,81 @@ class ClimateLocation:
212
  "global_horizontal_radiation": float(global_radiation[i]) if not np.isnan(global_radiation[i]) else 0.0,
213
  "direct_normal_irradiance": dni,
214
  "diffuse_horizontal_irradiance": dhi,
215
- "ground_reflected_radiation": ground_radiation,
216
  "wind_speed": float(wind_speed[i]) if not np.isnan(wind_speed[i]) else 0.0,
217
  "wind_direction": float(wind_direction[i]) if not np.isnan(wind_direction[i]) else 0.0,
218
- "solar_zenith": solar_angles['zenith'],
219
- "solar_azimuth": solar_angles['azimuth']
 
 
220
  }
221
  self.hourly_data.append(record)
222
 
223
- # Log summary of negative DNI/DHI corrections
224
- if negative_dni_count > 0:
225
- logger.warning(f"Corrected {negative_dni_count} negative DNI values using clear-sky model")
226
- if negative_dhi_count > 0:
227
- logger.warning(f"Corrected {negative_dhi_count} negative DHI values using clear-sky model")
228
-
229
  if len(self.hourly_data) != 8760:
230
  st.warning(f"Hourly data has {len(self.hourly_data)} records instead of 8760. Some records may have been excluded due to missing data.")
231
 
232
- def _calculate_solar_time(self, timestamp: datetime) -> datetime:
233
- """Calculate solar time based on local time, longitude, and time zone (ASHRAE Fundamentals, Chapter 14, Section 14.7)."""
234
  # Standard meridian for the time zone
235
- standard_meridian = self.time_zone * 15 # Convert UTC offset to degrees
236
- # Longitude correction in minutes (4 minutes per degree)
237
- longitude_correction = 4 * (self.longitude - standard_meridian)
238
- # Equation of time (simplified, using pvlib for accuracy)
239
  day_of_year = timestamp.timetuple().tm_yday
240
- solpos = pvlib.solarposition.get_solarposition(
241
- time=timestamp,
242
- latitude=self.latitude,
243
- longitude=self.longitude,
244
- altitude=self.elevation
245
- )
246
- # Solar time = Local time + Longitude correction + Equation of time
247
- equation_of_time = solpos['equation_of_time'].iloc[0] # in minutes
248
- time_diff = longitude_correction + equation_of_time
249
- solar_time = timestamp + timedelta(minutes=time_diff)
250
  return solar_time
251
 
252
  def _calculate_solar_angles(self, timestamp: datetime) -> Dict[str, float]:
253
- """Calculate solar zenith and azimuth angles using pvlib (ASHRAE Fundamentals, Chapter 14, Section 14.7)."""
254
- # Create a cache key
255
- cache_key = hashlib.md5(f"{timestamp.isoformat()}_{self.latitude}_{self.longitude}_{self.time_zone}".encode()).hexdigest()
 
 
256
 
257
- if cache_key in self._solar_angle_cache:
258
- return self._solar_angle_cache[cache_key]
259
 
 
260
  solpos = pvlib.solarposition.get_solarposition(
261
  time=timestamp,
262
  latitude=self.latitude,
263
  longitude=self.longitude,
264
  altitude=self.elevation
265
  )
266
- angles = {
267
- 'zenith': round(float(solpos['zenith'].iloc[0]), 2),
268
- 'azimuth': round(float(solpos['azimuth'].iloc[0]), 2)
269
- }
270
 
271
- # Cache the result
272
- self._solar_angle_cache[cache_key] = angles
273
- return angles
274
-
275
- def _calculate_clear_sky_irradiance(self, timestamp: datetime, component: str) -> float:
276
- """Calculate clear-sky DNI or DHI using ASHRAE clear-sky model (Chapter 14, Section 14.6)."""
277
- clearsky = pvlib.clearsky.ineichen(
278
- time=timestamp,
279
- latitude=self.latitude,
280
- longitude=self.longitude,
281
- altitude=self.elevation,
282
- pressure=self.pressure
283
- )
284
- return float(clearsky[component].iloc[0]) if component in ['dni', 'dhi'] else 0.0
285
-
286
- def _calculate_ground_reflected_radiation(self, dni: float, dhi: float, zenith: float) -> float:
287
- """Calculate ground-reflected radiation using albedo (ASHRAE Fundamentals, Chapter 14, Table 4)."""
288
- albedo = 0.2 # Default albedo per ASHRAE
289
- cos_zenith = np.cos(np.radians(zenith))
290
- ground_radiation = albedo * (dni * cos_zenith + dhi)
291
- return round(max(0, ground_radiation), 1)
292
 
293
- def _calculate_solstice_solar_angles(self) -> Dict[str, Dict]:
294
- """Calculate solar angles at solar noon for summer and winter solstices."""
295
  dates = [
296
- datetime(2025, 6, 21, 12), # Winter solstice (Southern Hemisphere)
297
- datetime(2025, 12, 21, 12) # Summer solstice (Southern Hemisphere)
298
  ]
299
- solstice_angles = {}
300
  for date in dates:
301
- season = 'winter' if date.month == 6 else 'summer'
302
- angles = self._calculate_solar_angles(date)
303
- solstice_angles[f"{season}_solstice"] = {
304
- "zenith": angles['zenith'],
305
- "azimuth": angles['azimuth']
306
- }
307
- return solstice_angles
 
 
308
 
309
  def to_dict(self) -> Dict[str, Any]:
310
  """Convert the climate location to a dictionary."""
@@ -328,10 +291,11 @@ class ClimateLocation:
328
  "pressure": self.pressure,
329
  "direct_normal_irradiance": self.direct_normal_irradiance,
330
  "diffuse_horizontal_irradiance": self.diffuse_horizontal_irradiance,
 
331
  "hourly_data": self.hourly_data,
332
  "typical_extreme_periods": self.typical_extreme_periods,
333
  "ground_temperatures": self.ground_temperatures,
334
- "solstice_solar_angles": self.solstice_solar_angles
335
  }
336
 
337
  class ClimateData:
@@ -380,7 +344,7 @@ class ClimateData:
380
  "winter_design_temp", "summer_design_temp_db", "summer_design_temp_wb",
381
  "summer_daily_range", "wind_speed", "pressure",
382
  "direct_normal_irradiance", "diffuse_horizontal_irradiance", "hourly_data",
383
- "solstice_solar_angles"
384
  ]
385
 
386
  for field in required_fields:
@@ -424,7 +388,9 @@ class ClimateData:
424
  if not (0 <= data["diffuse_horizontal_irradiance"] <= 1200):
425
  st.error(f"Validation failed: Diffuse horizontal irradiance {data['diffuse_horizontal_irradiance']} outside range")
426
  return False
427
-
 
 
428
  if not data["hourly_data"] or len(data["hourly_data"]) < 8700:
429
  st.error(f"Validation failed: Hourly data has {len(data['hourly_data'])} records, expected ~8760")
430
  return False
@@ -456,8 +422,14 @@ class ClimateData:
456
  if not (0 <= record["diffuse_horizontal_irradiance"] <= 1200):
457
  st.error(f"Validation failed: Diffuse horizontal irradiance {record['diffuse_horizontal_irradiance']} outside range")
458
  return False
459
- if not (0 <= record["ground_reflected_radiation"] <= 1200):
460
- st.error(f"Validation failed: Ground reflected radiation {record['ground_reflected_radiation']} outside range")
 
 
 
 
 
 
461
  return False
462
  if not (0 <= record["solar_zenith"] <= 180):
463
  st.error(f"Validation failed: Solar zenith {record['solar_zenith']} outside range")
@@ -465,11 +437,8 @@ class ClimateData:
465
  if not (0 <= record["solar_azimuth"] <= 360):
466
  st.error(f"Validation failed: Solar azimuth {record['solar_azimuth']} outside range")
467
  return False
468
- if not (0 <= record["wind_speed"] <= 30):
469
- st.error(f"Validation failed: Wind speed {record['wind_speed']} outside range")
470
- return False
471
- if not (0 <= record["wind_direction"] <= 360):
472
- st.error(f"Validation failed: Wind direction {record['wind_direction']} outside range")
473
  return False
474
 
475
  # Validate typical/extreme periods (optional)
@@ -491,22 +460,12 @@ class ClimateData:
491
  st.error(f"Validation failed: Invalid ground temperatures for depth {depth}")
492
  return False
493
 
494
- # Validate solstice solar angles
495
- if "solstice_solar_angles" in data:
496
- for season in ["summer_solstice", "winter_solstice"]:
497
- if season not in data["solstice_solar_angles"]:
498
- st.error(f"Validation failed: Missing {season} in solstice_solar_angles")
499
- return False
500
- angles = data["solstice_solar_angles"][season]
501
- if not (0 <= angles["zenith"] <= 180 and 0 <= angles["azimuth"] <= 360):
502
- st.error(f"Validation failed: Invalid {season} angles: {angles}")
503
- return False
504
-
505
- # Log presence of ground_reflected_radiation during import
506
- if all("ground_reflected_radiation" in record for record in data["hourly_data"]):
507
- logger.info("Validated ground_reflected_radiation present in all hourly data records")
508
- else:
509
- st.error("Validation failed: Missing ground_reflected_radiation in some hourly data records")
510
  return False
511
 
512
  return True
@@ -539,8 +498,8 @@ class ClimateData:
539
  except ValueError:
540
  return False
541
 
542
- def display_climate_input(self, session_state: Dict[str, Any]):
543
- """Display Streamlit interface for EPW and JSON upload, and visualizations."""
544
  st.title("Climate Data Analysis")
545
 
546
  # Apply consistent styling
@@ -551,64 +510,16 @@ class ClimateData:
551
  st.warning("Clearing invalid climate data from session state.")
552
  del session_state["climate_data"]
553
 
554
- # File uploaders
555
- uploaded_epw_file = st.file_uploader("Upload EPW File", type=["epw"])
556
- uploaded_json_file = st.file_uploader("Upload JSON File", type=["json"])
557
 
558
  # Initialize location and epw_data for display
559
  location = None
560
  epw_data = None
561
 
562
- # Process JSON file if uploaded
563
- if uploaded_json_file:
564
- try:
565
- json_content = uploaded_json_file.read().decode("utf-8")
566
- json_data = json.loads(json_content)
567
- for loc_id, loc_dict in json_data.items():
568
- if not self.validate_climate_data(loc_dict):
569
- raise ValueError("Invalid climate data in JSON file.")
570
- # Rebuild epw_data from hourly_data
571
- hourly_data = loc_dict["hourly_data"]
572
- epw_data = pd.DataFrame({
573
- 1: [d["month"] for d in hourly_data],
574
- 2: [d["day"] for d in hourly_data],
575
- 3: [d["hour"] for d in hourly_data],
576
- 6: [d["dry_bulb"] for d in hourly_data],
577
- 8: [d["relative_humidity"] for d in hourly_data],
578
- 9: [d["atmospheric_pressure"] for d in hourly_data],
579
- 13: [d["global_horizontal_radiation"] for d in hourly_data],
580
- 14: [d["direct_normal_irradiance"] for d in hourly_data],
581
- 15: [d["diffuse_horizontal_irradiance"] for d in hourly_data],
582
- 20: [d["wind_direction"] for d in hourly_data],
583
- 21: [d["wind_speed"] for d in hourly_data],
584
- })
585
- location = ClimateLocation(
586
- epw_file=epw_data,
587
- typical_extreme_periods=loc_dict["typical_extreme_periods"],
588
- ground_temperatures=loc_dict["ground_temperatures"],
589
- id=loc_dict["id"],
590
- country=loc_dict["country"],
591
- state_province=loc_dict["state_province"],
592
- city=loc_dict["city"],
593
- latitude=loc_dict["latitude"],
594
- longitude=loc_dict["longitude"],
595
- elevation=loc_dict["elevation"],
596
- time_zone=loc_dict["time_zone"]
597
- )
598
- location.hourly_data = loc_dict["hourly_data"]
599
- location.solstice_solar_angles = loc_dict["solstice_solar_angles"]
600
- self.add_location(location)
601
- session_state["climate_data"] = loc_dict
602
- st.success(f"Climate data loaded from JSON for {loc_dict['city']}")
603
- logger.info(f"Successfully imported JSON with ground_reflected_radiation for {loc_dict['city']}")
604
- except Exception as e:
605
- st.error(f"Error processing JSON file: {str(e)}. Ensure it has valid climate data.")
606
-
607
- # Process EPW file if uploaded
608
- elif uploaded_epw_file:
609
  try:
610
  # Process new EPW file
611
- epw_content = uploaded_epw_file.read().decode("utf-8")
612
  epw_lines = epw_content.splitlines()
613
 
614
  # Parse header
@@ -649,7 +560,7 @@ class ClimateData:
649
  period_name = parts[2 + i*4]
650
  period_type = parts[3 + i*4]
651
  start_date = parts[4 + i*4].strip()
652
- end_date = parts[5 + i*4].strip()
653
  if period_name in [
654
  "Summer - Week Nearest Max Temperature For Period",
655
  "Summer - Week Nearest Average Temperature For Period",
@@ -721,6 +632,7 @@ class ClimateData:
721
  epw_file=epw_data,
722
  typical_extreme_periods=typical_extreme_periods,
723
  ground_temperatures=ground_temperatures,
 
724
  id=f"{country[:1].upper()}{city[:3].upper()}",
725
  country=country,
726
  state_province=state_province,
@@ -733,12 +645,9 @@ class ClimateData:
733
  climate_data_dict = location.to_dict()
734
  if not self.validate_climate_data(climate_data_dict):
735
  raise ValueError("Invalid climate data extracted from EPW file.")
736
-
737
- # Add location and save to session state
738
- self.add_location(location)
739
  session_state["climate_data"] = climate_data_dict
740
  st.success("Climate data extracted from EPW file!")
741
-
742
  except Exception as e:
743
  st.error(f"Error processing EPW file: {str(e)}. Ensure it has 8760 hourly records and correct format.")
744
 
@@ -767,6 +676,7 @@ class ClimateData:
767
  epw_file=epw_data,
768
  typical_extreme_periods=climate_data_dict["typical_extreme_periods"],
769
  ground_temperatures=climate_data_dict["ground_temperatures"],
 
770
  id=climate_data_dict["id"],
771
  country=climate_data_dict["country"],
772
  state_province=climate_data_dict["state_province"],
@@ -778,7 +688,7 @@ class ClimateData:
778
  )
779
  # Override hourly_data to ensure consistency
780
  location.hourly_data = climate_data_dict["hourly_data"]
781
- location.solstice_solar_angles = climate_data_dict["solstice_solar_angles"]
782
  self.add_location(location)
783
  st.info("Displaying previously extracted climate data.")
784
 
@@ -808,18 +718,13 @@ class ClimateData:
808
  self.plot_wind_rose(epw_data)
809
 
810
  else:
811
- st.info("No climate data available. Please upload an EPW or JSON file to proceed.")
812
 
813
  def display_design_conditions(self, location: ClimateLocation):
814
  """Display design conditions for HVAC calculations using styled HTML."""
815
  st.subheader("Design Conditions")
816
 
817
  # Location Details
818
- solstice_items = [
819
- f"<li><strong>{key.replace('_', ' ').title()} Zenith:</strong> {angles['zenith']}°</li>"
820
- f"<li><strong>{key.replace('_', ' ').title()} Azimuth:</strong> {angles['azimuth']}°</li>"
821
- for key, angles in location.solstice_solar_angles.items()
822
- ]
823
  st.markdown(f"""
824
  <div class="markdown-text">
825
  <h3>Location Details</h3>
@@ -831,7 +736,8 @@ class ClimateData:
831
  <li><strong>Longitude:</strong> {location.longitude}°</li>
832
  <li><strong>Elevation:</strong> {location.elevation} m</li>
833
  <li><strong>Time Zone:</strong> UTC{location.time_zone:+.1f}</li>
834
- {''.join(solstice_items)}
 
835
  </ul>
836
  </div>
837
  """, unsafe_allow_html=True)
@@ -852,6 +758,7 @@ class ClimateData:
852
  <li><strong>Mean Atmospheric Pressure:</strong> {location.pressure} Pa</li>
853
  <li><strong>Mean Direct Normal Irradiance:</strong> {location.direct_normal_irradiance} W/m²</li>
854
  <li><strong>Mean Diffuse Horizontal Irradiance:</strong> {location.diffuse_horizontal_irradiance} W/m²</li>
 
855
  </ul>
856
  </div>
857
  """, unsafe_allow_html=True)
@@ -871,21 +778,6 @@ class ClimateData:
871
  </div>
872
  """, unsafe_allow_html=True)
873
 
874
- # Ground Temperatures (Table)
875
- if location.typical_extreme_periods:
876
- period_items = [
877
- f"<li><strong>{key.replace('_', ' ').title()}:</strong> {period['start']['month']}/{period['start']['day']} to {period['end']['month']}/{period['end']['day']}</li>"
878
- for key, period in location.typical_extreme_periods.items()
879
- ]
880
- st.markdown(f"""
881
- <div class="markdown-text">
882
- <h3>Typical/Extreme Periods</h3>
883
- <ul>
884
- {''.join(period_items)}
885
- </ul>
886
- </div>
887
- """, unsafe_allow_html=True)
888
-
889
  # Ground Temperatures (Table)
890
  if location.ground_temperatures:
891
  st.markdown('<div class="markdown-text"><h3>Ground Temperatures</h3></div>', unsafe_allow_html=True)
@@ -1215,6 +1107,7 @@ class ClimateData:
1215
  epw_file=epw_data,
1216
  typical_extreme_periods=loc_dict["typical_extreme_periods"],
1217
  ground_temperatures=loc_dict["ground_temperatures"],
 
1218
  id=loc_dict["id"],
1219
  country=loc_dict["country"],
1220
  state_province=loc_dict["state_province"],
@@ -1224,12 +1117,12 @@ class ClimateData:
1224
  elevation=loc_dict["elevation"],
1225
  time_zone=loc_dict["time_zone"]
1226
  )
1227
- location.hourly_data = loc_dict["hourly_data"]
1228
- location.solstice_solar_angles = loc_dict["solstice_solar_angles"]
1229
  climate_data.add_location(location)
1230
  return climate_data
1231
 
1232
  if __name__ == "__main__":
1233
  climate_data = ClimateData()
1234
  session_state = {"building_info": {"country": "Australia", "city": "Geelong"}, "page": "Climate Data"}
1235
- climate_data.display_climate_input(session_state)
 
1
  """
2
  ASHRAE 169 climate data module for HVAC Load Calculator.
3
  Extracts climate data from EPW files and provides visualizations inspired by Climate Consultant.
 
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
 
84
  hourly_data: List[Dict] # Hourly data for integration with main.py
85
  typical_extreme_periods: Dict[str, Dict] # Typical/extreme periods (summer/winter)
86
  ground_temperatures: Dict[str, List[float]] # Monthly ground temperatures by depth
87
+ solstice_zenith_angles: Dict[str, float] # Summer and winter solstice zenith angles
88
+ _solar_cache: Dict[str, Dict[str, float]] # Cache for solar angles
89
+
90
+ def __init__(self, epw_file: pd.DataFrame, typical_extreme_periods: Dict, ground_temperatures: Dict, albedo: float = 0.2, **kwargs):
91
  """Initialize ClimateLocation with EPW file data and header information."""
92
  self.id = kwargs.get("id")
93
  self.country = kwargs.get("country")
 
99
  self.time_zone = kwargs.get("time_zone")
100
  self.typical_extreme_periods = typical_extreme_periods
101
  self.ground_temperatures = ground_temperatures
102
+ self._solar_cache = {}
103
+ self.albedo = albedo # Default albedo from ASHRAE Fundamentals, Chapter 14, Table 4
 
104
 
105
  # Extract columns from EPW data
106
  months = pd.to_numeric(epw_file[1], errors='coerce').values
 
113
  direct_normal_irradiance = pd.to_numeric(epw_file[14], errors='coerce').values
114
  diffuse_horizontal_irradiance = pd.to_numeric(epw_file[15], errors='coerce').values
115
  wind_direction = pd.to_numeric(epw_file[20], errors='coerce').values
116
+ wind_speed = pd.to_numeric(epw_file[21], errors='coerce')
117
 
118
  # Filter wind speed outliers and log high values
119
  wind_speed = wind_speed[wind_speed <= 50] # Remove extreme outliers
120
  if (wind_speed > 15).any():
121
  logger.warning(f"High wind speeds detected: {wind_speed[wind_speed > 15].tolist()}")
122
 
123
+ # Filter irradiance outliers
 
 
124
  direct_normal_irradiance = direct_normal_irradiance[direct_normal_irradiance <= 1200]
125
  diffuse_horizontal_irradiance = diffuse_horizontal_irradiance[diffuse_horizontal_irradiance <= 1200]
126
  if (direct_normal_irradiance > 1000).any():
 
163
  # Assign climate zone
164
  self.climate_zone = ClimateData.assign_climate_zone(self.heating_degree_days, self.cooling_degree_days, np.nanmean(humidity))
165
 
166
+ # Calculate solstice zenith angles
167
+ self.solstice_zenith_angles = self._calculate_solstice_zenith_angles()
168
 
169
+ # Store hourly data with enhanced fields
170
  self.hourly_data = []
 
 
171
  for i in range(len(months)):
172
  if np.isnan(months[i]) or np.isnan(days[i]) or np.isnan(hours[i]) or np.isnan(dry_bulb[i]):
173
  continue # Skip records with missing critical fields
 
174
  # Calculate solar time and angles
175
+ timestamp = datetime(2025, int(months[i]), int(days[i]), int(hours[i]) - 1) # Hour is 1-24, adjust to 0-23
176
  solar_time = self._calculate_solar_time(timestamp)
177
  solar_angles = self._calculate_solar_angles(timestamp)
178
 
179
+ # Handle DNI and DHI: set to 0 if missing or negative
180
+ dni = float(direct_normal_irradiance[i]) if not np.isnan(direct_normal_irradiance[i]) and direct_normal_irradiance[i] >= 0 else 0.0
181
+ dhi = float(diffuse_horizontal_irradiance[i]) if not np.isnan(diffuse_horizontal_irradiance[i]) and diffuse_horizontal_irradiance[i] >= 0 else 0.0
 
182
 
183
+ # Calculate ground-reflected radiation
184
+ ground_reflected = self.albedo * (dni * np.cos(np.radians(solar_angles['zenith'])) + dhi)
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
  record = {
187
  "month": int(months[i]),
 
193
  "global_horizontal_radiation": float(global_radiation[i]) if not np.isnan(global_radiation[i]) else 0.0,
194
  "direct_normal_irradiance": dni,
195
  "diffuse_horizontal_irradiance": dhi,
 
196
  "wind_speed": float(wind_speed[i]) if not np.isnan(wind_speed[i]) else 0.0,
197
  "wind_direction": float(wind_direction[i]) if not np.isnan(wind_direction[i]) else 0.0,
198
+ "solar_time": float(solar_time),
199
+ "solar_zenith": float(solar_angles['zenith']),
200
+ "solar_azimuth": float(solar_angles['azimuth']),
201
+ "ground_reflected_radiation": float(ground_reflected)
202
  }
203
  self.hourly_data.append(record)
204
 
 
 
 
 
 
 
205
  if len(self.hourly_data) != 8760:
206
  st.warning(f"Hourly data has {len(self.hourly_data)} records instead of 8760. Some records may have been excluded due to missing data.")
207
 
208
+ def _calculate_solar_time(self, timestamp: datetime) -> float:
209
+ """Calculate solar time from local time, adjusting for longitude and time zone (ASHRAE Fundamentals, Chapter 14, Section 14.7)."""
210
  # Standard meridian for the time zone
211
+ standard_meridian = self.time_zone * 15 # Time zone offset in degrees (15° per hour)
212
+ # Longitude correction (4 minutes per degree)
213
+ longitude_correction = (self.longitude - standard_meridian) * 4 / 60 # in hours
214
+ # Equation of time (simplified approximation, could use pvlib for precision)
215
  day_of_year = timestamp.timetuple().tm_yday
216
+ B = (day_of_year - 1) * 360 / 365.242
217
+ E = 229.18 * (0.000075 + 0.001868 * np.cos(np.radians(B)) - 0.032077 * np.sin(np.radians(B)) -
218
+ 0.014615 * np.cos(np.radians(2 * B)) - 0.04089 * np.sin(np.radians(2 * B))) / 60 # in hours
219
+ # Local standard time in hours
220
+ local_std_time = timestamp.hour + timestamp.minute / 60
221
+ # Solar time
222
+ solar_time = local_std_time + longitude_correction + E
223
+ # Normalize to 0-24 hours
224
+ solar_time = solar_time % 24
 
225
  return solar_time
226
 
227
  def _calculate_solar_angles(self, timestamp: datetime) -> Dict[str, float]:
228
+ """Calculate solar zenith and azimuth angles, caching results (ASHRAE Fundamentals, Chapter 14, Section 14.7)."""
229
+ # Create cache key
230
+ cache_key = hashlib.md5(
231
+ f"{timestamp.strftime('%Y-%m-%d_%H')}_{self.latitude}_{self.longitude}_{self.time_zone}".encode()
232
+ ).hexdigest()
233
 
234
+ if cache_key in self._solar_cache:
235
+ return self._solar_cache[cache_key]
236
 
237
+ # Use pvlib for solar position
238
  solpos = pvlib.solarposition.get_solarposition(
239
  time=timestamp,
240
  latitude=self.latitude,
241
  longitude=self.longitude,
242
  altitude=self.elevation
243
  )
244
+ zenith = solpos['zenith'].iloc[0]
245
+ azimuth = solpos['azimuth'].iloc[0]
 
 
246
 
247
+ # Cache results
248
+ self._solar_cache[cache_key] = {
249
+ 'zenith': zenith,
250
+ 'azimuth': azimuth
251
+ }
252
+ return self._solar_cache[cache_key]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
+ def _calculate_solstice_zenith_angles(self) -> Dict[str, float]:
255
+ """Calculate solar zenith angles for summer and winter solstices at noon."""
256
  dates = [
257
+ datetime(2025, 12, 21, 12), # Summer solstice (Southern Hemisphere)
258
+ datetime(2025, 6, 21, 12) # Winter solstice (Southern Hemisphere)
259
  ]
260
+ solstice_zeniths = {}
261
  for date in dates:
262
+ solpos = pvlib.solarposition.get_solarposition(
263
+ time=date,
264
+ latitude=self.latitude,
265
+ longitude=self.longitude,
266
+ altitude=self.elevation
267
+ )
268
+ key = 'summer' if date.month == 12 else 'winter'
269
+ solstice_zeniths[key] = round(float(solpos['zenith'].iloc[0]), 1)
270
+ return solstice_zeniths
271
 
272
  def to_dict(self) -> Dict[str, Any]:
273
  """Convert the climate location to a dictionary."""
 
291
  "pressure": self.pressure,
292
  "direct_normal_irradiance": self.direct_normal_irradiance,
293
  "diffuse_horizontal_irradiance": self.diffuse_horizontal_irradiance,
294
+ "albedo": self.albedo,
295
  "hourly_data": self.hourly_data,
296
  "typical_extreme_periods": self.typical_extreme_periods,
297
  "ground_temperatures": self.ground_temperatures,
298
+ "solstice_zenith_angles": self.solstice_zenith_angles
299
  }
300
 
301
  class ClimateData:
 
344
  "winter_design_temp", "summer_design_temp_db", "summer_design_temp_wb",
345
  "summer_daily_range", "wind_speed", "pressure",
346
  "direct_normal_irradiance", "diffuse_horizontal_irradiance", "hourly_data",
347
+ "albedo", "solstice_zenith_angles"
348
  ]
349
 
350
  for field in required_fields:
 
388
  if not (0 <= data["diffuse_horizontal_irradiance"] <= 1200):
389
  st.error(f"Validation failed: Diffuse horizontal irradiance {data['diffuse_horizontal_irradiance']} outside range")
390
  return False
391
+ if not (0 <= data["albedo"] <= 1):
392
+ st.error(f"Validation failed: Albedo {data['albedo']} outside range")
393
+ return False
394
  if not data["hourly_data"] or len(data["hourly_data"]) < 8700:
395
  st.error(f"Validation failed: Hourly data has {len(data['hourly_data'])} records, expected ~8760")
396
  return False
 
422
  if not (0 <= record["diffuse_horizontal_irradiance"] <= 1200):
423
  st.error(f"Validation failed: Diffuse horizontal irradiance {record['diffuse_horizontal_irradiance']} outside range")
424
  return False
425
+ if not (0 <= record["wind_speed"] <= 30):
426
+ st.error(f"Validation failed: Wind speed {record['wind_speed']} outside range")
427
+ return False
428
+ if not (0 <= record["wind_direction"] <= 360):
429
+ st.error(f"Validation failed: Wind direction {record['wind_direction']} outside range")
430
+ return False
431
+ if not (0 <= record["solar_time"] < 24):
432
+ st.error(f"Validation failed: Solar time {record['solar_time']} outside range")
433
  return False
434
  if not (0 <= record["solar_zenith"] <= 180):
435
  st.error(f"Validation failed: Solar zenith {record['solar_zenith']} outside range")
 
437
  if not (0 <= record["solar_azimuth"] <= 360):
438
  st.error(f"Validation failed: Solar azimuth {record['solar_azimuth']} outside range")
439
  return False
440
+ if not (0 <= record["ground_reflected_radiation"] <= 1200):
441
+ st.error(f"Validation failed: Ground reflected radiation {record['ground_reflected_radiation']} outside range")
 
 
 
442
  return False
443
 
444
  # Validate typical/extreme periods (optional)
 
460
  st.error(f"Validation failed: Invalid ground temperatures for depth {depth}")
461
  return False
462
 
463
+ # Validate solstice zenith angles
464
+ if "solstice_zenith_angles" not in data or not all(key in data["solstice_zenith_angles"] for key in ["summer", "winter"]):
465
+ st.error("Validation failed: Missing or incomplete solstice zenith angles")
466
+ return False
467
+ if not (0 <= data["solstice_zenith_angles"]["summer"] <= 180 and 0 <= data["solstice_zenith_angles"]["winter"] <= 180):
468
+ st.error("Validation failed: Invalid solstice zenith angles")
 
 
 
 
 
 
 
 
 
 
469
  return False
470
 
471
  return True
 
498
  except ValueError:
499
  return False
500
 
501
+ def display_climate_input(self, session_state: Dict[str, Any], albedo: float = 0.2):
502
+ """Display Streamlit interface for EPW upload and visualizations."""
503
  st.title("Climate Data Analysis")
504
 
505
  # Apply consistent styling
 
510
  st.warning("Clearing invalid climate data from session state.")
511
  del session_state["climate_data"]
512
 
513
+ uploaded_file = st.file_uploader("Upload EPW File", type=["epw"])
 
 
514
 
515
  # Initialize location and epw_data for display
516
  location = None
517
  epw_data = None
518
 
519
+ if uploaded_file:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  try:
521
  # Process new EPW file
522
+ epw_content = uploaded_file.read().decode("utf-8")
523
  epw_lines = epw_content.splitlines()
524
 
525
  # Parse header
 
560
  period_name = parts[2 + i*4]
561
  period_type = parts[3 + i*4]
562
  start_date = parts[4 + i*4].strip()
563
+ end_date = bits[5 + i*4].strip()
564
  if period_name in [
565
  "Summer - Week Nearest Max Temperature For Period",
566
  "Summer - Week Nearest Average Temperature For Period",
 
632
  epw_file=epw_data,
633
  typical_extreme_periods=typical_extreme_periods,
634
  ground_temperatures=ground_temperatures,
635
+ albedo=albedo, # Pass albedo from main
636
  id=f"{country[:1].upper()}{city[:3].upper()}",
637
  country=country,
638
  state_province=state_province,
 
645
  climate_data_dict = location.to_dict()
646
  if not self.validate_climate_data(climate_data_dict):
647
  raise ValueError("Invalid climate data extracted from EPW file.")
 
 
 
648
  session_state["climate_data"] = climate_data_dict
649
  st.success("Climate data extracted from EPW file!")
650
+
651
  except Exception as e:
652
  st.error(f"Error processing EPW file: {str(e)}. Ensure it has 8760 hourly records and correct format.")
653
 
 
676
  epw_file=epw_data,
677
  typical_extreme_periods=climate_data_dict["typical_extreme_periods"],
678
  ground_temperatures=climate_data_dict["ground_temperatures"],
679
+ albedo=climate_data_dict["albedo"],
680
  id=climate_data_dict["id"],
681
  country=climate_data_dict["country"],
682
  state_province=climate_data_dict["state_province"],
 
688
  )
689
  # Override hourly_data to ensure consistency
690
  location.hourly_data = climate_data_dict["hourly_data"]
691
+ location.solstice_zenith_angles = climate_data_dict["solstice_zenith_angles"]
692
  self.add_location(location)
693
  st.info("Displaying previously extracted climate data.")
694
 
 
718
  self.plot_wind_rose(epw_data)
719
 
720
  else:
721
+ st.info("No climate data available. Please upload an EPW file to proceed.")
722
 
723
  def display_design_conditions(self, location: ClimateLocation):
724
  """Display design conditions for HVAC calculations using styled HTML."""
725
  st.subheader("Design Conditions")
726
 
727
  # Location Details
 
 
 
 
 
728
  st.markdown(f"""
729
  <div class="markdown-text">
730
  <h3>Location Details</h3>
 
736
  <li><strong>Longitude:</strong> {location.longitude}°</li>
737
  <li><strong>Elevation:</strong> {location.elevation} m</li>
738
  <li><strong>Time Zone:</strong> UTC{location.time_zone:+.1f}</li>
739
+ <li><strong>Summer Solstice Zenith Angle (Dec 21):</strong> {location.solstice_zenith_angles['summer']}°</li>
740
+ <li><strong>Winter Solstice Zenith Angle (Jun 21):</strong> {location.solstice_zenith_angles['winter']}°</li>
741
  </ul>
742
  </div>
743
  """, unsafe_allow_html=True)
 
758
  <li><strong>Mean Atmospheric Pressure:</strong> {location.pressure} Pa</li>
759
  <li><strong>Mean Direct Normal Irradiance:</strong> {location.direct_normal_irradiance} W/m²</li>
760
  <li><strong>Mean Diffuse Horizontal Irradiance:</strong> {location.diffuse_horizontal_irradiance} W/m²</li>
761
+ <li><strong>Albedo:</strong> {location.albedo}</li>
762
  </ul>
763
  </div>
764
  """, unsafe_allow_html=True)
 
778
  </div>
779
  """, unsafe_allow_html=True)
780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  # Ground Temperatures (Table)
782
  if location.ground_temperatures:
783
  st.markdown('<div class="markdown-text"><h3>Ground Temperatures</h3></div>', unsafe_allow_html=True)
 
1107
  epw_file=epw_data,
1108
  typical_extreme_periods=loc_dict["typical_extreme_periods"],
1109
  ground_temperatures=loc_dict["ground_temperatures"],
1110
+ albedo=loc_dict["albedo"],
1111
  id=loc_dict["id"],
1112
  country=loc_dict["country"],
1113
  state_province=loc_dict["state_province"],
 
1117
  elevation=loc_dict["elevation"],
1118
  time_zone=loc_dict["time_zone"]
1119
  )
1120
+ location.hourly_data = loc_dict["hourly_data"] # Restore all hourly data including solar and radiation fields
1121
+ location.solstice_zenith_angles = loc_dict["solstice_zenith_angles"]
1122
  climate_data.add_location(location)
1123
  return climate_data
1124
 
1125
  if __name__ == "__main__":
1126
  climate_data = ClimateData()
1127
  session_state = {"building_info": {"country": "Australia", "city": "Geelong"}, "page": "Climate Data"}
1128
+ climate_data.display_climate_input(session_state, albedo=0.2) # Default albedo