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.
Files changed (78) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/citygml.py +32 -18
  4. voxcity/downloader/gba.py +210 -0
  5. voxcity/downloader/gee.py +5 -1
  6. voxcity/downloader/mbfp.py +1 -1
  7. voxcity/downloader/oemj.py +80 -8
  8. voxcity/downloader/osm.py +23 -7
  9. voxcity/downloader/overture.py +26 -1
  10. voxcity/downloader/utils.py +73 -73
  11. voxcity/errors.py +30 -0
  12. voxcity/exporter/__init__.py +13 -4
  13. voxcity/exporter/cityles.py +633 -535
  14. voxcity/exporter/envimet.py +728 -708
  15. voxcity/exporter/magicavoxel.py +334 -297
  16. voxcity/exporter/netcdf.py +238 -0
  17. voxcity/exporter/obj.py +1481 -655
  18. voxcity/generator/__init__.py +44 -0
  19. voxcity/generator/api.py +675 -0
  20. voxcity/generator/grids.py +379 -0
  21. voxcity/generator/io.py +94 -0
  22. voxcity/generator/pipeline.py +282 -0
  23. voxcity/generator/voxelizer.py +380 -0
  24. voxcity/geoprocessor/__init__.py +75 -6
  25. voxcity/geoprocessor/conversion.py +153 -0
  26. voxcity/geoprocessor/draw.py +62 -12
  27. voxcity/geoprocessor/heights.py +199 -0
  28. voxcity/geoprocessor/io.py +101 -0
  29. voxcity/geoprocessor/merge_utils.py +91 -0
  30. voxcity/geoprocessor/mesh.py +806 -790
  31. voxcity/geoprocessor/network.py +708 -679
  32. voxcity/geoprocessor/overlap.py +84 -0
  33. voxcity/geoprocessor/raster/__init__.py +82 -0
  34. voxcity/geoprocessor/raster/buildings.py +428 -0
  35. voxcity/geoprocessor/raster/canopy.py +258 -0
  36. voxcity/geoprocessor/raster/core.py +150 -0
  37. voxcity/geoprocessor/raster/export.py +93 -0
  38. voxcity/geoprocessor/raster/landcover.py +156 -0
  39. voxcity/geoprocessor/raster/raster.py +110 -0
  40. voxcity/geoprocessor/selection.py +85 -0
  41. voxcity/geoprocessor/utils.py +18 -14
  42. voxcity/models.py +113 -0
  43. voxcity/simulator/common/__init__.py +22 -0
  44. voxcity/simulator/common/geometry.py +98 -0
  45. voxcity/simulator/common/raytracing.py +450 -0
  46. voxcity/simulator/solar/__init__.py +43 -0
  47. voxcity/simulator/solar/integration.py +336 -0
  48. voxcity/simulator/solar/kernels.py +62 -0
  49. voxcity/simulator/solar/radiation.py +648 -0
  50. voxcity/simulator/solar/temporal.py +434 -0
  51. voxcity/simulator/view.py +36 -2286
  52. voxcity/simulator/visibility/__init__.py +29 -0
  53. voxcity/simulator/visibility/landmark.py +392 -0
  54. voxcity/simulator/visibility/view.py +508 -0
  55. voxcity/utils/logging.py +61 -0
  56. voxcity/utils/orientation.py +51 -0
  57. voxcity/utils/weather/__init__.py +26 -0
  58. voxcity/utils/weather/epw.py +146 -0
  59. voxcity/utils/weather/files.py +36 -0
  60. voxcity/utils/weather/onebuilding.py +486 -0
  61. voxcity/visualizer/__init__.py +24 -0
  62. voxcity/visualizer/builder.py +43 -0
  63. voxcity/visualizer/grids.py +141 -0
  64. voxcity/visualizer/maps.py +187 -0
  65. voxcity/visualizer/palette.py +228 -0
  66. voxcity/visualizer/renderer.py +928 -0
  67. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
  68. voxcity-0.7.0.dist-info/RECORD +77 -0
  69. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
  70. voxcity/generator.py +0 -1137
  71. voxcity/geoprocessor/grid.py +0 -1568
  72. voxcity/geoprocessor/polygon.py +0 -1344
  73. voxcity/simulator/solar.py +0 -2329
  74. voxcity/utils/visualization.py +0 -2660
  75. voxcity/utils/weather.py +0 -817
  76. voxcity-0.6.15.dist-info/RECORD +0 -37
  77. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
  78. {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('&ntilde;', 'n')
384
- content = content.replace('&Ntilde;', '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+)&deg;\s*(\d+\.\d+)\'.*?([EW]) (\d+)&deg;\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