1+ """GADM (Global Administrative Areas) data provider."""
2+
3+ import os
4+ import zipfile
5+ from typing import List , Tuple , Optional
6+ from urllib .parse import urljoin
7+
8+ import requests
9+ import shapefile
10+ from shapely .geometry import Polygon
11+ from shapely .ops import unary_union
12+
13+ from .._types import BoundingBox
14+
15+
16+ class GADMProvider :
17+ """Provider for GADM administrative boundary data."""
18+
19+ GADM_BASE_URL = "https://geodata.ucdavis.edu/gadm/gadm4.1/shp/"
20+ GADM_URL_FORMAT = "gadm41_{0}_shp.zip"
21+
22+ def __init__ (self , data_dir : str = "data" ):
23+ """Initialize GADM provider.
24+
25+ Args:
26+ data_dir: Directory to store downloaded data
27+ """
28+ self .data_dir = data_dir
29+ os .makedirs (data_dir , exist_ok = True )
30+
31+ def get_country_list (self ) -> List [str ]:
32+ """Get list of available countries from GADM.
33+
34+ Returns:
35+ List of country names
36+ """
37+ try :
38+ response = requests .get ("https://gadm.org/download_country.html" , timeout = 30 )
39+ response .raise_for_status ()
40+
41+ # Extract country codes from HTML
42+ # This is a simplified version - in practice you'd parse the HTML properly
43+ countries = []
44+ for line in response .text .split ('\n ' ):
45+ if 'option value=' in line and 'selected' not in line :
46+ # Extract country name from HTML option
47+ start = line .find ('>' ) + 1
48+ end = line .find ('<' , start )
49+ if start > 0 and end > start :
50+ country = line [start :end ].strip ()
51+ if country and country != "Select country" :
52+ countries .append (country )
53+
54+ return sorted (countries )
55+
56+ except Exception as e :
57+ print (f"Warning: Could not fetch country list: { e } " )
58+ return []
59+
60+ def download_country_data (self , country_code : str ) -> str :
61+ """Download GADM shapefile data for a country.
62+
63+ Args:
64+ country_code: Three-letter country code (e.g., 'IND')
65+
66+ Returns:
67+ Path to the downloaded and extracted directory
68+ """
69+ filename = self .GADM_URL_FORMAT .format (country_code )
70+ url = urljoin (self .GADM_BASE_URL , filename )
71+
72+ local_zip_path = os .path .join (self .data_dir , filename )
73+ extract_dir = os .path .join (self .data_dir , country_code )
74+
75+ # Download if not already present
76+ if not os .path .exists (local_zip_path ):
77+ print (f"Downloading { url } ..." )
78+ self ._download_file (url , local_zip_path )
79+
80+ # Extract if not already done
81+ if not os .path .exists (extract_dir ):
82+ print (f"Extracting { filename } ..." )
83+ with zipfile .ZipFile (local_zip_path , 'r' ) as zip_ref :
84+ zip_ref .extractall (extract_dir )
85+
86+ return extract_dir
87+
88+ def load_boundaries (self , country_code : str , admin_level : int ,
89+ region_name : Optional [str ] = None ) -> Tuple [List [str ], Polygon , BoundingBox ]:
90+ """Load administrative boundaries for a country/region.
91+
92+ Args:
93+ country_code: Three-letter country code
94+ admin_level: Administrative level (1-4)
95+ region_name: Specific region name to filter by
96+
97+ Returns:
98+ Tuple of (region_names, combined_polygon, bounding_box)
99+ """
100+ extract_dir = self .download_country_data (country_code )
101+ shapefile_path = os .path .join (extract_dir , f"gadm41_{ country_code } _{ admin_level } " )
102+
103+ if not os .path .exists (f"{ shapefile_path } .shp" ):
104+ raise FileNotFoundError (f"Shapefile not found: { shapefile_path } .shp" )
105+
106+ # Load shapefile
107+ sf = shapefile .Reader (shapefile_path )
108+
109+ # Get field names to find the appropriate name field
110+ field_names = [field [0 ] for field in sf .fields [1 :]] # Skip DeletionFlag
111+ name_field = f"NAME_{ admin_level } "
112+
113+ if name_field not in field_names :
114+ # Fallback to other possible name fields
115+ for field in ["NAME_EN" , "NAME" , "NAME_1" , "NAME_2" ]:
116+ if field in field_names :
117+ name_field = field
118+ break
119+
120+ region_names = []
121+ polygons = []
122+
123+ # Process each shape record
124+ for record in sf .iterShapeRecords ():
125+ shape = record .shape
126+ rec = record .record
127+
128+ # Get region name
129+ try :
130+ name_index = field_names .index (name_field )
131+ current_name = rec [name_index ]
132+ except (ValueError , IndexError ):
133+ current_name = "Unknown"
134+
135+ # Filter by region name if specified
136+ if region_name and region_name .lower () not in current_name .lower ():
137+ continue
138+
139+ region_names .append (current_name )
140+
141+ # Convert shape to Shapely polygon
142+ if shape .shapeType == 5 : # Polygon
143+ polygons .append (Polygon (shape .points ))
144+ elif shape .shapeType == 15 : # PolygonZ
145+ polygons .append (Polygon ([(x , y ) for x , y , z in shape .points ]))
146+
147+ if not polygons :
148+ if region_name :
149+ raise ValueError (f"Region '{ region_name } ' not found in { country_code } level { admin_level } " )
150+ else :
151+ raise ValueError (f"No boundaries found for { country_code } level { admin_level } " )
152+
153+ # Combine all polygons
154+ combined_polygon = unary_union (polygons )
155+
156+ # Calculate bounding box
157+ bounds = combined_polygon .bounds
158+ bbox = BoundingBox (
159+ min_long = bounds [0 ],
160+ min_lat = bounds [1 ],
161+ max_long = bounds [2 ],
162+ max_lat = bounds [3 ]
163+ )
164+
165+ return region_names , combined_polygon , bbox
166+
167+ def _download_file (self , url : str , local_path : str ) -> None :
168+ """Download a file from URL to local path."""
169+ response = requests .get (url , timeout = 30 , stream = True )
170+ response .raise_for_status ()
171+
172+ with open (local_path , 'wb' ) as f :
173+ for chunk in response .iter_content (chunk_size = 8192 ):
174+ f .write (chunk )
0 commit comments