2025-02-26 10:53:07 +00:00
import argparse
2025-03-04 16:18:41 +00:00
import json
2025-02-26 10:53:07 +00:00
import duckdb
import geohash
import geopandas as gpd
import pandas as pd
from pathlib import Path
from shapely import wkb
2025-03-04 15:26:36 +00:00
from shapely . geometry import box
2025-03-04 16:17:56 +00:00
from tqdm import tqdm # For progress bar
from typing import List , Dict , Optional
2025-02-26 10:53:07 +00:00
2025-03-04 15:26:36 +00:00
# Configure logging
import logging
logging . basicConfig ( format = " %(levelname)s : %(message)s " , level = logging . INFO )
logger = logging . getLogger ( __name__ )
2025-02-26 10:53:07 +00:00
def get_geohashes_from_bbox ( min_x , min_y , max_x , max_y ) - > List [ str ] :
""" Generates all 4-character Geohashes that intersect a bounding box. """
geohashes = set ( )
step_size = 0.25 # 4-character Geohash grid size
lat = min_y
while lat < = max_y :
lon = min_x
while lon < = max_x :
gh = geohash . encode ( lat , lon , precision = 4 )
geohashes . add ( gh )
lon + = step_size
lat + = step_size
return sorted ( geohashes )
2025-03-04 16:17:56 +00:00
def append_to_parquet ( file_path : Path , new_data : gpd . GeoDataFrame , add_water : bool ) :
""" Appends new data to an existing Parquet file while ensuring correct merging and avoiding duplicates. """
2025-02-26 10:53:07 +00:00
if file_path . exists ( ) :
try :
existing_data = gpd . read_parquet ( file_path )
2025-03-04 16:17:56 +00:00
# Remove sea polygons to avoid duplicates (only if we're adding water polygons)
if add_water :
existing_data = existing_data [ ~ existing_data [ " tags " ] . apply ( lambda tags : isinstance ( tags , dict ) and tags . get ( " natural " ) == " water " ) ]
# Ensure no duplicates based on feature_id (for land geometries)
if " feature_id " in existing_data . columns and " feature_id " in new_data . columns :
new_unique_data = new_data [ ~ new_data [ " feature_id " ] . isin ( existing_data [ " feature_id " ] ) ]
combined_data = pd . concat ( [ existing_data , new_unique_data ] , ignore_index = True )
else :
combined_data = pd . concat ( [ existing_data , new_data ] , ignore_index = True )
2025-02-26 10:53:07 +00:00
combined_data . to_parquet ( file_path , index = False )
except Exception as e :
2025-03-04 16:17:56 +00:00
logger . warning ( f " Error while merging { file_path } : { e } " )
2025-02-26 10:53:07 +00:00
else :
new_data . to_parquet ( file_path , index = False )
2025-02-27 10:33:19 +00:00
def save_as_geojson ( geohash_code : str , gdf : gpd . GeoDataFrame , output_path : Path ) :
""" Saves a GeoDataFrame as a GeoJSON file. """
geojson_file = output_path / f " { geohash_code } .geojson "
2025-03-04 16:17:56 +00:00
gdf . to_file ( geojson_file , driver = " GeoJSON " )
2025-03-04 15:26:36 +00:00
logger . info ( f " ✅ Saved GeoJSON: { geojson_file } " )
2025-02-27 10:33:19 +00:00
def save_as_cbor ( geohash_code : str , gdf : gpd . GeoDataFrame , output_path : Path ) :
""" Saves a GeoDataFrame as a CBOR file (compact binary format). """
2025-03-04 16:17:56 +00:00
import cbor2
2025-02-27 10:33:19 +00:00
cbor_file = output_path / f " { geohash_code } .cbor "
geojson_dict = json . loads ( gdf . to_json ( ) )
with open ( cbor_file , " wb " ) as f :
cbor2 . dump ( geojson_dict , f )
2025-03-04 15:26:36 +00:00
logger . info ( f " ✅ Saved CBOR: { cbor_file } " )
def geohash_bbox ( geohash_code ) :
""" Returns a bounding box polygon for a geohash. """
bbox = geohash . bbox ( geohash_code )
return box ( bbox [ " w " ] , bbox [ " s " ] , bbox [ " e " ] , bbox [ " n " ] )
def ensure_crs_consistency ( gdf ) :
""" Ensures the GeoDataFrame is in EPSG:4326 to prevent CRS mismatches. """
if gdf . crs is None or gdf . crs . to_string ( ) != " EPSG:4326 " :
gdf = gdf . to_crs ( " EPSG:4326 " )
return gdf
def add_water_to_geohash ( geohash_code : str , geohash_file : Path , water_gdf : gpd . GeoDataFrame ) :
""" Adds water polygons to a geohash tile. """
try :
existing_gdf = gpd . read_parquet ( geohash_file )
except Exception as e :
logger . warning ( f " Failed to read { geohash_file } . Skipping. Error: { e } " )
2025-03-04 16:17:56 +00:00
return False
2025-03-04 15:26:36 +00:00
existing_gdf = ensure_crs_consistency ( existing_gdf )
# Get the geohash bounding box
bbox = geohash_bbox ( geohash_code )
2025-02-27 10:33:19 +00:00
2025-03-04 15:26:36 +00:00
# Extract water polygons that overlap with this geohash
water_in_tile = water_gdf [ water_gdf . intersects ( bbox ) ] . copy ( )
if water_in_tile . empty :
2025-03-04 16:17:56 +00:00
return False # No water polygons added
2025-03-04 15:26:36 +00:00
# Clip water polygons to the geohash tile boundary
water_in_tile [ " geometry " ] = water_in_tile . intersection ( bbox )
2025-03-04 16:17:56 +00:00
# Assign "water" metadata and a unique identifier for water polygons
water_in_tile [ " feature_id " ] = [ f " water_ { geohash_code } _ { i } " for i in range ( len ( water_in_tile ) ) ]
2025-03-04 15:26:36 +00:00
water_in_tile [ " tags " ] = [ { " natural " : " water " , " water " : " sea " } ] * len ( water_in_tile )
# Ensure columns match before merging
for col in [ " feature_id " , " tags " ] :
if col not in existing_gdf . columns :
existing_gdf [ col ] = None
if col not in water_in_tile . columns :
water_in_tile [ col ] = None
# Merge updated water polygons into geohash file
updated_gdf = gpd . GeoDataFrame ( pd . concat ( [ existing_gdf , water_in_tile ] , ignore_index = True ) , crs = " EPSG:4326 " )
# Save back to parquet
updated_gdf . to_parquet ( geohash_file , index = False )
2025-03-04 16:17:56 +00:00
return True # Water polygons added
2025-03-04 15:26:36 +00:00
2025-03-04 16:17:56 +00:00
def slice_and_split_geoparquet ( input_file : str , output_dir : str , water_file : Optional [ str ] , export_parquet : bool , export_geojson : bool , export_cbor : bool ) :
""" Splits the GeoParquet file into multiple files based on 4-character Geohash tiles and optionally adds water polygons. """
input_path , output_path = Path ( input_file ) , Path ( output_dir )
2025-02-26 10:53:07 +00:00
if not input_path . exists ( ) :
raise FileNotFoundError ( f " File ' { input_file } ' not found. " )
output_path . mkdir ( parents = True , exist_ok = True )
2025-03-04 16:17:56 +00:00
# Load water dataset if provided
water_gdf = None
if water_file :
water_path = Path ( water_file )
if not water_path . exists ( ) :
raise FileNotFoundError ( f " File ' { water_file } ' not found. " )
water_gdf = ensure_crs_consistency ( gpd . read_parquet ( water_path ) )
2025-02-26 10:53:07 +00:00
2025-03-04 15:15:11 +00:00
# Connect to DuckDB and load spatial extension
2025-02-26 10:53:07 +00:00
con = duckdb . connect ( )
con . execute ( " INSTALL spatial; LOAD spatial; " )
2025-03-04 15:15:11 +00:00
# Load the GeoParquet file into DuckDB
2025-02-26 10:53:07 +00:00
con . execute ( f " CREATE TEMP TABLE geoparquet AS SELECT * FROM read_parquet( ' { input_path } ' ) WHERE ST_GeometryType(geometry) IS NOT NULL AND ST_GeometryType(geometry) != ' POINT ' " )
2025-03-04 15:15:11 +00:00
# Precompute bounding boxes for all features
2025-02-26 10:53:07 +00:00
bbox_results = con . execute ( """
SELECT feature_id , rowid , ST_XMin ( ST_Envelope ( geometry ) ) , ST_YMin ( ST_Envelope ( geometry ) ) ,
ST_XMax ( ST_Envelope ( geometry ) ) , ST_YMax ( ST_Envelope ( geometry ) )
FROM geoparquet ;
""" ).fetchall()
2025-03-04 15:15:11 +00:00
# Map geohash codes to rowids
2025-02-26 10:53:07 +00:00
geohash_mapping : Dict [ str , List [ int ] ] = { }
for feature_id , rowid , min_x , min_y , max_x , max_y in bbox_results :
intersecting_geohashes = get_geohashes_from_bbox ( min_x , min_y , max_x , max_y )
for geohash_code in intersecting_geohashes :
if geohash_code not in geohash_mapping :
geohash_mapping [ geohash_code ] = [ ]
geohash_mapping [ geohash_code ] . append ( rowid )
2025-03-04 15:15:11 +00:00
# Process each geohash tile
2025-03-04 16:17:56 +00:00
total_tiles = len ( geohash_mapping )
tiles_with_water = 0
with tqdm ( total = total_tiles , desc = " Processing geohash tiles " ) as pbar :
for geohash_code , rowids in geohash_mapping . items ( ) :
geohash_bbox = geohash . bbox ( geohash_code )
# Fetch and clip geometries for this geohash tile
filtered_data = con . execute ( f """
SELECT feature_id , tags ,
ST_AsWKB ( ST_Intersection ( geometry ,
ST_MakeEnvelope ( { geohash_bbox [ " w " ] } , { geohash_bbox [ " s " ] } ,
{ geohash_bbox [ " e " ] } , { geohash_bbox [ " n " ] } ) ) )
AS clipped_geom
FROM geoparquet
WHERE rowid IN ( { ' , ' . join ( map ( str , rowids ) ) } )
AND ST_Intersects ( geometry ,
ST_MakeEnvelope ( { geohash_bbox [ " w " ] } , { geohash_bbox [ " s " ] } ,
{ geohash_bbox [ " e " ] } , { geohash_bbox [ " n " ] } ) ) ;
""" ).fetchdf()
2025-02-26 10:53:07 +00:00
if not filtered_data . empty :
2025-03-04 16:17:56 +00:00
# Convert WKB geometries to Shapely geometries
filtered_data [ " geometry " ] = filtered_data [ " clipped_geom " ] . apply (
lambda x : wkb . loads ( bytes ( x ) ) if isinstance ( x , ( bytes , bytearray ) ) else None
)
filtered_data . drop ( columns = [ " clipped_geom " ] , inplace = True )
# Drop rows with invalid geometries
filtered_data = filtered_data . dropna ( subset = [ " geometry " ] )
if not filtered_data . empty :
gdf = gpd . GeoDataFrame ( filtered_data , geometry = " geometry " , crs = " EPSG:4326 " )
# Export to GeoParquet
if export_parquet :
geohash_file = output_path / f " { geohash_code } .parquet "
append_to_parquet ( geohash_file , gdf , add_water = ( water_gdf is not None ) )
if water_gdf is not None and add_water_to_geohash ( geohash_code , geohash_file , water_gdf ) :
tiles_with_water + = 1
# Export to GeoJSON
if export_geojson :
save_as_geojson ( geohash_code , gdf , output_path )
# Export to CBOR
if export_cbor :
save_as_cbor ( geohash_code , gdf , output_path )
pbar . update ( 1 )
2025-02-26 10:53:07 +00:00
con . close ( )
2025-03-04 16:17:56 +00:00
print ( f " 🎉 Processing complete! Generated { total_tiles } tiles, { tiles_with_water } of which had water polygons added. " )
2025-02-26 10:53:07 +00:00
def main ( ) :
2025-03-04 16:17:56 +00:00
parser = argparse . ArgumentParser ( description = " Split a GeoParquet file into Geohash tiles, optionally add water polygons, and export in desired formats. " )
2025-02-26 10:53:07 +00:00
parser . add_argument ( " -i " , " --input " , required = True , help = " Path to the input GeoParquet file. " )
2025-02-27 10:33:19 +00:00
parser . add_argument ( " -o " , " --output " , required = True , help = " Directory to save output files. " )
2025-03-04 16:17:56 +00:00
parser . add_argument ( " -w " , " --water " , required = False , help = " Path to the water.parquet file. " )
2025-02-27 10:33:19 +00:00
parser . add_argument ( " --parquet " , action = " store_true " , help = " Export as GeoParquet " )
parser . add_argument ( " --geojson " , action = " store_true " , help = " Export as GeoJSON " )
parser . add_argument ( " --cbor " , action = " store_true " , help = " Export as CBOR " )
2025-02-26 10:53:07 +00:00
args = parser . parse_args ( )
2025-02-27 10:33:19 +00:00
if not ( args . parquet or args . geojson or args . cbor ) :
args . parquet = True # Default to GeoParquet if no options are given
2025-02-26 10:53:07 +00:00
try :
2025-03-04 15:26:36 +00:00
slice_and_split_geoparquet ( args . input , args . output , args . water , args . parquet , args . geojson , args . cbor )
2025-02-26 10:53:07 +00:00
except Exception as e :
2025-03-04 15:26:36 +00:00
logger . error ( f " ❌ Error: { e } " )
2025-02-26 10:53:07 +00:00
if __name__ == " __main__ " :
2025-03-04 15:15:11 +00:00
main ( )