11from enum import Enum
22from typing import Dict , Optional
3+
4+ from geoalchemy2 import WKTElement
35from sqlalchemy .orm import Session
6+ from sqlalchemy import func , cast
7+ from geoalchemy2 .types import Geography
8+
49import pycountry
5- from shared .database_gen .sqlacodegen_models import Feed , Location
10+ from shared .database_gen .sqlacodegen_models import Feed , Location , Geopolygon
611import logging
712
813
@@ -11,8 +16,14 @@ class ReverseGeocodingStrategy(str, Enum):
1116 Enum for reverse geocoding strategies.
1217 """
1318
19+ # Per point strategy uses point-in-polygon to find the location for each point
20+ # It queries the database for each point, which can be slow for large datasets
1421 PER_POINT = "per-point"
1522
23+ # Per polygon strategy uses point-in-polygon to find the location for each point
24+ # It queries the database for each polygon, which can be faster for large datasets
25+ PER_POLYGON = "per-polygon"
26+
1627
1728def get_country_code (country_name : str ) -> Optional [str ]:
1829 """
@@ -133,3 +144,90 @@ def translate_feed_locations(feed: Feed, location_translations: Dict):
133144 if location_translation ["country_translation" ]
134145 else location .country
135146 )
147+
148+
149+ def to_shapely (g ):
150+ """
151+ Convert a GeoAlchemy WKB/WKT element or WKT string into a Shapely geometry.
152+ If it's already a Shapely geometry, return it as-is.
153+ """
154+ # Import here to avoid adding unnecessary dependencies if not used to GCP functions
155+ from shapely import wkt as shapely_wkt
156+ from geoalchemy2 import WKTElement , WKBElement
157+ from geoalchemy2 .shape import to_shape
158+
159+ if isinstance (g , WKBElement ):
160+ return to_shape (g )
161+ if isinstance (g , WKTElement ):
162+ return shapely_wkt .loads (g .data )
163+ if isinstance (g , str ):
164+ # assume WKT
165+ return shapely_wkt .loads (g )
166+ return g # assume already shapely
167+
168+
169+ def select_highest_level_polygon (geopolygons : list [Geopolygon ]) -> Optional [Geopolygon ]:
170+ """
171+ Select the geopolygon with the highest admin_level from a list of geopolygons.
172+ Admin levels are compared, with NULL treated as the lowest priority.
173+ """
174+ if not geopolygons :
175+ return None
176+ # Treat NULL admin_level as the lowest priority
177+ return max (
178+ geopolygons , key = lambda g : (- 1 if g .admin_level is None else g .admin_level )
179+ )
180+
181+
182+ def select_lowest_level_polygon (geopolygons : list [Geopolygon ]) -> Optional [Geopolygon ]:
183+ """
184+ Select the geopolygon with the lowest admin_level from a list of geopolygons.
185+ Admin levels are compared, with NULL treated as the lowest priority.
186+ """
187+ if not geopolygons :
188+ return None
189+ # Treat NULL admin_level as the lowest priority
190+ return min (
191+ geopolygons , key = lambda g : (100 if g .admin_level is None else g .admin_level )
192+ )
193+
194+
195+ def get_country_code_from_polygons (geopolygons : list [Geopolygon ]) -> Optional [str ]:
196+ """
197+ Given a list of polygon GeoJSON-like features (each with 'properties'),
198+ return the country code (ISO 3166-1 alpha-2) from the most likely polygon.
199+
200+ Args:
201+ polygons: List of dicts, each must have 'properties' with
202+ 'admin_level' and 'iso_3166_1_code'
203+
204+ Returns:
205+ A two-letter country code string or None if not found
206+ """
207+ country_polygons = [g for g in geopolygons if g .iso_3166_1_code ]
208+ if not country_polygons :
209+ return None
210+
211+ # Prefer the one with the lowest admin_level (most local)
212+ lowest_admin_level_polygon = select_lowest_level_polygon (country_polygons )
213+ return lowest_admin_level_polygon .iso_3166_1_code
214+
215+
216+ def get_geopolygons_covers (stop_point : WKTElement , db_session : Session ):
217+ """
218+ Get all geopolygons that cover a given point using BigQuery-compatible semantics.
219+ """
220+ # BigQuery-compatible point-in-polygon (geodesic + border-inclusive)
221+ geopolygons = (
222+ db_session .query (Geopolygon )
223+ # optional prefilter to use your GiST index on geometry (fast)
224+ .filter (func .ST_Intersects (Geopolygon .geometry , stop_point ))
225+ # exact check matching BigQuery's GEOGRAPHY semantics
226+ .filter (
227+ func .ST_Covers (
228+ cast (Geopolygon .geometry , Geography (srid = 4326 )),
229+ cast (stop_point , Geography (srid = 4326 )),
230+ )
231+ ).all ()
232+ )
233+ return geopolygons
0 commit comments