voxcity 0.6.15__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voxcity/__init__.py +14 -8
- voxcity/downloader/__init__.py +2 -1
- voxcity/downloader/citygml.py +32 -18
- voxcity/downloader/gba.py +210 -0
- voxcity/downloader/gee.py +5 -1
- voxcity/downloader/mbfp.py +1 -1
- voxcity/downloader/oemj.py +80 -8
- voxcity/downloader/osm.py +23 -7
- voxcity/downloader/overture.py +26 -1
- voxcity/downloader/utils.py +73 -73
- voxcity/errors.py +30 -0
- voxcity/exporter/__init__.py +13 -4
- voxcity/exporter/cityles.py +633 -535
- voxcity/exporter/envimet.py +728 -708
- voxcity/exporter/magicavoxel.py +334 -297
- voxcity/exporter/netcdf.py +238 -0
- voxcity/exporter/obj.py +1481 -655
- voxcity/generator/__init__.py +44 -0
- voxcity/generator/api.py +675 -0
- voxcity/generator/grids.py +379 -0
- voxcity/generator/io.py +94 -0
- voxcity/generator/pipeline.py +282 -0
- voxcity/generator/voxelizer.py +380 -0
- voxcity/geoprocessor/__init__.py +75 -6
- voxcity/geoprocessor/conversion.py +153 -0
- voxcity/geoprocessor/draw.py +62 -12
- voxcity/geoprocessor/heights.py +199 -0
- voxcity/geoprocessor/io.py +101 -0
- voxcity/geoprocessor/merge_utils.py +91 -0
- voxcity/geoprocessor/mesh.py +806 -790
- voxcity/geoprocessor/network.py +708 -679
- voxcity/geoprocessor/overlap.py +84 -0
- voxcity/geoprocessor/raster/__init__.py +82 -0
- voxcity/geoprocessor/raster/buildings.py +428 -0
- voxcity/geoprocessor/raster/canopy.py +258 -0
- voxcity/geoprocessor/raster/core.py +150 -0
- voxcity/geoprocessor/raster/export.py +93 -0
- voxcity/geoprocessor/raster/landcover.py +156 -0
- voxcity/geoprocessor/raster/raster.py +110 -0
- voxcity/geoprocessor/selection.py +85 -0
- voxcity/geoprocessor/utils.py +18 -14
- voxcity/models.py +113 -0
- voxcity/simulator/common/__init__.py +22 -0
- voxcity/simulator/common/geometry.py +98 -0
- voxcity/simulator/common/raytracing.py +450 -0
- voxcity/simulator/solar/__init__.py +43 -0
- voxcity/simulator/solar/integration.py +336 -0
- voxcity/simulator/solar/kernels.py +62 -0
- voxcity/simulator/solar/radiation.py +648 -0
- voxcity/simulator/solar/temporal.py +434 -0
- voxcity/simulator/view.py +36 -2286
- voxcity/simulator/visibility/__init__.py +29 -0
- voxcity/simulator/visibility/landmark.py +392 -0
- voxcity/simulator/visibility/view.py +508 -0
- voxcity/utils/logging.py +61 -0
- voxcity/utils/orientation.py +51 -0
- voxcity/utils/weather/__init__.py +26 -0
- voxcity/utils/weather/epw.py +146 -0
- voxcity/utils/weather/files.py +36 -0
- voxcity/utils/weather/onebuilding.py +486 -0
- voxcity/visualizer/__init__.py +24 -0
- voxcity/visualizer/builder.py +43 -0
- voxcity/visualizer/grids.py +141 -0
- voxcity/visualizer/maps.py +187 -0
- voxcity/visualizer/palette.py +228 -0
- voxcity/visualizer/renderer.py +928 -0
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
- voxcity-0.7.0.dist-info/RECORD +77 -0
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
- voxcity/generator.py +0 -1137
- voxcity/geoprocessor/grid.py +0 -1568
- voxcity/geoprocessor/polygon.py +0 -1344
- voxcity/simulator/solar.py +0 -2329
- voxcity/utils/visualization.py +0 -2660
- voxcity/utils/weather.py +0 -817
- voxcity-0.6.15.dist-info/RECORD +0 -37
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/LICENSE +0 -0
voxcity/utils/weather.py
DELETED
|
@@ -1,817 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Weather data utilities for VoxelCity.
|
|
3
|
-
|
|
4
|
-
This module provides functionality to download and process Energyplus Weather (EPW) files
|
|
5
|
-
from Climate.OneBuilding.Org based on geographical coordinates. It includes utilities for:
|
|
6
|
-
|
|
7
|
-
- Automatically finding the nearest weather station to given coordinates
|
|
8
|
-
- Downloading EPW files from various global regions
|
|
9
|
-
- Processing EPW files into pandas DataFrames for analysis
|
|
10
|
-
- Extracting solar radiation data for solar simulations
|
|
11
|
-
|
|
12
|
-
The main function get_nearest_epw_from_climate_onebuilding() provides a comprehensive
|
|
13
|
-
solution for obtaining weather data for any global location by automatically detecting
|
|
14
|
-
the appropriate region and finding the closest available weather station.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
import requests
|
|
18
|
-
import xml.etree.ElementTree as ET
|
|
19
|
-
import re
|
|
20
|
-
from math import radians, sin, cos, sqrt, atan2
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
from typing import Optional, Dict, List, Tuple, Union
|
|
23
|
-
import json
|
|
24
|
-
import zipfile
|
|
25
|
-
import pandas as pd
|
|
26
|
-
import io
|
|
27
|
-
import os
|
|
28
|
-
import numpy as np
|
|
29
|
-
from datetime import datetime
|
|
30
|
-
|
|
31
|
-
# =============================================================================
|
|
32
|
-
# FILE HANDLING UTILITIES
|
|
33
|
-
# =============================================================================
|
|
34
|
-
|
|
35
|
-
def safe_rename(src: Path, dst: Path) -> Path:
|
|
36
|
-
"""
|
|
37
|
-
Safely rename a file, handling existing files by adding a number suffix.
|
|
38
|
-
|
|
39
|
-
This function prevents file conflicts by automatically generating unique filenames
|
|
40
|
-
when the target destination already exists. It appends incremental numbers to
|
|
41
|
-
the base filename until a unique name is found.
|
|
42
|
-
|
|
43
|
-
Args:
|
|
44
|
-
src: Source file path
|
|
45
|
-
dst: Destination file path
|
|
46
|
-
|
|
47
|
-
Returns:
|
|
48
|
-
Path: Final destination path used
|
|
49
|
-
"""
|
|
50
|
-
# If destination doesn't exist, simple rename
|
|
51
|
-
if not dst.exists():
|
|
52
|
-
src.rename(dst)
|
|
53
|
-
return dst
|
|
54
|
-
|
|
55
|
-
# If file exists, add number suffix
|
|
56
|
-
base = dst.stem
|
|
57
|
-
ext = dst.suffix
|
|
58
|
-
counter = 1
|
|
59
|
-
# Keep incrementing counter until we find a name that doesn't exist
|
|
60
|
-
while True:
|
|
61
|
-
new_dst = dst.with_name(f"{base}_{counter}{ext}")
|
|
62
|
-
if not new_dst.exists():
|
|
63
|
-
src.rename(new_dst)
|
|
64
|
-
return new_dst
|
|
65
|
-
counter += 1
|
|
66
|
-
|
|
67
|
-
def safe_extract(zip_ref: zipfile.ZipFile, filename: str, extract_dir: Path) -> Path:
|
|
68
|
-
"""
|
|
69
|
-
Safely extract a file from zip, handling existing files.
|
|
70
|
-
|
|
71
|
-
This function handles the case where a file with the same name already exists
|
|
72
|
-
in the extraction directory by using a temporary filename with random suffix.
|
|
73
|
-
|
|
74
|
-
Args:
|
|
75
|
-
zip_ref: Open ZipFile reference
|
|
76
|
-
filename: Name of file to extract
|
|
77
|
-
extract_dir: Directory to extract to
|
|
78
|
-
|
|
79
|
-
Returns:
|
|
80
|
-
Path: Path to extracted file
|
|
81
|
-
"""
|
|
82
|
-
try:
|
|
83
|
-
zip_ref.extract(filename, extract_dir)
|
|
84
|
-
return extract_dir / filename
|
|
85
|
-
except FileExistsError:
|
|
86
|
-
# If file exists, extract to temporary name and return path
|
|
87
|
-
temp_name = f"temp_{os.urandom(4).hex()}_{filename}"
|
|
88
|
-
zip_ref.extract(filename, extract_dir, temp_name)
|
|
89
|
-
return extract_dir / temp_name
|
|
90
|
-
|
|
91
|
-
# =============================================================================
|
|
92
|
-
# EPW FILE PROCESSING
|
|
93
|
-
# =============================================================================
|
|
94
|
-
|
|
95
|
-
def process_epw(epw_path: Union[str, Path]) -> Tuple[pd.DataFrame, Dict]:
|
|
96
|
-
"""
|
|
97
|
-
Process an EPW file into a pandas DataFrame.
|
|
98
|
-
|
|
99
|
-
EPW (EnergyPlus Weather) files contain standardized weather data in a specific format.
|
|
100
|
-
The first 8 lines contain metadata, followed by 8760 lines of hourly weather data
|
|
101
|
-
for a typical meteorological year.
|
|
102
|
-
|
|
103
|
-
Args:
|
|
104
|
-
epw_path: Path to the EPW file
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
Tuple containing:
|
|
108
|
-
- DataFrame with hourly weather data indexed by datetime
|
|
109
|
-
- Dictionary with EPW header metadata including location information
|
|
110
|
-
"""
|
|
111
|
-
# EPW column names (these are standardized across all EPW files)
|
|
112
|
-
columns = [
|
|
113
|
-
'Year', 'Month', 'Day', 'Hour', 'Minute',
|
|
114
|
-
'Data Source and Uncertainty Flags',
|
|
115
|
-
'Dry Bulb Temperature', 'Dew Point Temperature',
|
|
116
|
-
'Relative Humidity', 'Atmospheric Station Pressure',
|
|
117
|
-
'Extraterrestrial Horizontal Radiation',
|
|
118
|
-
'Extraterrestrial Direct Normal Radiation',
|
|
119
|
-
'Horizontal Infrared Radiation Intensity',
|
|
120
|
-
'Global Horizontal Radiation',
|
|
121
|
-
'Direct Normal Radiation', 'Diffuse Horizontal Radiation',
|
|
122
|
-
'Global Horizontal Illuminance',
|
|
123
|
-
'Direct Normal Illuminance', 'Diffuse Horizontal Illuminance',
|
|
124
|
-
'Zenith Luminance', 'Wind Direction', 'Wind Speed',
|
|
125
|
-
'Total Sky Cover', 'Opaque Sky Cover', 'Visibility',
|
|
126
|
-
'Ceiling Height', 'Present Weather Observation',
|
|
127
|
-
'Present Weather Codes', 'Precipitable Water',
|
|
128
|
-
'Aerosol Optical Depth', 'Snow Depth',
|
|
129
|
-
'Days Since Last Snowfall', 'Albedo',
|
|
130
|
-
'Liquid Precipitation Depth', 'Liquid Precipitation Quantity'
|
|
131
|
-
]
|
|
132
|
-
|
|
133
|
-
# Read EPW file - EPW files are always in comma-separated format
|
|
134
|
-
with open(epw_path, 'r') as f:
|
|
135
|
-
lines = f.readlines()
|
|
136
|
-
|
|
137
|
-
# Extract header metadata (first 8 lines contain standardized metadata)
|
|
138
|
-
headers = {
|
|
139
|
-
'LOCATION': lines[0].strip(),
|
|
140
|
-
'DESIGN_CONDITIONS': lines[1].strip(),
|
|
141
|
-
'TYPICAL_EXTREME_PERIODS': lines[2].strip(),
|
|
142
|
-
'GROUND_TEMPERATURES': lines[3].strip(),
|
|
143
|
-
'HOLIDAYS_DAYLIGHT_SAVINGS': lines[4].strip(),
|
|
144
|
-
'COMMENTS_1': lines[5].strip(),
|
|
145
|
-
'COMMENTS_2': lines[6].strip(),
|
|
146
|
-
'DATA_PERIODS': lines[7].strip()
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
# Parse location data from first header line
|
|
150
|
-
# Format: LOCATION,City,State,Country,Source,WMO,Latitude,Longitude,TimeZone,Elevation
|
|
151
|
-
location = headers['LOCATION'].split(',')
|
|
152
|
-
if len(location) >= 10:
|
|
153
|
-
headers['LOCATION'] = {
|
|
154
|
-
'City': location[1].strip(),
|
|
155
|
-
'State': location[2].strip(),
|
|
156
|
-
'Country': location[3].strip(),
|
|
157
|
-
'Data Source': location[4].strip(),
|
|
158
|
-
'WMO': location[5].strip(),
|
|
159
|
-
'Latitude': float(location[6]),
|
|
160
|
-
'Longitude': float(location[7]),
|
|
161
|
-
'Time Zone': float(location[8]),
|
|
162
|
-
'Elevation': float(location[9])
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
# Create DataFrame from weather data (skipping 8 header lines)
|
|
166
|
-
data = [line.strip().split(',') for line in lines[8:]]
|
|
167
|
-
df = pd.DataFrame(data, columns=columns)
|
|
168
|
-
|
|
169
|
-
# Convert numeric columns to appropriate data types
|
|
170
|
-
# All weather parameters should be numeric except uncertainty flags and weather codes
|
|
171
|
-
numeric_columns = [
|
|
172
|
-
'Year', 'Month', 'Day', 'Hour', 'Minute',
|
|
173
|
-
'Dry Bulb Temperature', 'Dew Point Temperature',
|
|
174
|
-
'Relative Humidity', 'Atmospheric Station Pressure',
|
|
175
|
-
'Extraterrestrial Horizontal Radiation',
|
|
176
|
-
'Extraterrestrial Direct Normal Radiation',
|
|
177
|
-
'Horizontal Infrared Radiation Intensity',
|
|
178
|
-
'Global Horizontal Radiation',
|
|
179
|
-
'Direct Normal Radiation', 'Diffuse Horizontal Radiation',
|
|
180
|
-
'Global Horizontal Illuminance',
|
|
181
|
-
'Direct Normal Illuminance', 'Diffuse Horizontal Illuminance',
|
|
182
|
-
'Zenith Luminance', 'Wind Direction', 'Wind Speed',
|
|
183
|
-
'Total Sky Cover', 'Opaque Sky Cover', 'Visibility',
|
|
184
|
-
'Ceiling Height', 'Precipitable Water',
|
|
185
|
-
'Aerosol Optical Depth', 'Snow Depth',
|
|
186
|
-
'Days Since Last Snowfall', 'Albedo',
|
|
187
|
-
'Liquid Precipitation Depth', 'Liquid Precipitation Quantity'
|
|
188
|
-
]
|
|
189
|
-
|
|
190
|
-
# Convert to numeric, handling any parsing errors gracefully
|
|
191
|
-
for col in numeric_columns:
|
|
192
|
-
df[col] = pd.to_numeric(df[col], errors='coerce')
|
|
193
|
-
|
|
194
|
-
# Create datetime index for time series analysis
|
|
195
|
-
# EPW hours are 1-24, but pandas expects 0-23 for proper datetime handling
|
|
196
|
-
df['datetime'] = pd.to_datetime({
|
|
197
|
-
'year': df['Year'],
|
|
198
|
-
'month': df['Month'],
|
|
199
|
-
'day': df['Day'],
|
|
200
|
-
'hour': df['Hour'] - 1, # EPW hours are 1-24, pandas expects 0-23
|
|
201
|
-
'minute': df['Minute']
|
|
202
|
-
})
|
|
203
|
-
df.set_index('datetime', inplace=True)
|
|
204
|
-
|
|
205
|
-
return df, headers
|
|
206
|
-
|
|
207
|
-
# =============================================================================
|
|
208
|
-
# MAIN WEATHER DATA DOWNLOAD FUNCTION
|
|
209
|
-
# =============================================================================
|
|
210
|
-
|
|
211
|
-
def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float, output_dir: str = "./", max_distance: Optional[float] = None,
|
|
212
|
-
extract_zip: bool = True, load_data: bool = True, region: Optional[Union[str, List[str]]] = None) -> Tuple[Optional[str], Optional[pd.DataFrame], Optional[Dict]]:
|
|
213
|
-
"""
|
|
214
|
-
Download and process EPW weather file from Climate.OneBuilding.Org based on coordinates.
|
|
215
|
-
|
|
216
|
-
This function automatically finds and downloads the nearest available weather station
|
|
217
|
-
data from Climate.OneBuilding.Org's global database. It supports region-based searching
|
|
218
|
-
for improved performance and can automatically detect the appropriate region based on
|
|
219
|
-
coordinates.
|
|
220
|
-
|
|
221
|
-
The function performs the following steps:
|
|
222
|
-
1. Determines which regional KML files to scan based on coordinates or user input
|
|
223
|
-
2. Downloads and parses KML files to extract weather station metadata
|
|
224
|
-
3. Calculates distances to find the nearest station
|
|
225
|
-
4. Downloads the EPW file from the nearest station
|
|
226
|
-
5. Optionally processes the EPW data into a pandas DataFrame
|
|
227
|
-
|
|
228
|
-
Args:
|
|
229
|
-
longitude (float): Longitude of the location (-180 to 180)
|
|
230
|
-
latitude (float): Latitude of the location (-90 to 90)
|
|
231
|
-
output_dir (str): Directory to save the EPW file (defaults to current directory)
|
|
232
|
-
max_distance (float, optional): Maximum distance in kilometers to search for stations.
|
|
233
|
-
If no stations within this distance, uses closest available.
|
|
234
|
-
extract_zip (bool): Whether to extract the ZIP file (default True)
|
|
235
|
-
load_data (bool): Whether to load the EPW data into a DataFrame (default True)
|
|
236
|
-
region (str or List[str], optional): Specific region(s) to scan for stations.
|
|
237
|
-
Options: "Africa", "Asia", "Japan", "India", "Argentina",
|
|
238
|
-
"Canada", "USA", "Caribbean", "Southwest_Pacific",
|
|
239
|
-
"Europe", "Antarctica", or "all".
|
|
240
|
-
If None, will auto-detect region based on coordinates.
|
|
241
|
-
|
|
242
|
-
Returns:
|
|
243
|
-
Tuple containing:
|
|
244
|
-
- Path to the EPW file (or None if download fails)
|
|
245
|
-
- DataFrame with hourly weather data (if load_data=True, else None)
|
|
246
|
-
- Dictionary with EPW header metadata (if load_data=True, else None)
|
|
247
|
-
|
|
248
|
-
Raises:
|
|
249
|
-
ValueError: If invalid region specified or no weather stations found
|
|
250
|
-
requests.exceptions.RequestException: If network requests fail
|
|
251
|
-
"""
|
|
252
|
-
|
|
253
|
-
# Regional KML sources from Climate.OneBuilding.Org
|
|
254
|
-
# Each region maintains its own KML file with weather station locations and metadata
|
|
255
|
-
KML_SOURCES = {
|
|
256
|
-
"Africa": "https://climate.onebuilding.org/WMO_Region_1_Africa/Region1_Africa_EPW_Processing_locations.kml",
|
|
257
|
-
"Asia": "https://climate.onebuilding.org/WMO_Region_2_Asia/Region2_Asia_EPW_Processing_locations.kml",
|
|
258
|
-
"Japan": "https://climate.onebuilding.org/sources/JGMY_EPW_Processing_locations.kml",
|
|
259
|
-
"India": "https://climate.onebuilding.org/sources/ITMY_EPW_Processing_locations.kml",
|
|
260
|
-
"Argentina": "https://climate.onebuilding.org/sources/ArgTMY_EPW_Processing_locations.kml",
|
|
261
|
-
"Canada": "https://climate.onebuilding.org/sources/Region4_Canada_TMYx_EPW_Processing_locations.kml",
|
|
262
|
-
"USA": "https://climate.onebuilding.org/sources/Region4_USA_TMYx_EPW_Processing_locations.kml",
|
|
263
|
-
"Caribbean": "https://climate.onebuilding.org/sources/Region4_NA_CA_Caribbean_TMYx_EPW_Processing_locations.kml",
|
|
264
|
-
"Southwest_Pacific": "https://climate.onebuilding.org/sources/Region5_Southwest_Pacific_TMYx_EPW_Processing_locations.kml",
|
|
265
|
-
"Europe": "https://climate.onebuilding.org/sources/Region6_Europe_TMYx_EPW_Processing_locations.kml",
|
|
266
|
-
"Antarctica": "https://climate.onebuilding.org/sources/Region7_Antarctica_TMYx_EPW_Processing_locations.kml"
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
# Define approximate geographical boundaries for automatic region detection
|
|
270
|
-
# These bounds help determine which regional KML files to scan based on coordinates
|
|
271
|
-
REGION_BOUNDS = {
|
|
272
|
-
"Africa": {"lon_min": -20, "lon_max": 55, "lat_min": -35, "lat_max": 40},
|
|
273
|
-
"Asia": {"lon_min": 25, "lon_max": 150, "lat_min": 0, "lat_max": 55},
|
|
274
|
-
"Japan": {"lon_min": 127, "lon_max": 146, "lat_min": 24, "lat_max": 46},
|
|
275
|
-
"India": {"lon_min": 68, "lon_max": 97, "lat_min": 6, "lat_max": 36},
|
|
276
|
-
"Argentina": {"lon_min": -75, "lon_max": -53, "lat_min": -55, "lat_max": -22},
|
|
277
|
-
"Canada": {"lon_min": -141, "lon_max": -52, "lat_min": 42, "lat_max": 83},
|
|
278
|
-
"USA": {"lon_min": -170, "lon_max": -65, "lat_min": 20, "lat_max": 72},
|
|
279
|
-
"Caribbean": {"lon_min": -90, "lon_max": -59, "lat_min": 10, "lat_max": 27},
|
|
280
|
-
"Southwest_Pacific": {"lon_min": 110, "lon_max": 180, "lat_min": -50, "lat_max": 0},
|
|
281
|
-
"Europe": {"lon_min": -25, "lon_max": 40, "lat_min": 35, "lat_max": 72},
|
|
282
|
-
"Antarctica": {"lon_min": -180, "lon_max": 180, "lat_min": -90, "lat_max": -60}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
def detect_regions(lon: float, lat: float) -> List[str]:
|
|
286
|
-
"""
|
|
287
|
-
Detect which region(s) the coordinates belong to.
|
|
288
|
-
|
|
289
|
-
Uses the REGION_BOUNDS to determine appropriate regions to search.
|
|
290
|
-
If coordinates don't fall within any region, returns the 3 closest regions.
|
|
291
|
-
|
|
292
|
-
Args:
|
|
293
|
-
lon: Longitude coordinate
|
|
294
|
-
lat: Latitude coordinate
|
|
295
|
-
|
|
296
|
-
Returns:
|
|
297
|
-
List of region names to search
|
|
298
|
-
"""
|
|
299
|
-
matching_regions = []
|
|
300
|
-
|
|
301
|
-
# Handle special case of longitude wrap around 180/-180
|
|
302
|
-
# Normalize longitude to standard -180 to 180 range
|
|
303
|
-
lon_adjusted = lon
|
|
304
|
-
if lon < -180:
|
|
305
|
-
lon_adjusted = lon + 360
|
|
306
|
-
elif lon > 180:
|
|
307
|
-
lon_adjusted = lon - 360
|
|
308
|
-
|
|
309
|
-
# Check if coordinates fall within any region bounds
|
|
310
|
-
for region_name, bounds in REGION_BOUNDS.items():
|
|
311
|
-
# Check if point is within region bounds
|
|
312
|
-
if (bounds["lon_min"] <= lon_adjusted <= bounds["lon_max"] and
|
|
313
|
-
bounds["lat_min"] <= lat <= bounds["lat_max"]):
|
|
314
|
-
matching_regions.append(region_name)
|
|
315
|
-
|
|
316
|
-
# If no regions matched, find the closest regions by boundary distance
|
|
317
|
-
if not matching_regions:
|
|
318
|
-
# Calculate "distance" to each region's boundary (simplified metric)
|
|
319
|
-
region_distances = []
|
|
320
|
-
for region_name, bounds in REGION_BOUNDS.items():
|
|
321
|
-
# Calculate distance to closest edge of region bounds
|
|
322
|
-
lon_dist = 0
|
|
323
|
-
if lon_adjusted < bounds["lon_min"]:
|
|
324
|
-
lon_dist = bounds["lon_min"] - lon_adjusted
|
|
325
|
-
elif lon_adjusted > bounds["lon_max"]:
|
|
326
|
-
lon_dist = lon_adjusted - bounds["lon_max"]
|
|
327
|
-
|
|
328
|
-
lat_dist = 0
|
|
329
|
-
if lat < bounds["lat_min"]:
|
|
330
|
-
lat_dist = bounds["lat_min"] - lat
|
|
331
|
-
elif lat > bounds["lat_max"]:
|
|
332
|
-
lat_dist = lat - bounds["lat_max"]
|
|
333
|
-
|
|
334
|
-
# Simple Euclidean distance metric (not actual geographic distance)
|
|
335
|
-
distance = (lon_dist**2 + lat_dist**2)**0.5
|
|
336
|
-
region_distances.append((region_name, distance))
|
|
337
|
-
|
|
338
|
-
# Get 3 closest regions to ensure we find stations
|
|
339
|
-
closest_regions = sorted(region_distances, key=lambda x: x[1])[:3]
|
|
340
|
-
matching_regions = [r[0] for r in closest_regions]
|
|
341
|
-
|
|
342
|
-
return matching_regions
|
|
343
|
-
|
|
344
|
-
def try_decode(content: bytes) -> str:
|
|
345
|
-
"""
|
|
346
|
-
Try different encodings to decode content.
|
|
347
|
-
|
|
348
|
-
KML files from different regions may use various text encodings.
|
|
349
|
-
This function tries common encodings to successfully decode the content.
|
|
350
|
-
|
|
351
|
-
Args:
|
|
352
|
-
content: Raw bytes content
|
|
353
|
-
|
|
354
|
-
Returns:
|
|
355
|
-
Decoded string content
|
|
356
|
-
"""
|
|
357
|
-
# Try common encodings in order of preference
|
|
358
|
-
encodings = ['utf-8', 'latin1', 'iso-8859-1', 'cp1252']
|
|
359
|
-
for encoding in encodings:
|
|
360
|
-
try:
|
|
361
|
-
return content.decode(encoding)
|
|
362
|
-
except UnicodeDecodeError:
|
|
363
|
-
continue
|
|
364
|
-
|
|
365
|
-
# If all else fails, try to decode with replacement characters
|
|
366
|
-
return content.decode('utf-8', errors='replace')
|
|
367
|
-
|
|
368
|
-
def clean_xml(content: str) -> str:
|
|
369
|
-
"""
|
|
370
|
-
Clean XML content of invalid characters.
|
|
371
|
-
|
|
372
|
-
Some KML files contain characters that cause XML parsing issues.
|
|
373
|
-
This function replaces or removes problematic characters to ensure
|
|
374
|
-
successful XML parsing.
|
|
375
|
-
|
|
376
|
-
Args:
|
|
377
|
-
content: Raw XML content string
|
|
378
|
-
|
|
379
|
-
Returns:
|
|
380
|
-
Cleaned XML content string
|
|
381
|
-
"""
|
|
382
|
-
# Replace problematic Spanish characters that cause XML parsing issues
|
|
383
|
-
content = content.replace('ñ', 'n')
|
|
384
|
-
content = content.replace('Ñ', 'N')
|
|
385
|
-
content = content.replace('ñ', 'n')
|
|
386
|
-
content = content.replace('Ñ', 'N')
|
|
387
|
-
|
|
388
|
-
# Remove other invalid XML characters using regex
|
|
389
|
-
# Keep only valid XML characters: tab, newline, carriage return, printable ASCII, and extended Latin
|
|
390
|
-
content = re.sub(r'[^\x09\x0A\x0D\x20-\x7E\x85\xA0-\xFF]', '', content)
|
|
391
|
-
return content
|
|
392
|
-
|
|
393
|
-
def haversine_distance(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
|
|
394
|
-
"""
|
|
395
|
-
Calculate the great circle distance between two points on Earth.
|
|
396
|
-
|
|
397
|
-
Uses the Haversine formula to calculate the shortest distance between
|
|
398
|
-
two points on a sphere (Earth) given their latitude and longitude.
|
|
399
|
-
|
|
400
|
-
Args:
|
|
401
|
-
lon1, lat1: Coordinates of first point
|
|
402
|
-
lon2, lat2: Coordinates of second point
|
|
403
|
-
|
|
404
|
-
Returns:
|
|
405
|
-
Distance in kilometers
|
|
406
|
-
"""
|
|
407
|
-
R = 6371 # Earth's radius in kilometers
|
|
408
|
-
|
|
409
|
-
# Convert coordinates to radians
|
|
410
|
-
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
|
411
|
-
dlat = lat2 - lat1
|
|
412
|
-
dlon = lon2 - lon1
|
|
413
|
-
|
|
414
|
-
# Haversine formula calculation
|
|
415
|
-
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
|
416
|
-
c = 2 * atan2(sqrt(a), sqrt(1-a))
|
|
417
|
-
return R * c
|
|
418
|
-
|
|
419
|
-
def parse_coordinates(point_text: str) -> Tuple[float, float, float]:
|
|
420
|
-
"""
|
|
421
|
-
Parse coordinates from KML Point text.
|
|
422
|
-
|
|
423
|
-
KML Point elements contain coordinates in the format "longitude,latitude,elevation".
|
|
424
|
-
This function extracts and converts these values to float.
|
|
425
|
-
|
|
426
|
-
Args:
|
|
427
|
-
point_text: Raw coordinate text from KML Point element
|
|
428
|
-
|
|
429
|
-
Returns:
|
|
430
|
-
Tuple of (latitude, longitude, elevation) or None if parsing fails
|
|
431
|
-
"""
|
|
432
|
-
try:
|
|
433
|
-
coords = point_text.strip().split(',')
|
|
434
|
-
if len(coords) >= 2:
|
|
435
|
-
lon, lat = map(float, coords[:2])
|
|
436
|
-
elevation = float(coords[2]) if len(coords) > 2 else 0
|
|
437
|
-
return lat, lon, elevation
|
|
438
|
-
except (ValueError, IndexError):
|
|
439
|
-
pass
|
|
440
|
-
return None
|
|
441
|
-
|
|
442
|
-
def parse_station_from_description(desc: str, point_coords: Optional[Tuple[float, float, float]] = None) -> Dict:
|
|
443
|
-
"""
|
|
444
|
-
Parse station metadata from KML description.
|
|
445
|
-
|
|
446
|
-
KML description fields contain detailed station information including:
|
|
447
|
-
- Download URL for EPW file
|
|
448
|
-
- Coordinates in degrees/minutes format
|
|
449
|
-
- Station metadata (WMO code, climate zone, etc.)
|
|
450
|
-
- Design conditions and climate statistics
|
|
451
|
-
|
|
452
|
-
Args:
|
|
453
|
-
desc: KML description text containing station metadata
|
|
454
|
-
point_coords: Fallback coordinates from KML Point element
|
|
455
|
-
|
|
456
|
-
Returns:
|
|
457
|
-
Dictionary with parsed station metadata or None if parsing fails
|
|
458
|
-
"""
|
|
459
|
-
if not desc:
|
|
460
|
-
return None
|
|
461
|
-
|
|
462
|
-
# Extract download URL - this is required for station to be valid
|
|
463
|
-
url_match = re.search(r'URL (https://.*?\.zip)', desc)
|
|
464
|
-
if not url_match:
|
|
465
|
-
return None
|
|
466
|
-
|
|
467
|
-
url = url_match.group(1)
|
|
468
|
-
|
|
469
|
-
# First try to parse coordinates in degrees/minutes format from description
|
|
470
|
-
# Format: N XX°YY.YY' W ZZ°AA.AA'
|
|
471
|
-
coord_match = re.search(r'([NS]) (\d+)°\s*(\d+\.\d+)\'.*?([EW]) (\d+)°\s*(\d+\.\d+)\'', desc)
|
|
472
|
-
|
|
473
|
-
if coord_match:
|
|
474
|
-
# Convert degrees/minutes to decimal degrees
|
|
475
|
-
ns, lat_deg, lat_min, ew, lon_deg, lon_min = coord_match.groups()
|
|
476
|
-
lat = float(lat_deg) + float(lat_min)/60
|
|
477
|
-
if ns == 'S':
|
|
478
|
-
lat = -lat
|
|
479
|
-
lon = float(lon_deg) + float(lon_min)/60
|
|
480
|
-
if ew == 'W':
|
|
481
|
-
lon = -lon
|
|
482
|
-
elif point_coords:
|
|
483
|
-
# Fall back to coordinates from KML Point element
|
|
484
|
-
lat, lon, _ = point_coords
|
|
485
|
-
else:
|
|
486
|
-
# No coordinates available - station is not usable
|
|
487
|
-
return None
|
|
488
|
-
|
|
489
|
-
# Extract metadata with error handling using helper function
|
|
490
|
-
def extract_value(pattern: str, default: str = None) -> str:
|
|
491
|
-
"""Extract value using regex pattern, return default if not found."""
|
|
492
|
-
match = re.search(pattern, desc)
|
|
493
|
-
return match.group(1) if match else default
|
|
494
|
-
|
|
495
|
-
# Build comprehensive station metadata dictionary
|
|
496
|
-
metadata = {
|
|
497
|
-
'url': url,
|
|
498
|
-
'longitude': lon,
|
|
499
|
-
'latitude': lat,
|
|
500
|
-
'elevation': int(extract_value(r'Elevation <b>(-?\d+)</b>', '0')),
|
|
501
|
-
'name': extract_value(r'<b>(.*?)</b>'),
|
|
502
|
-
'wmo': extract_value(r'WMO <b>(\d+)</b>'),
|
|
503
|
-
'climate_zone': extract_value(r'Climate Zone <b>(.*?)</b>'),
|
|
504
|
-
'period': extract_value(r'Period of Record=(\d{4}-\d{4})'),
|
|
505
|
-
'heating_db': extract_value(r'99% Heating DB <b>(.*?)</b>'),
|
|
506
|
-
'cooling_db': extract_value(r'1% Cooling DB <b>(.*?)</b>'),
|
|
507
|
-
'hdd18': extract_value(r'HDD18 <b>(\d+)</b>'),
|
|
508
|
-
'cdd10': extract_value(r'CDD10 <b>(\d+)</b>'),
|
|
509
|
-
'time_zone': extract_value(r'Time Zone {GMT <b>([-+]?\d+\.\d+)</b>')
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
return metadata
|
|
513
|
-
|
|
514
|
-
def get_stations_from_kml(kml_url: str) -> List[Dict]:
|
|
515
|
-
"""
|
|
516
|
-
Get weather stations from a KML file.
|
|
517
|
-
|
|
518
|
-
Downloads and parses a KML file containing weather station information.
|
|
519
|
-
Each Placemark in the KML represents a weather station with metadata
|
|
520
|
-
in the description field and coordinates in Point elements.
|
|
521
|
-
|
|
522
|
-
Args:
|
|
523
|
-
kml_url: URL to the KML file
|
|
524
|
-
|
|
525
|
-
Returns:
|
|
526
|
-
List of dictionaries containing station metadata
|
|
527
|
-
"""
|
|
528
|
-
try:
|
|
529
|
-
# Download KML file with timeout
|
|
530
|
-
response = requests.get(kml_url, timeout=30)
|
|
531
|
-
response.raise_for_status()
|
|
532
|
-
|
|
533
|
-
# Try to decode content with multiple encodings
|
|
534
|
-
content = try_decode(response.content)
|
|
535
|
-
content = clean_xml(content)
|
|
536
|
-
|
|
537
|
-
# Parse XML content
|
|
538
|
-
try:
|
|
539
|
-
root = ET.fromstring(content.encode('utf-8'))
|
|
540
|
-
except ET.ParseError as e:
|
|
541
|
-
print(f"Error parsing KML file {kml_url}: {e}")
|
|
542
|
-
return []
|
|
543
|
-
|
|
544
|
-
# Define KML namespace for element searching
|
|
545
|
-
ns = {'kml': 'http://earth.google.com/kml/2.1'}
|
|
546
|
-
|
|
547
|
-
stations = []
|
|
548
|
-
|
|
549
|
-
# Find all Placemark elements (each represents a weather station)
|
|
550
|
-
for placemark in root.findall('.//kml:Placemark', ns):
|
|
551
|
-
name = placemark.find('kml:name', ns)
|
|
552
|
-
desc = placemark.find('kml:description', ns)
|
|
553
|
-
point = placemark.find('.//kml:Point/kml:coordinates', ns)
|
|
554
|
-
|
|
555
|
-
# Skip placemarks without description or that don't contain weather data
|
|
556
|
-
if desc is None or not desc.text or "Data Source" not in desc.text:
|
|
557
|
-
continue
|
|
558
|
-
|
|
559
|
-
# Get coordinates from Point element if available
|
|
560
|
-
point_coords = None
|
|
561
|
-
if point is not None and point.text:
|
|
562
|
-
point_coords = parse_coordinates(point.text)
|
|
563
|
-
|
|
564
|
-
# Parse comprehensive station data from description
|
|
565
|
-
station_data = parse_station_from_description(desc.text, point_coords)
|
|
566
|
-
if station_data:
|
|
567
|
-
# Add station name and source information
|
|
568
|
-
station_data['name'] = name.text if name is not None else "Unknown"
|
|
569
|
-
station_data['kml_source'] = kml_url
|
|
570
|
-
stations.append(station_data)
|
|
571
|
-
|
|
572
|
-
return stations
|
|
573
|
-
|
|
574
|
-
except requests.exceptions.RequestException as e:
|
|
575
|
-
print(f"Error accessing KML file {kml_url}: {e}")
|
|
576
|
-
return []
|
|
577
|
-
except Exception as e:
|
|
578
|
-
print(f"Error processing KML file {kml_url}: {e}")
|
|
579
|
-
return []
|
|
580
|
-
|
|
581
|
-
try:
|
|
582
|
-
# Create output directory if it doesn't exist
|
|
583
|
-
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
584
|
-
|
|
585
|
-
# Determine which regions to scan based on user input or auto-detection
|
|
586
|
-
regions_to_scan = {}
|
|
587
|
-
if region is None:
|
|
588
|
-
# Auto-detect regions based on coordinates
|
|
589
|
-
detected_regions = detect_regions(longitude, latitude)
|
|
590
|
-
|
|
591
|
-
if detected_regions:
|
|
592
|
-
print(f"Auto-detected regions: {', '.join(detected_regions)}")
|
|
593
|
-
for r in detected_regions:
|
|
594
|
-
regions_to_scan[r] = KML_SOURCES[r]
|
|
595
|
-
else:
|
|
596
|
-
# Fallback to all regions if detection fails
|
|
597
|
-
print("Could not determine region from coordinates. Scanning all regions.")
|
|
598
|
-
regions_to_scan = KML_SOURCES
|
|
599
|
-
elif isinstance(region, str):
|
|
600
|
-
# Handle string input for region selection
|
|
601
|
-
if region.lower() == "all":
|
|
602
|
-
regions_to_scan = KML_SOURCES
|
|
603
|
-
elif region in KML_SOURCES:
|
|
604
|
-
regions_to_scan[region] = KML_SOURCES[region]
|
|
605
|
-
else:
|
|
606
|
-
valid_regions = ", ".join(KML_SOURCES.keys())
|
|
607
|
-
raise ValueError(f"Invalid region: '{region}'. Valid regions are: {valid_regions}")
|
|
608
|
-
else:
|
|
609
|
-
# Handle list input for multiple regions
|
|
610
|
-
for r in region:
|
|
611
|
-
if r not in KML_SOURCES:
|
|
612
|
-
valid_regions = ", ".join(KML_SOURCES.keys())
|
|
613
|
-
raise ValueError(f"Invalid region: '{r}'. Valid regions are: {valid_regions}")
|
|
614
|
-
regions_to_scan[r] = KML_SOURCES[r]
|
|
615
|
-
|
|
616
|
-
# Get stations from selected KML sources
|
|
617
|
-
print("Fetching weather station data from Climate.OneBuilding.Org...")
|
|
618
|
-
all_stations = []
|
|
619
|
-
|
|
620
|
-
# Process each selected region
|
|
621
|
-
for region_name, url in regions_to_scan.items():
|
|
622
|
-
print(f"Scanning {region_name}...")
|
|
623
|
-
stations = get_stations_from_kml(url)
|
|
624
|
-
all_stations.extend(stations)
|
|
625
|
-
print(f"Found {len(stations)} stations in {region_name}")
|
|
626
|
-
|
|
627
|
-
print(f"\nTotal stations found: {len(all_stations)}")
|
|
628
|
-
|
|
629
|
-
if not all_stations:
|
|
630
|
-
raise ValueError("No weather stations found")
|
|
631
|
-
|
|
632
|
-
# Calculate distances from target coordinates to all stations
|
|
633
|
-
stations_with_distances = [
|
|
634
|
-
(station, haversine_distance(longitude, latitude, station['longitude'], station['latitude']))
|
|
635
|
-
for station in all_stations
|
|
636
|
-
]
|
|
637
|
-
|
|
638
|
-
# Filter by maximum distance if specified
|
|
639
|
-
if max_distance is not None:
|
|
640
|
-
close_stations = [
|
|
641
|
-
(station, distance)
|
|
642
|
-
for station, distance in stations_with_distances
|
|
643
|
-
if distance <= max_distance
|
|
644
|
-
]
|
|
645
|
-
if not close_stations:
|
|
646
|
-
# If no stations within max_distance, find the closest one anyway
|
|
647
|
-
closest_station, min_distance = min(stations_with_distances, key=lambda x: x[1])
|
|
648
|
-
print(f"\nNo stations found within {max_distance} km. Closest station is {min_distance:.1f} km away.")
|
|
649
|
-
print("Using closest available station.")
|
|
650
|
-
stations_with_distances = [(closest_station, min_distance)]
|
|
651
|
-
else:
|
|
652
|
-
stations_with_distances = close_stations
|
|
653
|
-
|
|
654
|
-
# Find the nearest weather station
|
|
655
|
-
nearest_station, distance = min(stations_with_distances, key=lambda x: x[1])
|
|
656
|
-
|
|
657
|
-
# Download the EPW file from the nearest station
|
|
658
|
-
print(f"\nDownloading EPW file for {nearest_station['name']}...")
|
|
659
|
-
epw_response = requests.get(nearest_station['url'])
|
|
660
|
-
epw_response.raise_for_status()
|
|
661
|
-
|
|
662
|
-
# Create a temporary directory for zip extraction
|
|
663
|
-
temp_dir = Path(output_dir) / "temp"
|
|
664
|
-
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
665
|
-
|
|
666
|
-
# Save the downloaded zip file temporarily
|
|
667
|
-
zip_file = temp_dir / "weather_data.zip"
|
|
668
|
-
with open(zip_file, 'wb') as f:
|
|
669
|
-
f.write(epw_response.content)
|
|
670
|
-
|
|
671
|
-
final_epw = None
|
|
672
|
-
try:
|
|
673
|
-
# Extract the EPW file from the zip archive
|
|
674
|
-
if extract_zip:
|
|
675
|
-
with zipfile.ZipFile(zip_file, 'r') as zip_ref:
|
|
676
|
-
# Find the EPW file in the archive (should be exactly one)
|
|
677
|
-
epw_files = [f for f in zip_ref.namelist() if f.lower().endswith('.epw')]
|
|
678
|
-
if not epw_files:
|
|
679
|
-
raise ValueError("No EPW file found in the downloaded archive")
|
|
680
|
-
|
|
681
|
-
# Extract the EPW file
|
|
682
|
-
epw_filename = epw_files[0]
|
|
683
|
-
extracted_epw = safe_extract(zip_ref, epw_filename, temp_dir)
|
|
684
|
-
|
|
685
|
-
# Move the EPW file to the final location with cleaned filename
|
|
686
|
-
final_epw = Path(output_dir) / f"{nearest_station['name'].replace(' ', '_').replace(',', '').lower()}.epw"
|
|
687
|
-
final_epw = safe_rename(extracted_epw, final_epw)
|
|
688
|
-
finally:
|
|
689
|
-
# Clean up temporary files regardless of success or failure
|
|
690
|
-
try:
|
|
691
|
-
if zip_file.exists():
|
|
692
|
-
zip_file.unlink()
|
|
693
|
-
if temp_dir.exists() and not any(temp_dir.iterdir()):
|
|
694
|
-
temp_dir.rmdir()
|
|
695
|
-
except Exception as e:
|
|
696
|
-
print(f"Warning: Could not clean up temporary files: {e}")
|
|
697
|
-
|
|
698
|
-
if final_epw is None:
|
|
699
|
-
raise ValueError("Failed to extract EPW file")
|
|
700
|
-
|
|
701
|
-
# Save station metadata alongside the EPW file
|
|
702
|
-
metadata_file = final_epw.with_suffix('.json')
|
|
703
|
-
with open(metadata_file, 'w') as f:
|
|
704
|
-
json.dump(nearest_station, f, indent=2)
|
|
705
|
-
|
|
706
|
-
# Print comprehensive station information
|
|
707
|
-
print(f"\nDownloaded EPW file for {nearest_station['name']}")
|
|
708
|
-
print(f"Distance: {distance:.2f} km")
|
|
709
|
-
print(f"Station coordinates: {nearest_station['longitude']}, {nearest_station['latitude']}")
|
|
710
|
-
if nearest_station['wmo']:
|
|
711
|
-
print(f"WMO: {nearest_station['wmo']}")
|
|
712
|
-
if nearest_station['climate_zone']:
|
|
713
|
-
print(f"Climate zone: {nearest_station['climate_zone']}")
|
|
714
|
-
if nearest_station['period']:
|
|
715
|
-
print(f"Data period: {nearest_station['period']}")
|
|
716
|
-
print(f"Files saved:")
|
|
717
|
-
print(f"- EPW: {final_epw}")
|
|
718
|
-
print(f"- Metadata: {metadata_file}")
|
|
719
|
-
|
|
720
|
-
# Load the EPW data into DataFrame if requested
|
|
721
|
-
df = None
|
|
722
|
-
headers = None
|
|
723
|
-
if load_data:
|
|
724
|
-
print("\nLoading EPW data...")
|
|
725
|
-
df, headers = process_epw(final_epw)
|
|
726
|
-
print(f"Loaded {len(df)} hourly records")
|
|
727
|
-
|
|
728
|
-
return str(final_epw), df, headers
|
|
729
|
-
|
|
730
|
-
except Exception as e:
|
|
731
|
-
print(f"Error processing data: {e}")
|
|
732
|
-
return None, None, None
|
|
733
|
-
|
|
734
|
-
# =============================================================================
|
|
735
|
-
# SOLAR SIMULATION UTILITIES
|
|
736
|
-
# =============================================================================
|
|
737
|
-
|
|
738
|
-
def read_epw_for_solar_simulation(epw_file_path):
|
|
739
|
-
"""
|
|
740
|
-
Read EPW file specifically for solar simulation purposes.
|
|
741
|
-
|
|
742
|
-
This function extracts essential solar radiation data and location metadata
|
|
743
|
-
from an EPW file for use in solar energy calculations. It focuses on the
|
|
744
|
-
Direct Normal Irradiance (DNI) and Diffuse Horizontal Irradiance (DHI)
|
|
745
|
-
which are the primary inputs for solar simulation models.
|
|
746
|
-
|
|
747
|
-
Args:
|
|
748
|
-
epw_file_path: Path to the EPW weather file
|
|
749
|
-
|
|
750
|
-
Returns:
|
|
751
|
-
Tuple containing:
|
|
752
|
-
- DataFrame with time-indexed DNI and DHI data
|
|
753
|
-
- Longitude (degrees)
|
|
754
|
-
- Latitude (degrees)
|
|
755
|
-
- Time zone offset (hours from UTC)
|
|
756
|
-
- Elevation (meters above sea level)
|
|
757
|
-
|
|
758
|
-
Raises:
|
|
759
|
-
ValueError: If LOCATION line not found or data parsing fails
|
|
760
|
-
"""
|
|
761
|
-
# Read the entire EPW file
|
|
762
|
-
with open(epw_file_path, 'r', encoding='utf-8') as f:
|
|
763
|
-
lines = f.readlines()
|
|
764
|
-
|
|
765
|
-
# Find the LOCATION line (first line in EPW format)
|
|
766
|
-
location_line = None
|
|
767
|
-
for line in lines:
|
|
768
|
-
if line.startswith("LOCATION"):
|
|
769
|
-
location_line = line.strip().split(',')
|
|
770
|
-
break
|
|
771
|
-
|
|
772
|
-
if location_line is None:
|
|
773
|
-
raise ValueError("Could not find LOCATION line in EPW file.")
|
|
774
|
-
|
|
775
|
-
# Parse LOCATION line format:
|
|
776
|
-
# LOCATION,City,State/Country,Country,DataSource,WMO,Latitude,Longitude,Time Zone,Elevation
|
|
777
|
-
# Example: LOCATION,Marina.Muni.AP,CA,USA,SRC-TMYx,690070,36.68300,-121.7670,-8.0,43.0
|
|
778
|
-
lat = float(location_line[6])
|
|
779
|
-
lon = float(location_line[7])
|
|
780
|
-
tz = float(location_line[8]) # local standard time offset from UTC
|
|
781
|
-
elevation_m = float(location_line[9])
|
|
782
|
-
|
|
783
|
-
# Find start of weather data (after 8 header lines)
|
|
784
|
-
data_start_index = None
|
|
785
|
-
for i, line in enumerate(lines):
|
|
786
|
-
vals = line.strip().split(',')
|
|
787
|
-
# Weather data lines have more than 30 columns and start after line 8
|
|
788
|
-
if i >= 8 and len(vals) > 30:
|
|
789
|
-
data_start_index = i
|
|
790
|
-
break
|
|
791
|
-
|
|
792
|
-
if data_start_index is None:
|
|
793
|
-
raise ValueError("Could not find start of weather data lines in EPW file.")
|
|
794
|
-
|
|
795
|
-
# Parse weather data focusing on solar radiation components
|
|
796
|
-
data = []
|
|
797
|
-
for l in lines[data_start_index:]:
|
|
798
|
-
vals = l.strip().split(',')
|
|
799
|
-
if len(vals) < 15: # Skip malformed lines
|
|
800
|
-
continue
|
|
801
|
-
# Extract time components and solar radiation data
|
|
802
|
-
year = int(vals[0])
|
|
803
|
-
month = int(vals[1])
|
|
804
|
-
day = int(vals[2])
|
|
805
|
-
hour = int(vals[3]) - 1 # Convert EPW 1-24 hours to 0-23
|
|
806
|
-
dni = float(vals[14]) # Direct Normal Irradiance (Wh/m²)
|
|
807
|
-
dhi = float(vals[15]) # Diffuse Horizontal Irradiance (Wh/m²)
|
|
808
|
-
|
|
809
|
-
# Create pandas timestamp for time series indexing
|
|
810
|
-
timestamp = pd.Timestamp(year, month, day, hour)
|
|
811
|
-
data.append([timestamp, dni, dhi])
|
|
812
|
-
|
|
813
|
-
# Create DataFrame with time index for efficient time series operations
|
|
814
|
-
df = pd.DataFrame(data, columns=['time', 'DNI', 'DHI']).set_index('time')
|
|
815
|
-
df = df.sort_index()
|
|
816
|
-
|
|
817
|
-
return df, lon, lat, tz, elevation_m
|