voxcity 0.6.26__py3-none-any.whl → 1.0.2__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 (81) hide show
  1. voxcity/__init__.py +10 -4
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/gba.py +210 -0
  4. voxcity/downloader/gee.py +5 -1
  5. voxcity/downloader/mbfp.py +1 -1
  6. voxcity/downloader/oemj.py +80 -8
  7. voxcity/downloader/utils.py +73 -73
  8. voxcity/errors.py +30 -0
  9. voxcity/exporter/__init__.py +9 -1
  10. voxcity/exporter/cityles.py +129 -34
  11. voxcity/exporter/envimet.py +51 -26
  12. voxcity/exporter/magicavoxel.py +42 -5
  13. voxcity/exporter/netcdf.py +27 -0
  14. voxcity/exporter/obj.py +103 -28
  15. voxcity/generator/__init__.py +47 -0
  16. voxcity/generator/api.py +721 -0
  17. voxcity/generator/grids.py +381 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/update.py +429 -0
  21. voxcity/generator/voxelizer.py +392 -0
  22. voxcity/geoprocessor/__init__.py +75 -6
  23. voxcity/geoprocessor/conversion.py +153 -0
  24. voxcity/geoprocessor/draw.py +1488 -1169
  25. voxcity/geoprocessor/heights.py +199 -0
  26. voxcity/geoprocessor/io.py +101 -0
  27. voxcity/geoprocessor/merge_utils.py +91 -0
  28. voxcity/geoprocessor/mesh.py +26 -10
  29. voxcity/geoprocessor/network.py +35 -6
  30. voxcity/geoprocessor/overlap.py +84 -0
  31. voxcity/geoprocessor/raster/__init__.py +82 -0
  32. voxcity/geoprocessor/raster/buildings.py +435 -0
  33. voxcity/geoprocessor/raster/canopy.py +258 -0
  34. voxcity/geoprocessor/raster/core.py +150 -0
  35. voxcity/geoprocessor/raster/export.py +93 -0
  36. voxcity/geoprocessor/raster/landcover.py +159 -0
  37. voxcity/geoprocessor/raster/raster.py +110 -0
  38. voxcity/geoprocessor/selection.py +85 -0
  39. voxcity/geoprocessor/utils.py +824 -820
  40. voxcity/models.py +113 -0
  41. voxcity/simulator/common/__init__.py +22 -0
  42. voxcity/simulator/common/geometry.py +98 -0
  43. voxcity/simulator/common/raytracing.py +450 -0
  44. voxcity/simulator/solar/__init__.py +66 -0
  45. voxcity/simulator/solar/integration.py +336 -0
  46. voxcity/simulator/solar/kernels.py +62 -0
  47. voxcity/simulator/solar/radiation.py +648 -0
  48. voxcity/simulator/solar/sky.py +668 -0
  49. voxcity/simulator/solar/temporal.py +792 -0
  50. voxcity/simulator/view.py +36 -2286
  51. voxcity/simulator/visibility/__init__.py +29 -0
  52. voxcity/simulator/visibility/landmark.py +392 -0
  53. voxcity/simulator/visibility/view.py +508 -0
  54. voxcity/utils/__init__.py +11 -0
  55. voxcity/utils/classes.py +194 -0
  56. voxcity/utils/lc.py +80 -39
  57. voxcity/utils/logging.py +61 -0
  58. voxcity/utils/orientation.py +51 -0
  59. voxcity/utils/shape.py +230 -0
  60. voxcity/utils/weather/__init__.py +26 -0
  61. voxcity/utils/weather/epw.py +146 -0
  62. voxcity/utils/weather/files.py +36 -0
  63. voxcity/utils/weather/onebuilding.py +486 -0
  64. voxcity/visualizer/__init__.py +24 -0
  65. voxcity/visualizer/builder.py +43 -0
  66. voxcity/visualizer/grids.py +141 -0
  67. voxcity/visualizer/maps.py +187 -0
  68. voxcity/visualizer/palette.py +228 -0
  69. voxcity/visualizer/renderer.py +1145 -0
  70. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/METADATA +162 -48
  71. voxcity-1.0.2.dist-info/RECORD +81 -0
  72. voxcity/generator.py +0 -1302
  73. voxcity/geoprocessor/grid.py +0 -1739
  74. voxcity/geoprocessor/polygon.py +0 -1344
  75. voxcity/simulator/solar.py +0 -2339
  76. voxcity/utils/visualization.py +0 -2849
  77. voxcity/utils/weather.py +0 -1038
  78. voxcity-0.6.26.dist-info/RECORD +0 -38
  79. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
  80. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,821 +1,825 @@
1
- """
2
- Utility functions for geographic operations and coordinate transformations.
3
-
4
- This module provides various utility functions for working with geographic data,
5
- including coordinate transformations, distance calculations, geocoding, and building
6
- polygon processing. It supports operations such as:
7
-
8
- - Tile coordinate calculations and quadkey conversions
9
- - Geographic distance calculations (Haversine and geodetic)
10
- - Coordinate system transformations
11
- - Polygon and GeoDataFrame operations
12
- - Raster file processing and merging
13
- - Geocoding and reverse geocoding
14
- - Timezone and location information retrieval
15
- - Building polygon validation and processing
16
-
17
- The module uses several external libraries for geographic operations:
18
- - pyproj: For coordinate transformations and geodetic calculations
19
- - geopandas: For handling geographic data frames
20
- - rasterio: For raster file operations
21
- - shapely: For geometric operations
22
- - geopy: For geocoding services
23
- - timezonefinder: For timezone lookups
24
- """
25
-
26
- # Standard library imports
27
- import os
28
- import math
29
- from math import radians, sin, cos, sqrt, atan2
30
- from datetime import datetime
31
-
32
- # Third-party geographic processing libraries
33
- import numpy as np
34
- from pyproj import Geod, Transformer
35
- import geopandas as gpd
36
- import rasterio
37
- from rasterio.merge import merge
38
- from rasterio.warp import transform_bounds
39
- from rasterio.mask import mask
40
- from shapely.geometry import Polygon, box
41
- from fiona.crs import from_epsg
42
- from rtree import index
43
-
44
- # Geocoding and location services
45
- from geopy.geocoders import Nominatim
46
- from geopy.exc import GeocoderTimedOut, GeocoderServiceError, GeocoderInsufficientPrivileges
47
- from geopy.extra.rate_limiter import RateLimiter
48
- import reverse_geocoder as rg
49
- import pycountry
50
-
51
- # Timezone handling
52
- from timezonefinder import TimezoneFinder
53
- import pytz
54
-
55
- # Suppress rasterio warnings for non-georeferenced files
56
- import warnings
57
- warnings.filterwarnings("ignore", category=rasterio.errors.NotGeoreferencedWarning)
58
-
59
- # Global constants
60
- floor_height = 2.5 # Standard floor height in meters used for building height calculations
61
-
62
- # Build a compliant Nominatim user agent once and reuse it
63
- try:
64
- # Prefer package metadata if available
65
- from voxcity import __version__ as _vox_version, __email__ as _vox_email
66
- except Exception:
67
- _vox_version, _vox_email = "dev", "contact@voxcity.local"
68
-
69
- _ENV_UA = os.environ.get("VOXCITY_NOMINATIM_UA", "").strip()
70
- _DEFAULT_UA = f"voxcity/{_vox_version} (+https://github.com/kunifujiwara/voxcity; contact: {_vox_email})"
71
- _NOMINATIM_USER_AGENT = _ENV_UA or _DEFAULT_UA
72
-
73
- def _create_nominatim_geolocator() -> Nominatim:
74
- """
75
- Create a Nominatim geolocator with a compliant identifying user agent.
76
- The user agent can be overridden via the environment variable
77
- VOXCITY_NOMINATIM_UA.
78
- """
79
- return Nominatim(user_agent=_NOMINATIM_USER_AGENT)
80
-
81
- def tile_from_lat_lon(lat, lon, level_of_detail):
82
- """
83
- Convert latitude/longitude coordinates to tile coordinates at a given zoom level.
84
- Uses the Web Mercator projection (EPSG:3857) commonly used in web mapping.
85
-
86
- Args:
87
- lat (float): Latitude in degrees (-90 to 90)
88
- lon (float): Longitude in degrees (-180 to 180)
89
- level_of_detail (int): Zoom level (0-23, where 0 is the entire world)
90
-
91
- Returns:
92
- tuple: (tile_x, tile_y) tile coordinates in the global tile grid
93
-
94
- Example:
95
- >>> tile_x, tile_y = tile_from_lat_lon(35.6762, 139.6503, 12) # Tokyo at zoom 12
96
- """
97
- # Convert latitude to radians and calculate sine
98
- sin_lat = math.sin(lat * math.pi / 180)
99
-
100
- # Convert longitude to normalized x coordinate (0-1)
101
- x = (lon + 180) / 360
102
-
103
- # Convert latitude to y coordinate using Mercator projection formula
104
- y = 0.5 - math.log((1 + sin_lat) / (1 - sin_lat)) / (4 * math.pi)
105
-
106
- # Calculate map size in pixels at this zoom level (256 * 2^zoom)
107
- map_size = 256 << level_of_detail
108
-
109
- # Convert x,y to tile coordinates
110
- tile_x = int(x * map_size / 256)
111
- tile_y = int(y * map_size / 256)
112
- return tile_x, tile_y
113
-
114
- def quadkey_to_tile(quadkey):
115
- """
116
- Convert a quadkey string to tile coordinates.
117
- A quadkey is a string of digits (0-3) that identifies a tile at a certain zoom level.
118
- Each digit in the quadkey represents a tile at a zoom level, with each subsequent digit
119
- representing a more detailed zoom level.
120
-
121
- The quadkey numbering scheme:
122
- - 0: Top-left quadrant
123
- - 1: Top-right quadrant
124
- - 2: Bottom-left quadrant
125
- - 3: Bottom-right quadrant
126
-
127
- Args:
128
- quadkey (str): Quadkey string (e.g., "120" for zoom level 3)
129
-
130
- Returns:
131
- tuple: (tile_x, tile_y, level_of_detail) tile coordinates and zoom level
132
-
133
- Example:
134
- >>> x, y, zoom = quadkey_to_tile("120") # Returns coordinates at zoom level 3
135
- """
136
- tile_x = tile_y = 0
137
- level_of_detail = len(quadkey)
138
-
139
- # Process each character in quadkey
140
- for i in range(level_of_detail):
141
- bit = level_of_detail - i - 1
142
- mask = 1 << bit
143
-
144
- # Quadkey digit to binary:
145
- # 0 = neither x nor y bit set
146
- # 1 = x bit set
147
- # 2 = y bit set
148
- # 3 = both x and y bits set
149
- if quadkey[i] == '1':
150
- tile_x |= mask
151
- elif quadkey[i] == '2':
152
- tile_y |= mask
153
- elif quadkey[i] == '3':
154
- tile_x |= mask
155
- tile_y |= mask
156
- return tile_x, tile_y, level_of_detail
157
-
158
- def initialize_geod():
159
- """
160
- Initialize a Geod object for geodetic calculations using WGS84 ellipsoid.
161
- The WGS84 ellipsoid (EPSG:4326) is the standard reference system used by GPS
162
- and most modern mapping applications.
163
-
164
- The Geod object provides methods for:
165
- - Forward geodetic calculations (direct)
166
- - Inverse geodetic calculations (inverse)
167
- - Area calculations
168
- - Line length calculations
169
-
170
- Returns:
171
- Geod: Initialized Geod object for WGS84 calculations
172
-
173
- Example:
174
- >>> geod = initialize_geod()
175
- >>> fwd_az, back_az, dist = geod.inv(lon1, lat1, lon2, lat2)
176
- """
177
- return Geod(ellps='WGS84')
178
-
179
- def calculate_distance(geod, lon1, lat1, lon2, lat2):
180
- """
181
- Calculate geodetic distance between two points on the Earth's surface.
182
- Uses inverse geodetic computation to find the shortest distance along the ellipsoid,
183
- which is more accurate than great circle (spherical) calculations.
184
-
185
- Args:
186
- geod (Geod): Geod object for calculations, initialized with WGS84
187
- lon1, lat1 (float): Coordinates of first point in decimal degrees
188
- lon2, lat2 (float): Coordinates of second point in decimal degrees
189
-
190
- Returns:
191
- float: Distance in meters between the two points along the ellipsoid
192
-
193
- Example:
194
- >>> geod = initialize_geod()
195
- >>> distance = calculate_distance(geod, 139.6503, 35.6762,
196
- ... -74.0060, 40.7128) # Tokyo to NYC
197
- """
198
- # inv() returns forward azimuth, back azimuth, and distance
199
- _, _, dist = geod.inv(lon1, lat1, lon2, lat2)
200
- return dist
201
-
202
- def normalize_to_one_meter(vector, distance_in_meters):
203
- """
204
- Normalize a vector to represent one meter in geographic space.
205
- Useful for creating unit vectors in geographic calculations, particularly
206
- when working with distance-based operations or scaling geographic features.
207
-
208
- Args:
209
- vector (numpy.ndarray): Vector to normalize, typically a direction vector
210
- distance_in_meters (float): Current distance in meters that the vector represents
211
-
212
- Returns:
213
- numpy.ndarray: Normalized vector where magnitude represents 1 meter
214
-
215
- Example:
216
- >>> direction = np.array([3.0, 4.0]) # Vector of length 5
217
- >>> unit_meter = normalize_to_one_meter(direction, 5.0)
218
- """
219
- return vector * (1 / distance_in_meters)
220
-
221
- def setup_transformer(from_crs, to_crs):
222
- """
223
- Set up a coordinate transformer between two Coordinate Reference Systems (CRS).
224
- The always_xy=True parameter ensures consistent handling of coordinate order
225
- by always using (x,y) or (longitude,latitude) order regardless of CRS definition.
226
-
227
- Common CRS codes:
228
- - EPSG:4326 - WGS84 (latitude/longitude)
229
- - EPSG:3857 - Web Mercator
230
- - EPSG:2263 - NY State Plane
231
-
232
- Args:
233
- from_crs: Source coordinate reference system (EPSG code, proj4 string, or CRS dict)
234
- to_crs: Target coordinate reference system (EPSG code, proj4 string, or CRS dict)
235
-
236
- Returns:
237
- Transformer: Initialized transformer object for coordinate conversion
238
-
239
- Example:
240
- >>> transformer = setup_transformer("EPSG:4326", "EPSG:3857")
241
- >>> x, y = transformer.transform(longitude, latitude)
242
- """
243
- return Transformer.from_crs(from_crs, to_crs, always_xy=True)
244
-
245
- def transform_coords(transformer, lon, lat):
246
- """
247
- Transform coordinates using provided transformer with error handling.
248
- Includes validation for infinite values that may result from invalid transformations
249
- or coordinates outside the valid range for the target CRS.
250
-
251
- Args:
252
- transformer (Transformer): Coordinate transformer from setup_transformer()
253
- lon, lat (float): Input coordinates in the source CRS
254
-
255
- Returns:
256
- tuple: (x, y) transformed coordinates in the target CRS, or (None, None) if transformation fails
257
-
258
- Example:
259
- >>> transformer = setup_transformer("EPSG:4326", "EPSG:3857")
260
- >>> x, y = transform_coords(transformer, -74.0060, 40.7128) # NYC coordinates
261
- >>> if x is not None:
262
- ... print(f"Transformed coordinates: ({x}, {y})")
263
- """
264
- try:
265
- x, y = transformer.transform(lon, lat)
266
- if np.isinf(x) or np.isinf(y):
267
- print(f"Transformation resulted in inf values for coordinates: {lon}, {lat}")
268
- return x, y
269
- except Exception as e:
270
- print(f"Error transforming coordinates {lon}, {lat}: {e}")
271
- return None, None
272
-
273
- def create_polygon(vertices):
274
- """
275
- Create a Shapely polygon from a list of vertices.
276
- Input vertices must be in (longitude, latitude) format as required by Shapely.
277
- The polygon will be automatically closed if the first and last vertices don't match.
278
-
279
- Args:
280
- vertices (list): List of (longitude, latitude) coordinate pairs forming the polygon.
281
- The coordinates should be in counter-clockwise order for exterior rings
282
- and clockwise order for interior rings (holes).
283
-
284
- Returns:
285
- Polygon: Shapely polygon object that can be used for spatial operations
286
-
287
- Example:
288
- >>> vertices = [(0, 0), (1, 0), (1, 1), (0, 1)] # Square
289
- >>> polygon = create_polygon(vertices)
290
- >>> print(f"Polygon area: {polygon.area}")
291
- """
292
- return Polygon(vertices)
293
-
294
- def create_geodataframe(polygon, crs=4326):
295
- """
296
- Create a GeoDataFrame from a Shapely polygon.
297
- Default CRS is WGS84 (EPSG:4326) for geographic coordinates.
298
- The GeoDataFrame provides additional functionality for spatial operations,
299
- data analysis, and export to various geographic formats.
300
-
301
- Args:
302
- polygon (Polygon): Shapely polygon object to convert
303
- crs (int): Coordinate reference system EPSG code (default: 4326 for WGS84)
304
-
305
- Returns:
306
- GeoDataFrame: GeoDataFrame containing the polygon with specified CRS
307
-
308
- Example:
309
- >>> vertices = [(0, 0), (1, 0), (1, 1), (0, 1)]
310
- >>> polygon = create_polygon(vertices)
311
- >>> gdf = create_geodataframe(polygon)
312
- >>> gdf.to_file("polygon.geojson", driver="GeoJSON")
313
- """
314
- return gpd.GeoDataFrame({'geometry': [polygon]}, crs=from_epsg(crs))
315
-
316
- def haversine_distance(lon1, lat1, lon2, lat2):
317
- """
318
- Calculate great-circle distance between two points using Haversine formula.
319
- This is an approximation that treats the Earth as a perfect sphere.
320
-
321
- Args:
322
- lon1, lat1 (float): Coordinates of first point
323
- lon2, lat2 (float): Coordinates of second point
324
-
325
- Returns:
326
- float: Distance in kilometers
327
- """
328
- R = 6371 # Earth's radius in kilometers
329
-
330
- # Convert all coordinates to radians
331
- lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
332
-
333
- # Calculate differences
334
- dlat = lat2 - lat1
335
- dlon = lon2 - lon1
336
-
337
- # Haversine formula
338
- a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
339
- c = 2 * atan2(sqrt(a), sqrt(1-a))
340
- return R * c
341
-
342
- def get_raster_bbox(raster_path):
343
- """
344
- Get the bounding box of a raster file in its native coordinate system.
345
- Returns a rectangular polygon representing the spatial extent of the raster,
346
- which can be used for spatial queries and intersection tests.
347
-
348
- Args:
349
- raster_path (str): Path to the raster file (GeoTIFF, IMG, etc.)
350
-
351
- Returns:
352
- box: Shapely box representing the raster bounds in the raster's CRS
353
-
354
- Example:
355
- >>> bbox = get_raster_bbox("elevation.tif")
356
- >>> print(f"Raster extent: {bbox.bounds}") # (minx, miny, maxx, maxy)
357
- """
358
- with rasterio.open(raster_path) as src:
359
- bounds = src.bounds
360
- return box(bounds.left, bounds.bottom, bounds.right, bounds.top)
361
-
362
- def raster_intersects_polygon(raster_path, polygon):
363
- """
364
- Check if a raster file's extent intersects with a given polygon.
365
- Automatically handles coordinate system transformations by converting
366
- the raster bounds to WGS84 (EPSG:4326) if needed before the intersection test.
367
-
368
- Args:
369
- raster_path (str): Path to the raster file to check
370
- polygon (Polygon): Shapely polygon to test intersection with (in WGS84)
371
-
372
- Returns:
373
- bool: True if raster intersects or contains the polygon, False otherwise
374
-
375
- Example:
376
- >>> aoi = create_polygon([(lon1, lat1), (lon2, lat2), ...]) # Area of interest
377
- >>> if raster_intersects_polygon("dem.tif", aoi):
378
- ... print("Raster covers the area of interest")
379
- """
380
- with rasterio.open(raster_path) as src:
381
- bounds = src.bounds
382
- # Transform bounds to WGS84 if raster is in different CRS
383
- if src.crs.to_epsg() != 4326:
384
- bounds = transform_bounds(src.crs, 'EPSG:4326', *bounds)
385
- raster_bbox = box(*bounds)
386
- intersects = raster_bbox.intersects(polygon) or polygon.intersects(raster_bbox)
387
- return intersects
388
-
389
- def save_raster(input_path, output_path):
390
- """
391
- Create a copy of a raster file at a new location.
392
- Performs a direct file copy without any transformation or modification,
393
- preserving all metadata, georeferencing, and pixel values.
394
-
395
- Args:
396
- input_path (str): Source raster file path
397
- output_path (str): Destination path for the copied raster
398
-
399
- Example:
400
- >>> save_raster("original.tif", "backup/copy.tif")
401
- >>> print("Copied original file to: backup/copy.tif")
402
- """
403
- import shutil
404
- shutil.copy(input_path, output_path)
405
- print(f"Copied original file to: {output_path}")
406
-
407
- def merge_geotiffs(geotiff_files, output_dir):
408
- """
409
- Merge multiple GeoTIFF files into a single mosaic.
410
- Handles edge matching and overlapping areas between adjacent rasters.
411
- The output will have the same coordinate system and data type as the input files.
412
-
413
- Important considerations:
414
- - All input files should have the same coordinate system
415
- - All input files should have the same data type
416
- - Overlapping areas are handled by taking the first value encountered
417
-
418
- Args:
419
- geotiff_files (list): List of paths to GeoTIFF files to merge
420
- output_dir (str): Directory where the merged output will be saved
421
-
422
- Example:
423
- >>> files = ["tile1.tif", "tile2.tif", "tile3.tif"]
424
- >>> merge_geotiffs(files, "output_directory")
425
- >>> print("Merged output saved to: output_directory/lulc.tif")
426
- """
427
- if not geotiff_files:
428
- return
429
-
430
- # Open all valid GeoTIFF files
431
- src_files_to_mosaic = [rasterio.open(file) for file in geotiff_files if os.path.exists(file)]
432
-
433
- if src_files_to_mosaic:
434
- try:
435
- # Merge rasters into a single mosaic and get output transform
436
- mosaic, out_trans = merge(src_files_to_mosaic)
437
-
438
- # Copy metadata from first raster and update for merged output
439
- out_meta = src_files_to_mosaic[0].meta.copy()
440
- out_meta.update({
441
- "driver": "GTiff",
442
- "height": mosaic.shape[1],
443
- "width": mosaic.shape[2],
444
- "transform": out_trans
445
- })
446
-
447
- # Save merged raster to output file
448
- merged_path = os.path.join(output_dir, "lulc.tif")
449
- with rasterio.open(merged_path, "w", **out_meta) as dest:
450
- dest.write(mosaic)
451
-
452
- print(f"Merged output saved to: {merged_path}")
453
- except Exception as e:
454
- print(f"Error merging files: {e}")
455
- else:
456
- print("No valid files to merge.")
457
-
458
- # Clean up by closing all opened files
459
- for src in src_files_to_mosaic:
460
- src.close()
461
-
462
- def convert_format_lat_lon(input_coords):
463
- """
464
- Convert coordinate format and close polygon.
465
- Input coordinates are already in [lon, lat] format.
466
-
467
- Args:
468
- input_coords (list): List of [lon, lat] coordinates
469
-
470
- Returns:
471
- list: List of [lon, lat] coordinates with first point repeated at end
472
- """
473
- # Create list with coordinates in same order
474
- output_coords = input_coords.copy()
475
- # Close polygon by repeating first point at end
476
- output_coords.append(output_coords[0])
477
- return output_coords
478
-
479
- def get_coordinates_from_cityname(place_name):
480
- """
481
- Geocode a city name to get its coordinates using OpenStreetMap's Nominatim service.
482
- Includes rate limiting and error handling to comply with Nominatim's usage policy.
483
-
484
- Note:
485
- - Results may vary based on the specificity of the place name
486
- - For better results, include country or state information
487
- - Service has usage limits and may timeout
488
-
489
- Args:
490
- place_name (str): Name of the city to geocode (e.g., "Tokyo, Japan")
491
-
492
- Returns:
493
- tuple: (latitude, longitude) coordinates or None if geocoding fails
494
-
495
- Example:
496
- >>> coords = get_coordinates_from_cityname("Paris, France")
497
- >>> if coords:
498
- ... lat, lon = coords
499
- ... print(f"Paris coordinates: {lat}, {lon}")
500
- """
501
- # Initialize geocoder with compliant user agent
502
- geolocator = _create_nominatim_geolocator()
503
- geocode_once = RateLimiter(geolocator.geocode, min_delay_seconds=1.0, max_retries=0)
504
-
505
- try:
506
- # Attempt to geocode the place name (single try; no retries on 403)
507
- location = geocode_once(place_name, exactly_one=True, timeout=10)
508
-
509
- if location:
510
- return (location.latitude, location.longitude)
511
- else:
512
- return None
513
- except GeocoderInsufficientPrivileges:
514
- print("Warning: Nominatim blocked the request (HTTP 403). Please set a proper user agent and avoid bulk requests.")
515
- return None
516
- except (GeocoderTimedOut, GeocoderServiceError):
517
- print(f"Error: Geocoding service timed out or encountered an error for {place_name}")
518
- return None
519
-
520
- def get_city_country_name_from_rectangle(coordinates):
521
- """
522
- Get the city and country name for a location defined by a rectangle.
523
- Uses reverse geocoding to find the nearest named place to the rectangle's center.
524
-
525
- The function:
526
- 1. Calculates the center point of the rectangle
527
- 2. Performs reverse geocoding with rate limiting
528
- 3. Extracts city and country information from the result
529
-
530
- Args:
531
- coordinates (list): List of (longitude, latitude) coordinates defining the rectangle
532
-
533
- Returns:
534
- str: String in format "city/ country" or fallback value if lookup fails
535
-
536
- Example:
537
- >>> coords = [(139.65, 35.67), (139.66, 35.67),
538
- ... (139.66, 35.68), (139.65, 35.68)]
539
- >>> location = get_city_country_name_from_rectangle(coords)
540
- >>> print(f"Location: {location}") # e.g., "Shibuya/ Japan"
541
- """
542
- # Calculate center point of rectangle
543
- longitudes = [coord[0] for coord in coordinates]
544
- latitudes = [coord[1] for coord in coordinates]
545
- center_lon = sum(longitudes) / len(longitudes)
546
- center_lat = sum(latitudes) / len(latitudes)
547
- center_coord = (center_lat, center_lon)
548
-
549
- # Initialize geocoder with compliant user agent and conservative rate limit (1 req/sec)
550
- geolocator = _create_nominatim_geolocator()
551
- reverse_once = RateLimiter(geolocator.reverse, min_delay_seconds=1.0, max_retries=0)
552
-
553
- try:
554
- # Attempt reverse geocoding of center coordinates (single try; no retries on 403)
555
- location = reverse_once(center_coord, language='en', exactly_one=True, timeout=10)
556
- if location:
557
- address = location.raw['address']
558
- # Try multiple address fields to find city name, falling back to county if needed
559
- city = address.get('city', '') or address.get('town', '') or address.get('village', '') or address.get('county', '')
560
- country = address.get('country', '')
561
- return f"{city}/ {country}"
562
- else:
563
- print("Location not found")
564
- return "Unknown Location/ Unknown Country"
565
- except GeocoderInsufficientPrivileges:
566
- # Fallback to offline reverse_geocoder at coarse resolution
567
- try:
568
- results = rg.search((center_lat, center_lon))
569
- name = results[0].get('name') or ''
570
- country = get_country_name(center_lon, center_lat) or ''
571
- if name or country:
572
- return f"{name}/ {country}".strip()
573
- except Exception:
574
- pass
575
- print("Warning: Nominatim blocked the request (HTTP 403). Falling back to offline coarse reverse geocoding.")
576
- return "Unknown Location/ Unknown Country"
577
- except (GeocoderTimedOut, GeocoderServiceError) as e:
578
- print(f"Error retrieving location for {center_coord}: {e}")
579
- return "Unknown Location/ Unknown Country"
580
-
581
- def get_timezone_info(rectangle_coords):
582
- """
583
- Get timezone and central meridian information for a location.
584
- Uses the rectangle's center point to determine the local timezone and
585
- calculates the central meridian based on the UTC offset.
586
-
587
- The function provides:
588
- 1. Local timezone identifier (e.g., "America/New_York")
589
- 2. UTC offset (e.g., "UTC-04:00")
590
- 3. Central meridian longitude for the timezone
591
-
592
- Args:
593
- rectangle_coords (list): List of (longitude, latitude) coordinates defining the area
594
-
595
- Returns:
596
- tuple: (timezone string with UTC offset, central meridian longitude string)
597
-
598
- Example:
599
- >>> coords = [(139.65, 35.67), (139.66, 35.67),
600
- ... (139.66, 35.68), (139.65, 35.68)]
601
- >>> tz, meridian = get_timezone_info(coords)
602
- >>> print(f"Timezone: {tz}, Meridian: {meridian}") # e.g., "UTC+09:00, 135.00000"
603
- """
604
- # Calculate center point of rectangle
605
- longitudes = [coord[0] for coord in rectangle_coords]
606
- latitudes = [coord[1] for coord in rectangle_coords]
607
- center_lon = sum(longitudes) / len(longitudes)
608
- center_lat = sum(latitudes) / len(latitudes)
609
-
610
- # Find timezone at center coordinates
611
- tf = TimezoneFinder()
612
- timezone_str = tf.timezone_at(lng=center_lon, lat=center_lat)
613
-
614
- if timezone_str:
615
- # Get current time in local timezone to calculate offset
616
- timezone = pytz.timezone(timezone_str)
617
- now = datetime.now(timezone)
618
- offset_seconds = now.utcoffset().total_seconds()
619
- offset_hours = offset_seconds / 3600
620
-
621
- # Format timezone offset and calculate central meridian
622
- utc_offset = f"UTC{offset_hours:+.2f}"
623
- timezone_longitude = offset_hours * 15 # Each hour offset = 15 degrees longitude
624
- timezone_longitude_str = f"{timezone_longitude:.5f}"
625
-
626
- return utc_offset, timezone_longitude_str
627
- else:
628
- # Return fallback values if timezone cannot be determined
629
- print("Warning: Timezone not found for the given location, using UTC+00:00")
630
- return "UTC+00:00", "0.00000"
631
-
632
- def validate_polygon_coordinates(geometry):
633
- """
634
- Validate and ensure proper closure of polygon coordinate rings.
635
- Performs validation and correction of GeoJSON polygon geometries according to
636
- the GeoJSON specification requirements.
637
-
638
- Validation checks:
639
- 1. Geometry type (Polygon or MultiPolygon)
640
- 2. Ring closure (first point equals last point)
641
- 3. Minimum number of points (4, including closure)
642
-
643
- Args:
644
- geometry (dict): GeoJSON geometry object with 'type' and 'coordinates' properties
645
-
646
- Returns:
647
- bool: True if polygon coordinates are valid or were successfully corrected,
648
- False if validation failed
649
-
650
- Example:
651
- >>> geom = {
652
- ... "type": "Polygon",
653
- ... "coordinates": [[[0,0], [1,0], [1,1], [0,1]]] # Not closed
654
- ... }
655
- >>> if validate_polygon_coordinates(geom):
656
- ... print("Polygon is valid") # Will close the ring automatically
657
- """
658
- if geometry['type'] == 'Polygon':
659
- for ring in geometry['coordinates']:
660
- # Ensure polygon is closed by checking/adding first point at end
661
- if ring[0] != ring[-1]:
662
- ring.append(ring[0]) # Close the ring
663
- # Check minimum points needed for valid polygon (3 points + closing point)
664
- if len(ring) < 4:
665
- return False
666
- return True
667
- elif geometry['type'] == 'MultiPolygon':
668
- for polygon in geometry['coordinates']:
669
- for ring in polygon:
670
- if ring[0] != ring[-1]:
671
- ring.append(ring[0]) # Close the ring
672
- if len(ring) < 4:
673
- return False
674
- return True
675
- else:
676
- return False
677
-
678
- def create_building_polygons(filtered_buildings):
679
- """
680
- Create building polygons with properties from filtered GeoJSON features.
681
- Processes a list of GeoJSON building features to create Shapely polygons
682
- with associated height and other properties, while also building a spatial index.
683
-
684
- Processing steps:
685
- 1. Extract and validate coordinates
686
- 2. Create Shapely polygons
687
- 3. Process building properties (height, levels, etc.)
688
- 4. Build spatial index for efficient querying
689
-
690
- Height calculation rules:
691
- - Use explicit height if available
692
- - Calculate from levels * floor_height if height not available
693
- - Calculate from floors * floor_height if levels not available
694
- - Use NaN if no height information available
695
-
696
- Args:
697
- filtered_buildings (list): List of GeoJSON building features with properties
698
-
699
- Returns:
700
- tuple: (
701
- list of tuples (polygon, height, min_height, is_inner, feature_id),
702
- rtree spatial index for the polygons
703
- )
704
-
705
- Example:
706
- >>> buildings = [
707
- ... {
708
- ... "type": "Feature",
709
- ... "geometry": {"type": "Polygon", "coordinates": [...]},
710
- ... "properties": {"height": 30, "levels": 10}
711
- ... },
712
- ... # ... more buildings ...
713
- ... ]
714
- >>> polygons, spatial_idx = create_building_polygons(buildings)
715
- """
716
- building_polygons = []
717
- idx = index.Index()
718
- valid_count = 0
719
- count = 0
720
-
721
- # Find highest existing ID to avoid duplicates
722
- id_list = []
723
- for i, building in enumerate(filtered_buildings):
724
- if building['properties'].get('id') is not None:
725
- id_list.append(building['properties']['id'])
726
- if len(id_list) > 0:
727
- id_count = max(id_list)+1
728
- else:
729
- id_count = 1
730
-
731
- for building in filtered_buildings:
732
- try:
733
- # Handle potential nested coordinate tuples
734
- coords = building['geometry']['coordinates'][0]
735
- # Flatten coordinates if they're nested tuples
736
- if isinstance(coords[0], tuple):
737
- coords = [list(c) for c in coords]
738
- elif isinstance(coords[0][0], tuple):
739
- coords = [list(c[0]) for c in coords]
740
-
741
- # Create polygon from coordinates
742
- polygon = Polygon(coords)
743
-
744
- # Skip invalid geometries
745
- if not polygon.is_valid:
746
- print(f"Warning: Skipping invalid polygon geometry")
747
- continue
748
-
749
- height = building['properties'].get('height')
750
- levels = building['properties'].get('levels')
751
- floors = building['properties'].get('num_floors')
752
- min_height = building['properties'].get('min_height')
753
- min_level = building['properties'].get('min_level')
754
- min_floor = building['properties'].get('min_floor')
755
-
756
- if (height is None) or (height<=0):
757
- if levels is not None:
758
- height = floor_height * levels
759
- elif floors is not None:
760
- height = floor_height * floors
761
- else:
762
- count += 1
763
- height = np.nan
764
-
765
- if (min_height is None) or (min_height<=0):
766
- if min_level is not None:
767
- min_height = floor_height * float(min_level)
768
- elif min_floor is not None:
769
- min_height = floor_height * float(min_floor)
770
- else:
771
- min_height = 0
772
-
773
- if building['properties'].get('id') is not None:
774
- feature_id = building['properties']['id']
775
- else:
776
- feature_id = id_count
777
- id_count += 1
778
-
779
- if building['properties'].get('is_inner') is not None:
780
- is_inner = building['properties']['is_inner']
781
- else:
782
- is_inner = False
783
-
784
- building_polygons.append((polygon, height, min_height, is_inner, feature_id))
785
- idx.insert(valid_count, polygon.bounds)
786
- valid_count += 1
787
-
788
- except Exception as e:
789
- print(f"Warning: Skipping invalid building geometry: {e}")
790
- continue
791
-
792
- return building_polygons, idx
793
-
794
- def get_country_name(lon, lat):
795
- """
796
- Get country name from coordinates using reverse geocoding.
797
- Uses a local database for fast reverse geocoding to country level,
798
- then converts the country code to full name using pycountry.
799
-
800
- Args:
801
- lon (float): Longitude in decimal degrees
802
- lat (float): Latitude in decimal degrees
803
-
804
- Returns:
805
- str: Full country name or None if lookup fails
806
-
807
- Example:
808
- >>> country = get_country_name(139.6503, 35.6762)
809
- >>> print(f"Country: {country}") # "Japan"
810
- """
811
- # Use reverse geocoder to get country code
812
- results = rg.search((lat, lon))
813
- country_code = results[0]['cc']
814
-
815
- # Convert country code to full name using pycountry
816
- country = pycountry.countries.get(alpha_2=country_code)
817
-
818
- if country:
819
- return country.name
820
- else:
1
+ """
2
+ Utility functions for geographic operations and coordinate transformations.
3
+
4
+ This module provides various utility functions for working with geographic data,
5
+ including coordinate transformations, distance calculations, geocoding, and building
6
+ polygon processing. It supports operations such as:
7
+
8
+ - Tile coordinate calculations and quadkey conversions
9
+ - Geographic distance calculations (Haversine and geodetic)
10
+ - Coordinate system transformations
11
+ - Polygon and GeoDataFrame operations
12
+ - Raster file processing and merging
13
+ - Geocoding and reverse geocoding
14
+ - Timezone and location information retrieval
15
+ - Building polygon validation and processing
16
+
17
+ The module uses several external libraries for geographic operations:
18
+ - pyproj: For coordinate transformations and geodetic calculations
19
+ - geopandas: For handling geographic data frames
20
+ - rasterio: For raster file operations
21
+ - shapely: For geometric operations
22
+ - geopy: For geocoding services
23
+ - timezonefinder: For timezone lookups
24
+ """
25
+
26
+ # Standard library imports
27
+ import os
28
+ import math
29
+ from math import radians, sin, cos, sqrt, atan2
30
+ from datetime import datetime
31
+
32
+ # Third-party geographic processing libraries
33
+ import numpy as np
34
+ from pyproj import Geod, Transformer
35
+ import geopandas as gpd
36
+ import rasterio
37
+ from rasterio.merge import merge
38
+ from rasterio.warp import transform_bounds
39
+ from rasterio.mask import mask
40
+ from shapely.geometry import Polygon, box
41
+ from fiona.crs import from_epsg
42
+ from rtree import index
43
+
44
+ # Geocoding and location services
45
+ from geopy.geocoders import Nominatim
46
+ from geopy.exc import GeocoderTimedOut, GeocoderServiceError, GeocoderInsufficientPrivileges
47
+ from geopy.extra.rate_limiter import RateLimiter
48
+ import reverse_geocoder as rg
49
+ import pycountry
50
+
51
+ # Timezone handling
52
+ from timezonefinder import TimezoneFinder
53
+ import pytz
54
+
55
+ # Suppress rasterio warnings for non-georeferenced files
56
+ import warnings
57
+ warnings.filterwarnings("ignore", category=rasterio.errors.NotGeoreferencedWarning)
58
+
59
+ # Global constants
60
+ floor_height = 2.5 # Standard floor height in meters used for building height calculations
61
+
62
+ # Package logging
63
+ from ..utils.logging import get_logger
64
+ logger = get_logger(__name__)
65
+
66
+ # Build a compliant Nominatim user agent once and reuse it
67
+ try:
68
+ # Prefer package metadata if available
69
+ from voxcity import __version__ as _vox_version, __email__ as _vox_email
70
+ except Exception:
71
+ _vox_version, _vox_email = "dev", "contact@voxcity.local"
72
+
73
+ _ENV_UA = os.environ.get("VOXCITY_NOMINATIM_UA", "").strip()
74
+ _DEFAULT_UA = f"voxcity/{_vox_version} (+https://github.com/kunifujiwara/voxcity; contact: {_vox_email})"
75
+ _NOMINATIM_USER_AGENT = _ENV_UA or _DEFAULT_UA
76
+
77
+ def _create_nominatim_geolocator() -> Nominatim:
78
+ """
79
+ Create a Nominatim geolocator with a compliant identifying user agent.
80
+ The user agent can be overridden via the environment variable
81
+ VOXCITY_NOMINATIM_UA.
82
+ """
83
+ return Nominatim(user_agent=_NOMINATIM_USER_AGENT)
84
+
85
+ def tile_from_lat_lon(lat, lon, level_of_detail):
86
+ """
87
+ Convert latitude/longitude coordinates to tile coordinates at a given zoom level.
88
+ Uses the Web Mercator projection (EPSG:3857) commonly used in web mapping.
89
+
90
+ Args:
91
+ lat (float): Latitude in degrees (-90 to 90)
92
+ lon (float): Longitude in degrees (-180 to 180)
93
+ level_of_detail (int): Zoom level (0-23, where 0 is the entire world)
94
+
95
+ Returns:
96
+ tuple: (tile_x, tile_y) tile coordinates in the global tile grid
97
+
98
+ Example:
99
+ >>> tile_x, tile_y = tile_from_lat_lon(35.6762, 139.6503, 12) # Tokyo at zoom 12
100
+ """
101
+ # Convert latitude to radians and calculate sine
102
+ sin_lat = math.sin(lat * math.pi / 180)
103
+
104
+ # Convert longitude to normalized x coordinate (0-1)
105
+ x = (lon + 180) / 360
106
+
107
+ # Convert latitude to y coordinate using Mercator projection formula
108
+ y = 0.5 - math.log((1 + sin_lat) / (1 - sin_lat)) / (4 * math.pi)
109
+
110
+ # Calculate map size in pixels at this zoom level (256 * 2^zoom)
111
+ map_size = 256 << level_of_detail
112
+
113
+ # Convert x,y to tile coordinates
114
+ tile_x = int(x * map_size / 256)
115
+ tile_y = int(y * map_size / 256)
116
+ return tile_x, tile_y
117
+
118
+ def quadkey_to_tile(quadkey):
119
+ """
120
+ Convert a quadkey string to tile coordinates.
121
+ A quadkey is a string of digits (0-3) that identifies a tile at a certain zoom level.
122
+ Each digit in the quadkey represents a tile at a zoom level, with each subsequent digit
123
+ representing a more detailed zoom level.
124
+
125
+ The quadkey numbering scheme:
126
+ - 0: Top-left quadrant
127
+ - 1: Top-right quadrant
128
+ - 2: Bottom-left quadrant
129
+ - 3: Bottom-right quadrant
130
+
131
+ Args:
132
+ quadkey (str): Quadkey string (e.g., "120" for zoom level 3)
133
+
134
+ Returns:
135
+ tuple: (tile_x, tile_y, level_of_detail) tile coordinates and zoom level
136
+
137
+ Example:
138
+ >>> x, y, zoom = quadkey_to_tile("120") # Returns coordinates at zoom level 3
139
+ """
140
+ tile_x = tile_y = 0
141
+ level_of_detail = len(quadkey)
142
+
143
+ # Process each character in quadkey
144
+ for i in range(level_of_detail):
145
+ bit = level_of_detail - i - 1
146
+ mask = 1 << bit
147
+
148
+ # Quadkey digit to binary:
149
+ # 0 = neither x nor y bit set
150
+ # 1 = x bit set
151
+ # 2 = y bit set
152
+ # 3 = both x and y bits set
153
+ if quadkey[i] == '1':
154
+ tile_x |= mask
155
+ elif quadkey[i] == '2':
156
+ tile_y |= mask
157
+ elif quadkey[i] == '3':
158
+ tile_x |= mask
159
+ tile_y |= mask
160
+ return tile_x, tile_y, level_of_detail
161
+
162
+ def initialize_geod():
163
+ """
164
+ Initialize a Geod object for geodetic calculations using WGS84 ellipsoid.
165
+ The WGS84 ellipsoid (EPSG:4326) is the standard reference system used by GPS
166
+ and most modern mapping applications.
167
+
168
+ The Geod object provides methods for:
169
+ - Forward geodetic calculations (direct)
170
+ - Inverse geodetic calculations (inverse)
171
+ - Area calculations
172
+ - Line length calculations
173
+
174
+ Returns:
175
+ Geod: Initialized Geod object for WGS84 calculations
176
+
177
+ Example:
178
+ >>> geod = initialize_geod()
179
+ >>> fwd_az, back_az, dist = geod.inv(lon1, lat1, lon2, lat2)
180
+ """
181
+ return Geod(ellps='WGS84')
182
+
183
+ def calculate_distance(geod, lon1, lat1, lon2, lat2):
184
+ """
185
+ Calculate geodetic distance between two points on the Earth's surface.
186
+ Uses inverse geodetic computation to find the shortest distance along the ellipsoid,
187
+ which is more accurate than great circle (spherical) calculations.
188
+
189
+ Args:
190
+ geod (Geod): Geod object for calculations, initialized with WGS84
191
+ lon1, lat1 (float): Coordinates of first point in decimal degrees
192
+ lon2, lat2 (float): Coordinates of second point in decimal degrees
193
+
194
+ Returns:
195
+ float: Distance in meters between the two points along the ellipsoid
196
+
197
+ Example:
198
+ >>> geod = initialize_geod()
199
+ >>> distance = calculate_distance(geod, 139.6503, 35.6762,
200
+ ... -74.0060, 40.7128) # Tokyo to NYC
201
+ """
202
+ # inv() returns forward azimuth, back azimuth, and distance
203
+ _, _, dist = geod.inv(lon1, lat1, lon2, lat2)
204
+ return dist
205
+
206
+ def normalize_to_one_meter(vector, distance_in_meters):
207
+ """
208
+ Normalize a vector to represent one meter in geographic space.
209
+ Useful for creating unit vectors in geographic calculations, particularly
210
+ when working with distance-based operations or scaling geographic features.
211
+
212
+ Args:
213
+ vector (numpy.ndarray): Vector to normalize, typically a direction vector
214
+ distance_in_meters (float): Current distance in meters that the vector represents
215
+
216
+ Returns:
217
+ numpy.ndarray: Normalized vector where magnitude represents 1 meter
218
+
219
+ Example:
220
+ >>> direction = np.array([3.0, 4.0]) # Vector of length 5
221
+ >>> unit_meter = normalize_to_one_meter(direction, 5.0)
222
+ """
223
+ return vector * (1 / distance_in_meters)
224
+
225
+ def setup_transformer(from_crs, to_crs):
226
+ """
227
+ Set up a coordinate transformer between two Coordinate Reference Systems (CRS).
228
+ The always_xy=True parameter ensures consistent handling of coordinate order
229
+ by always using (x,y) or (longitude,latitude) order regardless of CRS definition.
230
+
231
+ Common CRS codes:
232
+ - EPSG:4326 - WGS84 (latitude/longitude)
233
+ - EPSG:3857 - Web Mercator
234
+ - EPSG:2263 - NY State Plane
235
+
236
+ Args:
237
+ from_crs: Source coordinate reference system (EPSG code, proj4 string, or CRS dict)
238
+ to_crs: Target coordinate reference system (EPSG code, proj4 string, or CRS dict)
239
+
240
+ Returns:
241
+ Transformer: Initialized transformer object for coordinate conversion
242
+
243
+ Example:
244
+ >>> transformer = setup_transformer("EPSG:4326", "EPSG:3857")
245
+ >>> x, y = transformer.transform(longitude, latitude)
246
+ """
247
+ return Transformer.from_crs(from_crs, to_crs, always_xy=True)
248
+
249
+ def transform_coords(transformer, lon, lat):
250
+ """
251
+ Transform coordinates using provided transformer with error handling.
252
+ Includes validation for infinite values that may result from invalid transformations
253
+ or coordinates outside the valid range for the target CRS.
254
+
255
+ Args:
256
+ transformer (Transformer): Coordinate transformer from setup_transformer()
257
+ lon, lat (float): Input coordinates in the source CRS
258
+
259
+ Returns:
260
+ tuple: (x, y) transformed coordinates in the target CRS, or (None, None) if transformation fails
261
+
262
+ Example:
263
+ >>> transformer = setup_transformer("EPSG:4326", "EPSG:3857")
264
+ >>> x, y = transform_coords(transformer, -74.0060, 40.7128) # NYC coordinates
265
+ >>> if x is not None:
266
+ ... print(f"Transformed coordinates: ({x}, {y})")
267
+ """
268
+ try:
269
+ x, y = transformer.transform(lon, lat)
270
+ if np.isinf(x) or np.isinf(y):
271
+ logger.warning("Transformation resulted in inf values for coordinates: %s, %s", lon, lat)
272
+ return x, y
273
+ except Exception as e:
274
+ logger.error("Error transforming coordinates %s, %s: %s", lon, lat, e)
275
+ return None, None
276
+
277
+ def create_polygon(vertices):
278
+ """
279
+ Create a Shapely polygon from a list of vertices.
280
+ Input vertices must be in (longitude, latitude) format as required by Shapely.
281
+ The polygon will be automatically closed if the first and last vertices don't match.
282
+
283
+ Args:
284
+ vertices (list): List of (longitude, latitude) coordinate pairs forming the polygon.
285
+ The coordinates should be in counter-clockwise order for exterior rings
286
+ and clockwise order for interior rings (holes).
287
+
288
+ Returns:
289
+ Polygon: Shapely polygon object that can be used for spatial operations
290
+
291
+ Example:
292
+ >>> vertices = [(0, 0), (1, 0), (1, 1), (0, 1)] # Square
293
+ >>> polygon = create_polygon(vertices)
294
+ >>> print(f"Polygon area: {polygon.area}")
295
+ """
296
+ return Polygon(vertices)
297
+
298
+ def create_geodataframe(polygon, crs=4326):
299
+ """
300
+ Create a GeoDataFrame from a Shapely polygon.
301
+ Default CRS is WGS84 (EPSG:4326) for geographic coordinates.
302
+ The GeoDataFrame provides additional functionality for spatial operations,
303
+ data analysis, and export to various geographic formats.
304
+
305
+ Args:
306
+ polygon (Polygon): Shapely polygon object to convert
307
+ crs (int): Coordinate reference system EPSG code (default: 4326 for WGS84)
308
+
309
+ Returns:
310
+ GeoDataFrame: GeoDataFrame containing the polygon with specified CRS
311
+
312
+ Example:
313
+ >>> vertices = [(0, 0), (1, 0), (1, 1), (0, 1)]
314
+ >>> polygon = create_polygon(vertices)
315
+ >>> gdf = create_geodataframe(polygon)
316
+ >>> gdf.to_file("polygon.geojson", driver="GeoJSON")
317
+ """
318
+ return gpd.GeoDataFrame({'geometry': [polygon]}, crs=from_epsg(crs))
319
+
320
+ def haversine_distance(lon1, lat1, lon2, lat2):
321
+ """
322
+ Calculate great-circle distance between two points using Haversine formula.
323
+ This is an approximation that treats the Earth as a perfect sphere.
324
+
325
+ Args:
326
+ lon1, lat1 (float): Coordinates of first point
327
+ lon2, lat2 (float): Coordinates of second point
328
+
329
+ Returns:
330
+ float: Distance in kilometers
331
+ """
332
+ R = 6371 # Earth's radius in kilometers
333
+
334
+ # Convert all coordinates to radians
335
+ lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
336
+
337
+ # Calculate differences
338
+ dlat = lat2 - lat1
339
+ dlon = lon2 - lon1
340
+
341
+ # Haversine formula
342
+ a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
343
+ c = 2 * atan2(sqrt(a), sqrt(1-a))
344
+ return R * c
345
+
346
+ def get_raster_bbox(raster_path):
347
+ """
348
+ Get the bounding box of a raster file in its native coordinate system.
349
+ Returns a rectangular polygon representing the spatial extent of the raster,
350
+ which can be used for spatial queries and intersection tests.
351
+
352
+ Args:
353
+ raster_path (str): Path to the raster file (GeoTIFF, IMG, etc.)
354
+
355
+ Returns:
356
+ box: Shapely box representing the raster bounds in the raster's CRS
357
+
358
+ Example:
359
+ >>> bbox = get_raster_bbox("elevation.tif")
360
+ >>> print(f"Raster extent: {bbox.bounds}") # (minx, miny, maxx, maxy)
361
+ """
362
+ with rasterio.open(raster_path) as src:
363
+ bounds = src.bounds
364
+ return box(bounds.left, bounds.bottom, bounds.right, bounds.top)
365
+
366
+ def raster_intersects_polygon(raster_path, polygon):
367
+ """
368
+ Check if a raster file's extent intersects with a given polygon.
369
+ Automatically handles coordinate system transformations by converting
370
+ the raster bounds to WGS84 (EPSG:4326) if needed before the intersection test.
371
+
372
+ Args:
373
+ raster_path (str): Path to the raster file to check
374
+ polygon (Polygon): Shapely polygon to test intersection with (in WGS84)
375
+
376
+ Returns:
377
+ bool: True if raster intersects or contains the polygon, False otherwise
378
+
379
+ Example:
380
+ >>> aoi = create_polygon([(lon1, lat1), (lon2, lat2), ...]) # Area of interest
381
+ >>> if raster_intersects_polygon("dem.tif", aoi):
382
+ ... print("Raster covers the area of interest")
383
+ """
384
+ with rasterio.open(raster_path) as src:
385
+ bounds = src.bounds
386
+ # Transform bounds to WGS84 if raster is in different CRS
387
+ if src.crs.to_epsg() != 4326:
388
+ bounds = transform_bounds(src.crs, 'EPSG:4326', *bounds)
389
+ raster_bbox = box(*bounds)
390
+ intersects = raster_bbox.intersects(polygon) or polygon.intersects(raster_bbox)
391
+ return intersects
392
+
393
+ def save_raster(input_path, output_path):
394
+ """
395
+ Create a copy of a raster file at a new location.
396
+ Performs a direct file copy without any transformation or modification,
397
+ preserving all metadata, georeferencing, and pixel values.
398
+
399
+ Args:
400
+ input_path (str): Source raster file path
401
+ output_path (str): Destination path for the copied raster
402
+
403
+ Example:
404
+ >>> save_raster("original.tif", "backup/copy.tif")
405
+ >>> print("Copied original file to: backup/copy.tif")
406
+ """
407
+ import shutil
408
+ shutil.copy(input_path, output_path)
409
+ logger.info("Copied original file to: %s", output_path)
410
+
411
+ def merge_geotiffs(geotiff_files, output_dir):
412
+ """
413
+ Merge multiple GeoTIFF files into a single mosaic.
414
+ Handles edge matching and overlapping areas between adjacent rasters.
415
+ The output will have the same coordinate system and data type as the input files.
416
+
417
+ Important considerations:
418
+ - All input files should have the same coordinate system
419
+ - All input files should have the same data type
420
+ - Overlapping areas are handled by taking the first value encountered
421
+
422
+ Args:
423
+ geotiff_files (list): List of paths to GeoTIFF files to merge
424
+ output_dir (str): Directory where the merged output will be saved
425
+
426
+ Example:
427
+ >>> files = ["tile1.tif", "tile2.tif", "tile3.tif"]
428
+ >>> merge_geotiffs(files, "output_directory")
429
+ >>> print("Merged output saved to: output_directory/lulc.tif")
430
+ """
431
+ if not geotiff_files:
432
+ return
433
+
434
+ # Open all valid GeoTIFF files
435
+ src_files_to_mosaic = [rasterio.open(file) for file in geotiff_files if os.path.exists(file)]
436
+
437
+ if src_files_to_mosaic:
438
+ try:
439
+ # Merge rasters into a single mosaic and get output transform
440
+ mosaic, out_trans = merge(src_files_to_mosaic)
441
+
442
+ # Copy metadata from first raster and update for merged output
443
+ out_meta = src_files_to_mosaic[0].meta.copy()
444
+ out_meta.update({
445
+ "driver": "GTiff",
446
+ "height": mosaic.shape[1],
447
+ "width": mosaic.shape[2],
448
+ "transform": out_trans
449
+ })
450
+
451
+ # Save merged raster to output file
452
+ merged_path = os.path.join(output_dir, "lulc.tif")
453
+ with rasterio.open(merged_path, "w", **out_meta) as dest:
454
+ dest.write(mosaic)
455
+
456
+ logger.info("Merged output saved to: %s", merged_path)
457
+ except Exception as e:
458
+ logger.error("Error merging files: %s", e)
459
+ else:
460
+ logger.info("No valid files to merge.")
461
+
462
+ # Clean up by closing all opened files
463
+ for src in src_files_to_mosaic:
464
+ src.close()
465
+
466
+ def convert_format_lat_lon(input_coords):
467
+ """
468
+ Convert coordinate format and close polygon.
469
+ Input coordinates are already in [lon, lat] format.
470
+
471
+ Args:
472
+ input_coords (list): List of [lon, lat] coordinates
473
+
474
+ Returns:
475
+ list: List of [lon, lat] coordinates with first point repeated at end
476
+ """
477
+ # Create list with coordinates in same order
478
+ output_coords = input_coords.copy()
479
+ # Close polygon by repeating first point at end
480
+ output_coords.append(output_coords[0])
481
+ return output_coords
482
+
483
+ def get_coordinates_from_cityname(place_name):
484
+ """
485
+ Geocode a city name to get its coordinates using OpenStreetMap's Nominatim service.
486
+ Includes rate limiting and error handling to comply with Nominatim's usage policy.
487
+
488
+ Note:
489
+ - Results may vary based on the specificity of the place name
490
+ - For better results, include country or state information
491
+ - Service has usage limits and may timeout
492
+
493
+ Args:
494
+ place_name (str): Name of the city to geocode (e.g., "Tokyo, Japan")
495
+
496
+ Returns:
497
+ tuple: (latitude, longitude) coordinates or None if geocoding fails
498
+
499
+ Example:
500
+ >>> coords = get_coordinates_from_cityname("Paris, France")
501
+ >>> if coords:
502
+ ... lat, lon = coords
503
+ ... print(f"Paris coordinates: {lat}, {lon}")
504
+ """
505
+ # Initialize geocoder with compliant user agent
506
+ geolocator = _create_nominatim_geolocator()
507
+ geocode_once = RateLimiter(geolocator.geocode, min_delay_seconds=1.0, max_retries=0)
508
+
509
+ try:
510
+ # Attempt to geocode the place name (single try; no retries on 403)
511
+ location = geocode_once(place_name, exactly_one=True, timeout=10)
512
+
513
+ if location:
514
+ return (location.latitude, location.longitude)
515
+ else:
516
+ return None
517
+ except GeocoderInsufficientPrivileges:
518
+ logger.warning("Nominatim blocked the request (HTTP 403). Please set a proper user agent and avoid bulk requests.")
519
+ return None
520
+ except (GeocoderTimedOut, GeocoderServiceError):
521
+ logger.error("Geocoding service timed out or encountered an error for %s", place_name)
522
+ return None
523
+
524
+ def get_city_country_name_from_rectangle(coordinates):
525
+ """
526
+ Get the city and country name for a location defined by a rectangle.
527
+ Uses reverse geocoding to find the nearest named place to the rectangle's center.
528
+
529
+ The function:
530
+ 1. Calculates the center point of the rectangle
531
+ 2. Performs reverse geocoding with rate limiting
532
+ 3. Extracts city and country information from the result
533
+
534
+ Args:
535
+ coordinates (list): List of (longitude, latitude) coordinates defining the rectangle
536
+
537
+ Returns:
538
+ str: String in format "city/ country" or fallback value if lookup fails
539
+
540
+ Example:
541
+ >>> coords = [(139.65, 35.67), (139.66, 35.67),
542
+ ... (139.66, 35.68), (139.65, 35.68)]
543
+ >>> location = get_city_country_name_from_rectangle(coords)
544
+ >>> print(f"Location: {location}") # e.g., "Shibuya/ Japan"
545
+ """
546
+ # Calculate center point of rectangle
547
+ longitudes = [coord[0] for coord in coordinates]
548
+ latitudes = [coord[1] for coord in coordinates]
549
+ center_lon = sum(longitudes) / len(longitudes)
550
+ center_lat = sum(latitudes) / len(latitudes)
551
+ center_coord = (center_lat, center_lon)
552
+
553
+ # Initialize geocoder with compliant user agent and conservative rate limit (1 req/sec)
554
+ geolocator = _create_nominatim_geolocator()
555
+ reverse_once = RateLimiter(geolocator.reverse, min_delay_seconds=1.0, max_retries=0)
556
+
557
+ try:
558
+ # Attempt reverse geocoding of center coordinates (single try; no retries on 403)
559
+ location = reverse_once(center_coord, language='en', exactly_one=True, timeout=10)
560
+ if location:
561
+ address = location.raw['address']
562
+ # Try multiple address fields to find city name, falling back to county if needed
563
+ city = address.get('city', '') or address.get('town', '') or address.get('village', '') or address.get('county', '')
564
+ country = address.get('country', '')
565
+ return f"{city}/ {country}"
566
+ else:
567
+ logger.info("Reverse geocoding location not found for %s", center_coord)
568
+ return "Unknown Location/ Unknown Country"
569
+ except GeocoderInsufficientPrivileges:
570
+ # Fallback to offline reverse_geocoder at coarse resolution
571
+ try:
572
+ results = rg.search((center_lat, center_lon))
573
+ name = results[0].get('name') or ''
574
+ country = get_country_name(center_lon, center_lat) or ''
575
+ if name or country:
576
+ return f"{name}/ {country}".strip()
577
+ except Exception:
578
+ pass
579
+ logger.warning("Nominatim blocked the request (HTTP 403). Falling back to offline coarse reverse geocoding.")
580
+ return "Unknown Location/ Unknown Country"
581
+ except (GeocoderTimedOut, GeocoderServiceError) as e:
582
+ logger.error("Error retrieving location for %s: %s", center_coord, e)
583
+ return "Unknown Location/ Unknown Country"
584
+
585
+ def get_timezone_info(rectangle_coords):
586
+ """
587
+ Get timezone and central meridian information for a location.
588
+ Uses the rectangle's center point to determine the local timezone and
589
+ calculates the central meridian based on the UTC offset.
590
+
591
+ The function provides:
592
+ 1. Local timezone identifier (e.g., "America/New_York")
593
+ 2. UTC offset (e.g., "UTC-04:00")
594
+ 3. Central meridian longitude for the timezone
595
+
596
+ Args:
597
+ rectangle_coords (list): List of (longitude, latitude) coordinates defining the area
598
+
599
+ Returns:
600
+ tuple: (timezone string with UTC offset, central meridian longitude string)
601
+
602
+ Example:
603
+ >>> coords = [(139.65, 35.67), (139.66, 35.67),
604
+ ... (139.66, 35.68), (139.65, 35.68)]
605
+ >>> tz, meridian = get_timezone_info(coords)
606
+ >>> print(f"Timezone: {tz}, Meridian: {meridian}") # e.g., "UTC+09:00, 135.00000"
607
+ """
608
+ # Calculate center point of rectangle
609
+ longitudes = [coord[0] for coord in rectangle_coords]
610
+ latitudes = [coord[1] for coord in rectangle_coords]
611
+ center_lon = sum(longitudes) / len(longitudes)
612
+ center_lat = sum(latitudes) / len(latitudes)
613
+
614
+ # Find timezone at center coordinates
615
+ tf = TimezoneFinder()
616
+ timezone_str = tf.timezone_at(lng=center_lon, lat=center_lat)
617
+
618
+ if timezone_str:
619
+ # Get current time in local timezone to calculate offset
620
+ timezone = pytz.timezone(timezone_str)
621
+ now = datetime.now(timezone)
622
+ offset_seconds = now.utcoffset().total_seconds()
623
+ offset_hours = offset_seconds / 3600
624
+
625
+ # Format timezone offset and calculate central meridian
626
+ utc_offset = f"UTC{offset_hours:+.2f}"
627
+ timezone_longitude = offset_hours * 15 # Each hour offset = 15 degrees longitude
628
+ timezone_longitude_str = f"{timezone_longitude:.5f}"
629
+
630
+ return utc_offset, timezone_longitude_str
631
+ else:
632
+ # Return fallback values if timezone cannot be determined
633
+ logger.warning("Timezone not found for the given location, using UTC+00:00")
634
+ return "UTC+00:00", "0.00000"
635
+
636
+ def validate_polygon_coordinates(geometry):
637
+ """
638
+ Validate and ensure proper closure of polygon coordinate rings.
639
+ Performs validation and correction of GeoJSON polygon geometries according to
640
+ the GeoJSON specification requirements.
641
+
642
+ Validation checks:
643
+ 1. Geometry type (Polygon or MultiPolygon)
644
+ 2. Ring closure (first point equals last point)
645
+ 3. Minimum number of points (4, including closure)
646
+
647
+ Args:
648
+ geometry (dict): GeoJSON geometry object with 'type' and 'coordinates' properties
649
+
650
+ Returns:
651
+ bool: True if polygon coordinates are valid or were successfully corrected,
652
+ False if validation failed
653
+
654
+ Example:
655
+ >>> geom = {
656
+ ... "type": "Polygon",
657
+ ... "coordinates": [[[0,0], [1,0], [1,1], [0,1]]] # Not closed
658
+ ... }
659
+ >>> if validate_polygon_coordinates(geom):
660
+ ... print("Polygon is valid") # Will close the ring automatically
661
+ """
662
+ if geometry['type'] == 'Polygon':
663
+ for ring in geometry['coordinates']:
664
+ # Ensure polygon is closed by checking/adding first point at end
665
+ if ring[0] != ring[-1]:
666
+ ring.append(ring[0]) # Close the ring
667
+ # Check minimum points needed for valid polygon (3 points + closing point)
668
+ if len(ring) < 4:
669
+ return False
670
+ return True
671
+ elif geometry['type'] == 'MultiPolygon':
672
+ for polygon in geometry['coordinates']:
673
+ for ring in polygon:
674
+ if ring[0] != ring[-1]:
675
+ ring.append(ring[0]) # Close the ring
676
+ if len(ring) < 4:
677
+ return False
678
+ return True
679
+ else:
680
+ return False
681
+
682
+ def create_building_polygons(filtered_buildings):
683
+ """
684
+ Create building polygons with properties from filtered GeoJSON features.
685
+ Processes a list of GeoJSON building features to create Shapely polygons
686
+ with associated height and other properties, while also building a spatial index.
687
+
688
+ Processing steps:
689
+ 1. Extract and validate coordinates
690
+ 2. Create Shapely polygons
691
+ 3. Process building properties (height, levels, etc.)
692
+ 4. Build spatial index for efficient querying
693
+
694
+ Height calculation rules:
695
+ - Use explicit height if available
696
+ - Calculate from levels * floor_height if height not available
697
+ - Calculate from floors * floor_height if levels not available
698
+ - Use NaN if no height information available
699
+
700
+ Args:
701
+ filtered_buildings (list): List of GeoJSON building features with properties
702
+
703
+ Returns:
704
+ tuple: (
705
+ list of tuples (polygon, height, min_height, is_inner, feature_id),
706
+ rtree spatial index for the polygons
707
+ )
708
+
709
+ Example:
710
+ >>> buildings = [
711
+ ... {
712
+ ... "type": "Feature",
713
+ ... "geometry": {"type": "Polygon", "coordinates": [...]},
714
+ ... "properties": {"height": 30, "levels": 10}
715
+ ... },
716
+ ... # ... more buildings ...
717
+ ... ]
718
+ >>> polygons, spatial_idx = create_building_polygons(buildings)
719
+ """
720
+ building_polygons = []
721
+ idx = index.Index()
722
+ valid_count = 0
723
+ count = 0
724
+
725
+ # Find highest existing ID to avoid duplicates
726
+ id_list = []
727
+ for i, building in enumerate(filtered_buildings):
728
+ if building['properties'].get('id') is not None:
729
+ id_list.append(building['properties']['id'])
730
+ if len(id_list) > 0:
731
+ id_count = max(id_list)+1
732
+ else:
733
+ id_count = 1
734
+
735
+ for building in filtered_buildings:
736
+ try:
737
+ # Handle potential nested coordinate tuples
738
+ coords = building['geometry']['coordinates'][0]
739
+ # Flatten coordinates if they're nested tuples
740
+ if isinstance(coords[0], tuple):
741
+ coords = [list(c) for c in coords]
742
+ elif isinstance(coords[0][0], tuple):
743
+ coords = [list(c[0]) for c in coords]
744
+
745
+ # Create polygon from coordinates
746
+ polygon = Polygon(coords)
747
+
748
+ # Skip invalid geometries
749
+ if not polygon.is_valid:
750
+ logger.warning("Skipping invalid polygon geometry")
751
+ continue
752
+
753
+ height = building['properties'].get('height')
754
+ levels = building['properties'].get('levels')
755
+ floors = building['properties'].get('num_floors')
756
+ min_height = building['properties'].get('min_height')
757
+ min_level = building['properties'].get('min_level')
758
+ min_floor = building['properties'].get('min_floor')
759
+
760
+ if (height is None) or (height<=0):
761
+ if levels is not None:
762
+ height = floor_height * levels
763
+ elif floors is not None:
764
+ height = floor_height * floors
765
+ else:
766
+ count += 1
767
+ height = np.nan
768
+
769
+ if (min_height is None) or (min_height<=0):
770
+ if min_level is not None:
771
+ min_height = floor_height * float(min_level)
772
+ elif min_floor is not None:
773
+ min_height = floor_height * float(min_floor)
774
+ else:
775
+ min_height = 0
776
+
777
+ if building['properties'].get('id') is not None:
778
+ feature_id = building['properties']['id']
779
+ else:
780
+ feature_id = id_count
781
+ id_count += 1
782
+
783
+ if building['properties'].get('is_inner') is not None:
784
+ is_inner = building['properties']['is_inner']
785
+ else:
786
+ is_inner = False
787
+
788
+ building_polygons.append((polygon, height, min_height, is_inner, feature_id))
789
+ idx.insert(valid_count, polygon.bounds)
790
+ valid_count += 1
791
+
792
+ except Exception as e:
793
+ logger.warning("Skipping invalid building geometry: %s", e)
794
+ continue
795
+
796
+ return building_polygons, idx
797
+
798
+ def get_country_name(lon, lat):
799
+ """
800
+ Get country name from coordinates using reverse geocoding.
801
+ Uses a local database for fast reverse geocoding to country level,
802
+ then converts the country code to full name using pycountry.
803
+
804
+ Args:
805
+ lon (float): Longitude in decimal degrees
806
+ lat (float): Latitude in decimal degrees
807
+
808
+ Returns:
809
+ str: Full country name or None if lookup fails
810
+
811
+ Example:
812
+ >>> country = get_country_name(139.6503, 35.6762)
813
+ >>> print(f"Country: {country}") # "Japan"
814
+ """
815
+ # Use reverse geocoder to get country code
816
+ results = rg.search((lat, lon))
817
+ country_code = results[0]['cc']
818
+
819
+ # Convert country code to full name using pycountry
820
+ country = pycountry.countries.get(alpha_2=country_code)
821
+
822
+ if country:
823
+ return country.name
824
+ else:
821
825
  return None