voxcity 0.6.18__py3-none-any.whl → 0.6.20__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.

Potentially problematic release.


This version of voxcity might be problematic. Click here for more details.

voxcity/generator.py CHANGED
@@ -921,8 +921,22 @@ def get_voxcity_CityGML(rectangle_vertices, land_cover_source, canopy_height_sou
921
921
  # Remove 'output_dir' from kwargs to prevent duplication
922
922
  kwargs.pop('output_dir', None)
923
923
 
924
+ # SSL/HTTP options for CityGML download (optional)
925
+ # Backward compatible: accept 'verify' but prefer 'ssl_verify'
926
+ ssl_verify = kwargs.pop('ssl_verify', kwargs.pop('verify', True))
927
+ ca_bundle = kwargs.pop('ca_bundle', None)
928
+ timeout = kwargs.pop('timeout', 60)
929
+
924
930
  # get all required gdfs
925
- building_gdf, terrain_gdf, vegetation_gdf = load_buid_dem_veg_from_citygml(url=url_citygml, citygml_path=citygml_path, base_dir=output_dir, rectangle_vertices=rectangle_vertices)
931
+ building_gdf, terrain_gdf, vegetation_gdf = load_buid_dem_veg_from_citygml(
932
+ url=url_citygml,
933
+ citygml_path=citygml_path,
934
+ base_dir=output_dir,
935
+ rectangle_vertices=rectangle_vertices,
936
+ ssl_verify=ssl_verify,
937
+ ca_bundle=ca_bundle,
938
+ timeout=timeout
939
+ )
926
940
 
927
941
  # Normalize CRS to WGS84 (EPSG:4326) to ensure consistent operations downstream
928
942
  try:
@@ -1007,10 +1007,15 @@ def get_global_solar_irradiance_using_epw(
1007
1007
  output_dir=output_dir,
1008
1008
  max_distance=max_distance,
1009
1009
  extract_zip=True,
1010
- load_data=True
1010
+ load_data=True,
1011
+ allow_insecure_ssl=kwargs.get("allow_insecure_ssl", False),
1012
+ allow_http_fallback=kwargs.get("allow_http_fallback", False),
1013
+ ssl_verify=kwargs.get("ssl_verify", True)
1011
1014
  )
1012
1015
 
1013
1016
  # Read EPW data
1017
+ if epw_file_path is None:
1018
+ raise RuntimeError("EPW file path is None. Set 'epw_file_path' or enable 'download_nearest_epw' and ensure network succeeds.")
1014
1019
  df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
1015
1020
  if df.empty:
1016
1021
  raise ValueError("No data in EPW file.")
@@ -2030,10 +2035,15 @@ def get_building_global_solar_irradiance_using_epw(
2030
2035
  output_dir=output_dir,
2031
2036
  max_distance=max_distance,
2032
2037
  extract_zip=True,
2033
- load_data=True
2038
+ load_data=True,
2039
+ allow_insecure_ssl=kwargs.get("allow_insecure_ssl", False),
2040
+ allow_http_fallback=kwargs.get("allow_http_fallback", False),
2041
+ ssl_verify=kwargs.get("ssl_verify", True)
2034
2042
  )
2035
2043
 
2036
2044
  # Read EPW data
2045
+ if epw_file_path is None:
2046
+ raise RuntimeError("EPW file path is None. Set 'epw_file_path' or enable 'download_nearest_epw' and ensure network succeeds.")
2037
2047
  df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
2038
2048
  if df.empty:
2039
2049
  raise ValueError("No data in EPW file.")
voxcity/utils/weather.py CHANGED
@@ -209,7 +209,9 @@ def process_epw(epw_path: Union[str, Path]) -> Tuple[pd.DataFrame, Dict]:
209
209
  # =============================================================================
210
210
 
211
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]]:
212
+ extract_zip: bool = True, load_data: bool = True, region: Optional[Union[str, List[str]]] = None,
213
+ allow_insecure_ssl: bool = False, allow_http_fallback: bool = False,
214
+ ssl_verify: Union[bool, str] = True) -> Tuple[Optional[str], Optional[pd.DataFrame], Optional[Dict]]:
213
215
  """
214
216
  Download and process EPW weather file from Climate.OneBuilding.Org based on coordinates.
215
217
 
@@ -233,11 +235,19 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
233
235
  If no stations within this distance, uses closest available.
234
236
  extract_zip (bool): Whether to extract the ZIP file (default True)
235
237
  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".
238
+ region (str or List[str], optional): Specific region(s) or dataset(s) to scan for stations.
239
+ Regions: "Africa", "Asia", "South_America",
240
+ "North_and_Central_America", "Southwest_Pacific",
241
+ "Europe", "Antarctica".
242
+ Sub-datasets (can be used alone or auto-included by region):
243
+ "Japan", "India", "CSWD", "CityUHK", "PHIKO",
244
+ "Argentina", "INMET_TRY", "AMTUes", "BrazFuture",
245
+ plus legacy "Canada", "USA", "Caribbean" (Region 4).
246
+ Use "all" to scan every dataset.
240
247
  If None, will auto-detect region based on coordinates.
248
+ allow_insecure_ssl (bool): If True, on SSL errors retry with certificate verification disabled.
249
+ allow_http_fallback (bool): If True, on SSL/network errors, also try HTTP (insecure) fallback.
250
+ ssl_verify (bool|str): Passed to requests as 'verify' parameter for HTTPS; can be False or CA bundle path.
241
251
 
242
252
  Returns:
243
253
  Tuple containing:
@@ -250,35 +260,83 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
250
260
  requests.exceptions.RequestException: If network requests fail
251
261
  """
252
262
 
253
- # Regional KML sources from Climate.OneBuilding.Org
254
- # Each region maintains its own KML file with weather station locations and metadata
263
+ # Regional KML sources from Climate.OneBuilding.Org (2024+ TMYx structure)
264
+ # Each WMO region maintains a primary KML in /sources with the naming pattern:
265
+ # Region{N}_{Name}_TMYx_EPW_Processing_locations.kml
266
+ # Keep sub-region keys for backward compatibility (mapping to the Region KML where applicable)
255
267
  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",
268
+ # WMO Region 1
269
+ "Africa": "https://climate.onebuilding.org/sources/Region1_Africa_TMYx_EPW_Processing_locations.kml",
270
+ # WMO Region 2
271
+ "Asia": "https://climate.onebuilding.org/sources/Region2_Asia_TMYx_EPW_Processing_locations.kml",
272
+ # Subsets/datasets within Asia that still publish dedicated KMLs
258
273
  "Japan": "https://climate.onebuilding.org/sources/JGMY_EPW_Processing_locations.kml",
259
274
  "India": "https://climate.onebuilding.org/sources/ITMY_EPW_Processing_locations.kml",
275
+ "CSWD": "https://climate.onebuilding.org/sources/CSWD_EPW_Processing_locations.kml",
276
+ "CityUHK": "https://climate.onebuilding.org/sources/CityUHK_EPW_Processing_locations.kml",
277
+ "PHIKO": "https://climate.onebuilding.org/sources/PHIKO_EPW_Processing_locations.kml",
278
+ # WMO Region 3
279
+ "South_America": "https://climate.onebuilding.org/sources/Region3_South_America_TMYx_EPW_Processing_locations.kml",
280
+ # Historical/legacy dataset for Argentina maintained separately
260
281
  "Argentina": "https://climate.onebuilding.org/sources/ArgTMY_EPW_Processing_locations.kml",
282
+ "INMET_TRY": "https://climate.onebuilding.org/sources/INMET_TRY_EPW_Processing_locations.kml",
283
+ "AMTUes": "https://climate.onebuilding.org/sources/AMTUes_EPW_Processing_locations.kml",
284
+ "BrazFuture": "https://climate.onebuilding.org/sources/BrazFuture_EPW_Processing_locations.kml",
285
+ # WMO Region 4 (use subregion KMLs; umbrella selection expands to these)
286
+ # Note: There is no single unified Region 4 KML in /sources as of 2024.
287
+ # Use these three subregion KMLs instead.
261
288
  "Canada": "https://climate.onebuilding.org/sources/Region4_Canada_TMYx_EPW_Processing_locations.kml",
262
289
  "USA": "https://climate.onebuilding.org/sources/Region4_USA_TMYx_EPW_Processing_locations.kml",
263
290
  "Caribbean": "https://climate.onebuilding.org/sources/Region4_NA_CA_Caribbean_TMYx_EPW_Processing_locations.kml",
291
+ # WMO Region 5
264
292
  "Southwest_Pacific": "https://climate.onebuilding.org/sources/Region5_Southwest_Pacific_TMYx_EPW_Processing_locations.kml",
293
+ # WMO Region 6
265
294
  "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"
295
+ # WMO Region 7
296
+ "Antarctica": "https://climate.onebuilding.org/sources/Region7_Antarctica_TMYx_EPW_Processing_locations.kml",
297
+ }
298
+
299
+ # Group region selections to include relevant sub-datasets automatically
300
+ REGION_DATASET_GROUPS = {
301
+ "Africa": ["Africa"],
302
+ "Asia": ["Asia", "Japan", "India", "CSWD", "CityUHK", "PHIKO"],
303
+ "South_America": ["South_America", "Argentina", "INMET_TRY", "AMTUes", "BrazFuture"],
304
+ "North_and_Central_America": ["North_and_Central_America", "Canada", "USA", "Caribbean"],
305
+ "Southwest_Pacific": ["Southwest_Pacific"],
306
+ "Europe": ["Europe"],
307
+ "Antarctica": ["Antarctica"],
267
308
  }
268
309
 
269
310
  # Define approximate geographical boundaries for automatic region detection
270
311
  # These bounds help determine which regional KML files to scan based on coordinates
271
312
  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},
313
+ # WMO Region 1 - Africa (includes islands in Indian Ocean and Spanish territories off N. Africa)
314
+ "Africa": {"lon_min": -25, "lon_max": 80, "lat_min": -55, "lat_max": 45},
315
+ # WMO Region 2 - Asia (includes SE Asia, West Asia, Asian Russia, and BIOT)
316
+ "Asia": {"lon_min": 20, "lon_max": 180, "lat_min": -10, "lat_max": 80},
317
+ # Subsets
274
318
  "Japan": {"lon_min": 127, "lon_max": 146, "lat_min": 24, "lat_max": 46},
275
319
  "India": {"lon_min": 68, "lon_max": 97, "lat_min": 6, "lat_max": 36},
320
+ # WMO Region 3 - South America (includes Falklands, South Georgia/Sandwich, Galapagos)
321
+ "South_America": {"lon_min": -92, "lon_max": -20, "lat_min": -60, "lat_max": 15},
322
+ # Legacy/compatibility subset
276
323
  "Argentina": {"lon_min": -75, "lon_max": -53, "lat_min": -55, "lat_max": -22},
324
+ # WMO Region 4 - North and Central America (includes Greenland and Caribbean)
325
+ "North_and_Central_America": {"lon_min": -180, "lon_max": 20, "lat_min": -10, "lat_max": 85},
326
+ # Backward-compatible subsets mapped to Region 4 KML
277
327
  "Canada": {"lon_min": -141, "lon_max": -52, "lat_min": 42, "lat_max": 83},
278
328
  "USA": {"lon_min": -170, "lon_max": -65, "lat_min": 20, "lat_max": 72},
279
329
  "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},
330
+ # WMO Region 5 - Southwest Pacific (covers SE Asia + Pacific islands + Hawaii via antimeridian)
331
+ "Southwest_Pacific": {
332
+ "boxes": [
333
+ {"lon_min": 90, "lon_max": 180, "lat_min": -50, "lat_max": 25},
334
+ {"lon_min": -180, "lon_max": -140, "lat_min": -50, "lat_max": 25},
335
+ ]
336
+ },
337
+ # WMO Region 6 - Europe (includes Middle East countries listed and Greenland)
338
+ "Europe": {"lon_min": -75, "lon_max": 60, "lat_min": 25, "lat_max": 85},
339
+ # WMO Region 7 - Antarctica
282
340
  "Antarctica": {"lon_min": -180, "lon_max": 180, "lat_min": -90, "lat_max": -60}
283
341
  }
284
342
 
@@ -306,34 +364,45 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
306
364
  elif lon > 180:
307
365
  lon_adjusted = lon - 360
308
366
 
309
- # Check if coordinates fall within any region bounds
367
+ # Helper to test point within a single box
368
+ def _in_box(bx: Dict[str, float], lon_v: float, lat_v: float) -> bool:
369
+ return (bx["lon_min"] <= lon_v <= bx["lon_max"] and
370
+ bx["lat_min"] <= lat_v <= bx["lat_max"])
371
+
372
+ # Check if coordinates fall within any region bounds (support multi-box)
310
373
  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)
374
+ if "boxes" in bounds:
375
+ for bx in bounds["boxes"]:
376
+ if _in_box(bx, lon_adjusted, lat):
377
+ matching_regions.append(region_name)
378
+ break
379
+ else:
380
+ if _in_box(bounds, lon_adjusted, lat):
381
+ matching_regions.append(region_name)
315
382
 
316
383
  # If no regions matched, find the closest regions by boundary distance
317
384
  if not matching_regions:
318
385
  # Calculate "distance" to each region's boundary (simplified metric)
319
386
  region_distances = []
320
387
  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))
388
+ def _box_distance(bx: Dict[str, float]) -> float:
389
+ lon_dist = 0
390
+ if lon_adjusted < bx["lon_min"]:
391
+ lon_dist = bx["lon_min"] - lon_adjusted
392
+ elif lon_adjusted > bx["lon_max"]:
393
+ lon_dist = lon_adjusted - bx["lon_max"]
394
+ lat_dist = 0
395
+ if lat < bx["lat_min"]:
396
+ lat_dist = bx["lat_min"] - lat
397
+ elif lat > bx["lat_max"]:
398
+ lat_dist = lat - bx["lat_max"]
399
+ return (lon_dist**2 + lat_dist**2)**0.5
400
+
401
+ if "boxes" in bounds:
402
+ d = min(_box_distance(bx) for bx in bounds["boxes"])
403
+ else:
404
+ d = _box_distance(bounds)
405
+ region_distances.append((region_name, d))
337
406
 
338
407
  # Get 3 closest regions to ensure we find stations
339
408
  closest_regions = sorted(region_distances, key=lambda x: x[1])[:3]
@@ -511,6 +580,101 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
511
580
 
512
581
  return metadata
513
582
 
583
+ def try_download_station_zip(original_url: str, timeout_s: int = 30) -> Optional[bytes]:
584
+ """
585
+ Try downloading station archive; on 404s, attempt smart fallbacks.
586
+
587
+ Fallback strategies:
588
+ - Country rename: /TUR_Turkey/ -> /TUR_Turkiye/ (per Oct 2024 site update)
589
+ - TMYx period variants: .2009-2023.zip, .2007-2021.zip, .zip, .2004-2018.zip
590
+
591
+ Args:
592
+ original_url: URL extracted from KML
593
+ timeout_s: request timeout seconds
594
+ Returns:
595
+ Bytes of the downloaded zip on success, otherwise None
596
+ """
597
+ def candidate_urls(url: str) -> List[str]:
598
+ urls = []
599
+ urls.append(url)
600
+ # Country rename variants
601
+ if "/TUR_Turkey/" in url:
602
+ urls.append(url.replace("/TUR_Turkey/", "/TUR_Turkiye/"))
603
+ if "/TUR_Turkiye/" in url:
604
+ urls.append(url.replace("/TUR_Turkiye/", "/TUR_Turkey/"))
605
+ # TMYx period variants
606
+ m = re.search(r"(.*_TMYx)(?:\.(\d{4}-\d{4}))?\.zip$", url)
607
+ if m:
608
+ base = m.group(1)
609
+ suffix = m.group(2)
610
+ variants = [
611
+ f"{base}.2009-2023.zip",
612
+ f"{base}.2007-2021.zip",
613
+ f"{base}.zip",
614
+ f"{base}.2004-2018.zip",
615
+ ]
616
+ for v in variants:
617
+ if v not in urls:
618
+ urls.append(v)
619
+ # Also apply country rename to each variant
620
+ extra = []
621
+ for v in variants:
622
+ if "/TUR_Turkey/" in url:
623
+ extra.append(v.replace("/TUR_Turkey/", "/TUR_Turkiye/"))
624
+ if "/TUR_Turkiye/" in url:
625
+ extra.append(v.replace("/TUR_Turkiye/", "/TUR_Turkey/"))
626
+ for v in extra:
627
+ if v not in urls:
628
+ urls.append(v)
629
+ return urls
630
+
631
+ tried = set()
632
+ for u in candidate_urls(original_url):
633
+ if u in tried:
634
+ continue
635
+ tried.add(u)
636
+ try:
637
+ resp = requests.get(u, timeout=timeout_s, verify=ssl_verify)
638
+ resp.raise_for_status()
639
+ return resp.content
640
+ except requests.exceptions.SSLError:
641
+ # Retry with user-controlled insecure SSL
642
+ if allow_insecure_ssl:
643
+ try:
644
+ resp = requests.get(u, timeout=timeout_s, verify=False)
645
+ resp.raise_for_status()
646
+ return resp.content
647
+ except requests.exceptions.RequestException:
648
+ if allow_http_fallback and u.lower().startswith("https://"):
649
+ insecure_url = "http://" + u.split("://", 1)[1]
650
+ try:
651
+ resp = requests.get(insecure_url, timeout=timeout_s)
652
+ resp.raise_for_status()
653
+ return resp.content
654
+ except requests.exceptions.RequestException:
655
+ pass
656
+ continue
657
+ else:
658
+ if allow_http_fallback and u.lower().startswith("https://"):
659
+ insecure_url = "http://" + u.split("://", 1)[1]
660
+ try:
661
+ resp = requests.get(insecure_url, timeout=timeout_s)
662
+ resp.raise_for_status()
663
+ return resp.content
664
+ except requests.exceptions.RequestException:
665
+ pass
666
+ continue
667
+ except requests.exceptions.HTTPError as he:
668
+ # Only continue on 404; raise on other HTTP errors
669
+ if getattr(he.response, "status_code", None) == 404:
670
+ continue
671
+ else:
672
+ raise
673
+ except requests.exceptions.RequestException:
674
+ # On network errors, try next candidate
675
+ continue
676
+ return None
677
+
514
678
  def get_stations_from_kml(kml_url: str) -> List[Dict]:
515
679
  """
516
680
  Get weather stations from a KML file.
@@ -526,9 +690,32 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
526
690
  List of dictionaries containing station metadata
527
691
  """
528
692
  try:
529
- # Download KML file with timeout
530
- response = requests.get(kml_url, timeout=30)
531
- response.raise_for_status()
693
+ # Download KML file with timeout (secure first)
694
+ try:
695
+ response = requests.get(kml_url, timeout=30, verify=ssl_verify)
696
+ response.raise_for_status()
697
+ except requests.exceptions.SSLError:
698
+ if allow_insecure_ssl:
699
+ # Retry with certificate verification disabled (last resort)
700
+ try:
701
+ response = requests.get(kml_url, timeout=30, verify=False)
702
+ response.raise_for_status()
703
+ except requests.exceptions.RequestException:
704
+ # Try HTTP fallback if original was HTTPS and allowed
705
+ if allow_http_fallback and kml_url.lower().startswith("https://"):
706
+ insecure_url = "http://" + kml_url.split("://", 1)[1]
707
+ response = requests.get(insecure_url, timeout=30)
708
+ response.raise_for_status()
709
+ else:
710
+ raise
711
+ else:
712
+ # Try HTTP fallback only if allowed and original was HTTPS
713
+ if allow_http_fallback and kml_url.lower().startswith("https://"):
714
+ insecure_url = "http://" + kml_url.split("://", 1)[1]
715
+ response = requests.get(insecure_url, timeout=30)
716
+ response.raise_for_status()
717
+ else:
718
+ raise
532
719
 
533
720
  # Try to decode content with multiple encodings
534
721
  content = try_decode(response.content)
@@ -584,6 +771,18 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
584
771
 
585
772
  # Determine which regions to scan based on user input or auto-detection
586
773
  regions_to_scan = {}
774
+ def _add_selection(selection_name: str, mapping: Dict[str, str], out: Dict[str, str]):
775
+ """Expand a region or dataset selection into concrete KML URLs."""
776
+ if selection_name in REGION_DATASET_GROUPS:
777
+ for key in REGION_DATASET_GROUPS[selection_name]:
778
+ if key in KML_SOURCES:
779
+ out[key] = KML_SOURCES[key]
780
+ elif selection_name in KML_SOURCES:
781
+ out[selection_name] = KML_SOURCES[selection_name]
782
+ else:
783
+ valid = sorted(list(REGION_DATASET_GROUPS.keys()) + list(KML_SOURCES.keys()))
784
+ raise ValueError(f"Invalid region/dataset: '{selection_name}'. Valid options include: {', '.join(valid)}")
785
+
587
786
  if region is None:
588
787
  # Auto-detect regions based on coordinates
589
788
  detected_regions = detect_regions(longitude, latitude)
@@ -591,34 +790,32 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
591
790
  if detected_regions:
592
791
  print(f"Auto-detected regions: {', '.join(detected_regions)}")
593
792
  for r in detected_regions:
594
- regions_to_scan[r] = KML_SOURCES[r]
793
+ _add_selection(r, KML_SOURCES, regions_to_scan)
595
794
  else:
596
795
  # Fallback to all regions if detection fails
597
796
  print("Could not determine region from coordinates. Scanning all regions.")
598
- regions_to_scan = KML_SOURCES
797
+ regions_to_scan = dict(KML_SOURCES)
599
798
  elif isinstance(region, str):
600
799
  # Handle string input for region selection
601
800
  if region.lower() == "all":
602
- regions_to_scan = KML_SOURCES
603
- elif region in KML_SOURCES:
604
- regions_to_scan[region] = KML_SOURCES[region]
801
+ regions_to_scan = dict(KML_SOURCES)
605
802
  else:
606
- valid_regions = ", ".join(KML_SOURCES.keys())
607
- raise ValueError(f"Invalid region: '{region}'. Valid regions are: {valid_regions}")
803
+ _add_selection(region, KML_SOURCES, regions_to_scan)
608
804
  else:
609
805
  # Handle list input for multiple regions
610
806
  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]
807
+ _add_selection(r, KML_SOURCES, regions_to_scan)
615
808
 
616
809
  # Get stations from selected KML sources
617
810
  print("Fetching weather station data from Climate.OneBuilding.Org...")
618
811
  all_stations = []
619
812
 
620
813
  # Process each selected region
814
+ scanned_urls = set()
621
815
  for region_name, url in regions_to_scan.items():
816
+ if url in scanned_urls:
817
+ continue
818
+ scanned_urls.add(url)
622
819
  print(f"Scanning {region_name}...")
623
820
  stations = get_stations_from_kml(url)
624
821
  all_stations.extend(stations)
@@ -627,7 +824,23 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
627
824
  print(f"\nTotal stations found: {len(all_stations)}")
628
825
 
629
826
  if not all_stations:
630
- raise ValueError("No weather stations found")
827
+ # Fallback: if no stations found, try scanning all available datasets
828
+ if not (isinstance(region, str) and region.lower() == "all"):
829
+ print("No stations found from detected/selected regions. Falling back to global scan...")
830
+ regions_to_scan = dict(KML_SOURCES)
831
+ all_stations = []
832
+ scanned_urls = set()
833
+ for region_name, url in regions_to_scan.items():
834
+ if url in scanned_urls:
835
+ continue
836
+ scanned_urls.add(url)
837
+ print(f"Scanning {region_name}...")
838
+ stations = get_stations_from_kml(url)
839
+ all_stations.extend(stations)
840
+ print(f"Found {len(stations)} stations in {region_name}")
841
+ print(f"\nTotal stations found after global scan: {len(all_stations)}")
842
+ if not all_stations:
843
+ raise ValueError("No weather stations found")
631
844
 
632
845
  # Calculate distances from target coordinates to all stations
633
846
  stations_with_distances = [
@@ -654,10 +867,11 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
654
867
  # Find the nearest weather station
655
868
  nearest_station, distance = min(stations_with_distances, key=lambda x: x[1])
656
869
 
657
- # Download the EPW file from the nearest station
870
+ # Download the EPW archive from the nearest station with fallbacks
658
871
  print(f"\nDownloading EPW file for {nearest_station['name']}...")
659
- epw_response = requests.get(nearest_station['url'])
660
- epw_response.raise_for_status()
872
+ archive_bytes = try_download_station_zip(nearest_station['url'], timeout_s=30)
873
+ if archive_bytes is None:
874
+ raise ValueError(f"Failed to download EPW archive from station URL and fallbacks: {nearest_station['url']}")
661
875
 
662
876
  # Create a temporary directory for zip extraction
663
877
  temp_dir = Path(output_dir) / "temp"
@@ -666,7 +880,7 @@ def get_nearest_epw_from_climate_onebuilding(longitude: float, latitude: float,
666
880
  # Save the downloaded zip file temporarily
667
881
  zip_file = temp_dir / "weather_data.zip"
668
882
  with open(zip_file, 'wb') as f:
669
- f.write(epw_response.content)
883
+ f.write(archive_bytes)
670
884
 
671
885
  final_epw = None
672
886
  try:
@@ -758,8 +972,15 @@ def read_epw_for_solar_simulation(epw_file_path):
758
972
  Raises:
759
973
  ValueError: If LOCATION line not found or data parsing fails
760
974
  """
975
+ # Validate input path
976
+ if epw_file_path is None:
977
+ raise TypeError("EPW file path is None. Provide a valid path or ensure download succeeded.")
978
+ epw_path_obj = Path(epw_file_path)
979
+ if not epw_path_obj.exists() or not epw_path_obj.is_file():
980
+ raise FileNotFoundError(f"EPW file not found: {epw_file_path}")
981
+
761
982
  # Read the entire EPW file
762
- with open(epw_file_path, 'r', encoding='utf-8') as f:
983
+ with open(epw_path_obj, 'r', encoding='utf-8') as f:
763
984
  lines = f.readlines()
764
985
 
765
986
  # Find the LOCATION line (first line in EPW format)
@@ -1,8 +1,10 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: voxcity
3
- Version: 0.6.18
3
+ Version: 0.6.20
4
4
  Summary: voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data
5
5
  License: MIT
6
+ License-File: AUTHORS.rst
7
+ License-File: LICENSE
6
8
  Author: Kunihiko Fujiwara
7
9
  Author-email: fujiwara.kunihiko@takenaka.co.jp
8
10
  Maintainer: Kunihiko Fujiwara
@@ -1,6 +1,6 @@
1
1
  voxcity/__init__.py,sha256=el9v3gfybHOF_GUYPeSOqN0-vCrTW0eU1mcvi0sEfeU,252
2
2
  voxcity/downloader/__init__.py,sha256=o_T_EU7hZLGyXxX9wVWn1x-OAa3ThGYdnpgB1_2v3AE,151
3
- voxcity/downloader/citygml.py,sha256=vEsMDCjK64UwpCmQi6ELoqC7h5sbIUW8hWAxVf5ViNI,41321
3
+ voxcity/downloader/citygml.py,sha256=I8-wWijqVOA1VeH3nFP9ZlC3l6XvXfli6lB17ZIXHb0,42232
4
4
  voxcity/downloader/eubucco.py,sha256=ln1YNaaOgJfxNfCtVbYaMm775-bUvpAA_LDv60_i22w,17875
5
5
  voxcity/downloader/gee.py,sha256=nvJvYqcSZyyontRtG2cFeb__ZJfeY4rRN1NBPORxLwQ,23557
6
6
  voxcity/downloader/mbfp.py,sha256=UXDVjsO0fnb0fSal9yqrSFEIBThnRmnutnp08kZTmCA,6595
@@ -13,8 +13,8 @@ voxcity/exporter/cityles.py,sha256=Kfl2PAn4WquqCdjOlyPrHysxPLaudh8QfsoC6WAXlvs,1
13
13
  voxcity/exporter/envimet.py,sha256=Sh7s1JdQ6SgT_L2Xd_c4gtEGWK2hTS87bccaoIqik-s,31105
14
14
  voxcity/exporter/magicavoxel.py,sha256=SfGEgTZRlossKx3Xrv9d3iKSX-HmfQJEL9lZHgWMDX4,12782
15
15
  voxcity/exporter/netcdf.py,sha256=48rJ3wDsFhi9ANbElhMjXLxWMJoJzBt1gFbN0ekPp-A,7404
16
- voxcity/exporter/obj.py,sha256=2MA2GpexMC1_GnMv0u5GVNGO1N9DH8TAtYVcFoWvkbg,50386
17
- voxcity/generator.py,sha256=4WSjO09f8z3zt0tpKY0fyAfOMym1JT4VH3Tt1lOg2Bk,66900
16
+ voxcity/exporter/obj.py,sha256=7xmVSQ_y-X8QLjNdASDPsaltGvmyW9-yAacocByYKl4,58160
17
+ voxcity/generator.py,sha256=OHSZU4z7Vj6Iqbp1J0xXlFUSPyMsvOgPv3Tren8mEBc,67302
18
18
  voxcity/geoprocessor/__init__.py,sha256=WYxcAQrjGucIvGHF0JTC6rONZzL3kCms1S2vpzS6KaU,127
19
19
  voxcity/geoprocessor/draw.py,sha256=AZMWq23wxxDJygNloCbVzWAAr1JO7nC94umf9LSxJ5o,49248
20
20
  voxcity/geoprocessor/grid.py,sha256=NmlQwl1nJpS7MduVtJeJCG-xBfVIwKTOip7pMm3RhsY,76722
@@ -23,16 +23,16 @@ voxcity/geoprocessor/network.py,sha256=YynqR0nq_NUra_cQ3Z_56KxfRia1b6-hIzGCj3QT-
23
23
  voxcity/geoprocessor/polygon.py,sha256=DfzXf6R-qoWXEZv1z1aHCVfr-DCuCFw6lieQT5cNHPA,61188
24
24
  voxcity/geoprocessor/utils.py,sha256=s17XpgkLBelmNCk2wcUwTK1tEiFpguWR2BF_n7K17jg,31378
25
25
  voxcity/simulator/__init__.py,sha256=APdkcdaovj0v_RPOaA4SBvFUKT2RM7Hxuuz3Sux4gCo,65
26
- voxcity/simulator/solar.py,sha256=4WBFgMm25-9rZ5bSGBmIpaxq2mma9X46Fom7UvGEnT8,106361
26
+ voxcity/simulator/solar.py,sha256=4D5t2I79vBW1qXd90BZR0tMiA9WOEXWLG5b-d6E2XbQ,107127
27
27
  voxcity/simulator/utils.py,sha256=sEYBB2-hLJxTiXQps1_-Fi7t1HN3-1OPOvBCWtgIisA,130
28
28
  voxcity/simulator/view.py,sha256=k3FoS6gsibR-eDrTHJivJSQfvN3Tg8R8eSTeMqd9ans,93942
29
29
  voxcity/utils/__init__.py,sha256=Q-NYCqYnAAaF80KuNwpqIjbE7Ec3Gr4y_khMLIMhJrg,68
30
30
  voxcity/utils/lc.py,sha256=722Gz3lPbgAp0mmTZ-g-QKBbAnbxrcgaYwb1sa7q8Sk,16189
31
31
  voxcity/utils/material.py,sha256=H8K8Lq4wBL6dQtgj7esUW2U6wLCOTeOtelkTDJoRgMo,10007
32
32
  voxcity/utils/visualization.py,sha256=_c5WnhA0fawkseUviKEIpUUsF-M-OOLOT9FrpgQIh1A,118556
33
- voxcity/utils/weather.py,sha256=2Jtg-rIVJcsTtiKE-KuDnhIqS1-MSS16_zFRzj6zmu4,36435
34
- voxcity-0.6.18.dist-info/AUTHORS.rst,sha256=m82vkI5QokEGdcHof2OxK39lf81w1P58kG9ZNNAKS9U,175
35
- voxcity-0.6.18.dist-info/LICENSE,sha256=s_jE1Df1nTPL4A_5GCGic5Zwex0CVaPKcAmSilxJPPE,1089
36
- voxcity-0.6.18.dist-info/METADATA,sha256=2KzFc-aRuap89tiZhqAotBF2ACcK86VCJuwg2tUvKbk,26164
37
- voxcity-0.6.18.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
38
- voxcity-0.6.18.dist-info/RECORD,,
33
+ voxcity/utils/weather.py,sha256=cb6ZooL42Hc4214OtFiJ78cCgWYM6VE-DU8S3e-urRg,48449
34
+ voxcity-0.6.20.dist-info/licenses/AUTHORS.rst,sha256=m82vkI5QokEGdcHof2OxK39lf81w1P58kG9ZNNAKS9U,175
35
+ voxcity-0.6.20.dist-info/licenses/LICENSE,sha256=s_jE1Df1nTPL4A_5GCGic5Zwex0CVaPKcAmSilxJPPE,1089
36
+ voxcity-0.6.20.dist-info/METADATA,sha256=dRyklxMxGHwtTvE6lT3TfMFIlWBDZ354zt1Dr_Vv7qQ,26212
37
+ voxcity-0.6.20.dist-info/WHEEL,sha256=M5asmiAlL6HEcOq52Yi5mmk9KmTVjY2RDPtO4p9DMrc,88
38
+ voxcity-0.6.20.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.3
2
+ Generator: poetry-core 2.2.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any