2025-02-26 10:53:07 +00:00
import argparse
import duckdb
import geohash
import geopandas as gpd
import pandas as pd
import pyarrow . parquet as pq
import pyarrow as pa
from pathlib import Path
from shapely import wkb
from typing import List , Dict
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 )
def append_to_parquet ( file_path : Path , new_data : gpd . GeoDataFrame ) :
""" Appends new data to an existing Parquet file while ensuring correct merging. """
if file_path . exists ( ) :
try :
# ✅ Load existing data
existing_data = gpd . read_parquet ( file_path )
# ✅ Merge with new data
combined_data = pd . concat ( [ existing_data , new_data ] , ignore_index = True )
# ✅ Drop duplicates *only if feature_id exists*
if " feature_id " in combined_data . columns :
combined_data = combined_data . drop_duplicates ( subset = " feature_id " , keep = " last " )
# ✅ Reset index before saving
combined_data = combined_data . reset_index ( drop = True )
# ✅ Save back to the same file
combined_data . to_parquet ( file_path , index = False )
print ( f " ✅ Appended new data to { file_path } " )
except Exception as e :
print ( f " ❌ Error while merging { file_path } : { e } " )
else :
# If the file doesn't exist, create it
new_data . to_parquet ( file_path , index = False )
print ( f " ✅ Created new file: { file_path } " )
def slice_and_split_geoparquet ( input_file : str , output_dir : str ) :
"""
Splits the GeoParquet file into multiple files based on 4 - character Geohash boxes .
Appends new data to existing Geohash Parquet files while preventing duplicate geometries .
"""
input_path , output_path = Path ( input_file ) , Path ( output_dir )
if not input_path . exists ( ) :
raise FileNotFoundError ( f " File ' { input_file } ' not found. " )
output_path . mkdir ( parents = True , exist_ok = True )
print ( f " 📂 Processing ' { input_file } ' , output will be saved in ' { output_dir } ' ... " )
con = duckdb . connect ( )
con . execute ( " INSTALL spatial; LOAD spatial; " )
# Create a temporary table of all non POINT geometries
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 ' " )
# Compute bounding box for each geometry
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()
# Compute all intersecting geohashes
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 )
# Dictionary to store in-memory results before writing
geohash_data : Dict [ str , gpd . GeoDataFrame ] = { }
for geohash_code , rowids in geohash_mapping . items ( ) :
geohash_bbox = geohash . bbox ( geohash_code )
# Clip geometries inside this geohash box
filtered_data = con . execute ( f """
2025-02-26 14:01:09 +00:00
SELECT feature_id , tags ,
2025-02-26 10:53:07 +00:00
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()
if not filtered_data . empty :
# ✅ Remove non-WKB values
filtered_data = filtered_data [ filtered_data [ " clipped_geom " ] . apply ( lambda x : isinstance ( x , ( bytes , bytearray ) ) ) ]
if not filtered_data . empty :
# ✅ Convert WKB to Shapely geometries safely
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 )
# Remove rows where geometry conversion failed
filtered_data = filtered_data . dropna ( subset = [ " geometry " ] )
if not filtered_data . empty :
# Convert to GeoPandas GeoDataFrame
gdf = gpd . GeoDataFrame ( filtered_data , geometry = " geometry " , crs = " EPSG:4326 " )
# Store in memory
geohash_data [ geohash_code ] = gdf
# Step 4: Write each geohash's data **only once** and prevent duplicates
for geohash_code , gdf in geohash_data . items ( ) :
geohash_file = output_path / f " { geohash_code } .parquet "
append_to_parquet ( geohash_file , gdf )
print ( f " ✅ Updated: { geohash_file } " )
con . close ( )
print ( " 🎉 Processing complete! " )
def main ( ) :
parser = argparse . ArgumentParser ( description = " Split a GeoParquet file into Geohash tiles. " )
parser . add_argument ( " -i " , " --input " , required = True , help = " Path to the input GeoParquet file. " )
parser . add_argument ( " -o " , " --output " , required = True , help = " Directory to save output Geohash tiles. " )
args = parser . parse_args ( )
try :
slice_and_split_geoparquet ( args . input , args . output )
except Exception as e :
print ( f " ❌ Error: { e } " )
if __name__ == " __main__ " :
main ( )