|
|
""" |
|
|
Geocoding service for FleetMind |
|
|
Handles address validation with Google Maps API and smart mock fallback |
|
|
""" |
|
|
|
|
|
import os |
|
|
import googlemaps |
|
|
import logging |
|
|
import asyncio |
|
|
from concurrent.futures import ThreadPoolExecutor |
|
|
from typing import Dict, Optional |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
_geocoding_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="geocoding") |
|
|
|
|
|
|
|
|
GEOCODING_TIMEOUT = 10 |
|
|
|
|
|
|
|
|
CITY_COORDINATES = { |
|
|
"san francisco": (37.7749, -122.4194), |
|
|
"sf": (37.7749, -122.4194), |
|
|
"new york": (40.7128, -74.0060), |
|
|
"nyc": (40.7128, -74.0060), |
|
|
"los angeles": (34.0522, -118.2437), |
|
|
"la": (34.0522, -118.2437), |
|
|
"chicago": (41.8781, -87.6298), |
|
|
"houston": (29.7604, -95.3698), |
|
|
"phoenix": (33.4484, -112.0740), |
|
|
"philadelphia": (39.9526, -75.1652), |
|
|
"san antonio": (29.4241, -98.4936), |
|
|
"san diego": (32.7157, -117.1611), |
|
|
"dallas": (32.7767, -96.7970), |
|
|
"austin": (30.2672, -97.7431), |
|
|
"seattle": (47.6062, -122.3321), |
|
|
"boston": (42.3601, -71.0589), |
|
|
"denver": (39.7392, -104.9903), |
|
|
"miami": (25.7617, -80.1918), |
|
|
"atlanta": (33.7490, -84.3880), |
|
|
"portland": (45.5152, -122.6784), |
|
|
} |
|
|
|
|
|
|
|
|
class GeocodingService: |
|
|
"""Handle address geocoding with Google Maps API and smart mock fallback""" |
|
|
|
|
|
def __init__(self): |
|
|
self.google_maps_key = os.getenv("GOOGLE_MAPS_API_KEY", "") |
|
|
self.use_mock = not self.google_maps_key or self.google_maps_key.startswith("your_") |
|
|
|
|
|
if self.use_mock: |
|
|
logger.info("Geocoding: Using mock (GOOGLE_MAPS_API_KEY not configured)") |
|
|
self.gmaps_client = None |
|
|
else: |
|
|
try: |
|
|
|
|
|
self.gmaps_client = googlemaps.Client( |
|
|
key=self.google_maps_key, |
|
|
timeout=GEOCODING_TIMEOUT, |
|
|
retry_timeout=GEOCODING_TIMEOUT |
|
|
) |
|
|
logger.info("Geocoding: Using Google Maps API (timeout: 10s)") |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to initialize Google Maps client: {e}") |
|
|
self.use_mock = True |
|
|
self.gmaps_client = None |
|
|
|
|
|
def geocode(self, address: str) -> Dict: |
|
|
""" |
|
|
Geocode address, using mock if API unavailable. |
|
|
This is a synchronous method with built-in timeout. |
|
|
|
|
|
Args: |
|
|
address: Street address to geocode |
|
|
|
|
|
Returns: |
|
|
Dict with keys: lat, lng, formatted_address, confidence |
|
|
""" |
|
|
if self.use_mock: |
|
|
return self._geocode_mock(address) |
|
|
else: |
|
|
try: |
|
|
return self._geocode_google(address) |
|
|
except Exception as e: |
|
|
logger.error(f"Google Maps API failed: {e}, falling back to mock") |
|
|
return self._geocode_mock(address) |
|
|
|
|
|
async def geocode_async(self, address: str) -> Dict: |
|
|
""" |
|
|
Async-safe geocoding that runs blocking calls in a thread pool. |
|
|
Use this in async contexts to prevent blocking the event loop. |
|
|
|
|
|
Args: |
|
|
address: Street address to geocode |
|
|
|
|
|
Returns: |
|
|
Dict with keys: lat, lng, formatted_address, confidence |
|
|
""" |
|
|
if self.use_mock: |
|
|
return self._geocode_mock(address) |
|
|
|
|
|
loop = asyncio.get_event_loop() |
|
|
try: |
|
|
|
|
|
result = await asyncio.wait_for( |
|
|
loop.run_in_executor(_geocoding_executor, self._geocode_google, address), |
|
|
timeout=GEOCODING_TIMEOUT + 2 |
|
|
) |
|
|
return result |
|
|
except asyncio.TimeoutError: |
|
|
logger.error(f"Geocoding timed out for address: {address}, falling back to mock") |
|
|
return self._geocode_mock(address) |
|
|
except Exception as e: |
|
|
logger.error(f"Async geocoding failed: {e}, falling back to mock") |
|
|
return self._geocode_mock(address) |
|
|
|
|
|
def _geocode_google(self, address: str) -> Dict: |
|
|
"""Real Google Maps API geocoding""" |
|
|
try: |
|
|
|
|
|
result = self.gmaps_client.geocode(address) |
|
|
|
|
|
if not result: |
|
|
|
|
|
logger.warning(f"Google Maps API found no results for: {address}") |
|
|
return self._geocode_mock(address) |
|
|
|
|
|
|
|
|
first_result = result[0] |
|
|
location = first_result['geometry']['location'] |
|
|
|
|
|
return { |
|
|
"lat": location['lat'], |
|
|
"lng": location['lng'], |
|
|
"formatted_address": first_result.get('formatted_address', address), |
|
|
"confidence": "high (Google Maps API)" |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Google Maps geocoding error: {e}") |
|
|
raise |
|
|
|
|
|
def _geocode_mock(self, address: str) -> Dict: |
|
|
""" |
|
|
Smart mock geocoding for testing |
|
|
Tries to detect city name and use approximate coordinates |
|
|
""" |
|
|
address_lower = address.lower() |
|
|
|
|
|
|
|
|
for city, coords in CITY_COORDINATES.items(): |
|
|
if city in address_lower: |
|
|
logger.info(f"Mock geocoding detected city: {city}") |
|
|
return { |
|
|
"lat": coords[0], |
|
|
"lng": coords[1], |
|
|
"formatted_address": address, |
|
|
"confidence": f"medium (mock - {city})" |
|
|
} |
|
|
|
|
|
|
|
|
logger.info("Mock geocoding: Using default SF coordinates") |
|
|
return { |
|
|
"lat": 37.7749, |
|
|
"lng": -122.4194, |
|
|
"formatted_address": address, |
|
|
"confidence": "low (mock - default)" |
|
|
} |
|
|
|
|
|
def reverse_geocode(self, lat: float, lng: float) -> Dict: |
|
|
""" |
|
|
Reverse geocode coordinates to address. |
|
|
This is a synchronous method with built-in timeout. |
|
|
|
|
|
Args: |
|
|
lat: Latitude |
|
|
lng: Longitude |
|
|
|
|
|
Returns: |
|
|
Dict with keys: address, city, state, country, formatted_address |
|
|
""" |
|
|
if self.use_mock: |
|
|
return self._reverse_geocode_mock(lat, lng) |
|
|
else: |
|
|
try: |
|
|
return self._reverse_geocode_google(lat, lng) |
|
|
except Exception as e: |
|
|
logger.error(f"Google Maps reverse geocoding failed: {e}, falling back to mock") |
|
|
return self._reverse_geocode_mock(lat, lng) |
|
|
|
|
|
async def reverse_geocode_async(self, lat: float, lng: float) -> Dict: |
|
|
""" |
|
|
Async-safe reverse geocoding that runs blocking calls in a thread pool. |
|
|
Use this in async contexts to prevent blocking the event loop. |
|
|
|
|
|
Args: |
|
|
lat: Latitude |
|
|
lng: Longitude |
|
|
|
|
|
Returns: |
|
|
Dict with keys: address, city, state, country, formatted_address |
|
|
""" |
|
|
if self.use_mock: |
|
|
return self._reverse_geocode_mock(lat, lng) |
|
|
|
|
|
loop = asyncio.get_event_loop() |
|
|
try: |
|
|
|
|
|
result = await asyncio.wait_for( |
|
|
loop.run_in_executor( |
|
|
_geocoding_executor, |
|
|
self._reverse_geocode_google, |
|
|
lat, |
|
|
lng |
|
|
), |
|
|
timeout=GEOCODING_TIMEOUT + 2 |
|
|
) |
|
|
return result |
|
|
except asyncio.TimeoutError: |
|
|
logger.error(f"Reverse geocoding timed out for ({lat}, {lng}), falling back to mock") |
|
|
return self._reverse_geocode_mock(lat, lng) |
|
|
except Exception as e: |
|
|
logger.error(f"Async reverse geocoding failed: {e}, falling back to mock") |
|
|
return self._reverse_geocode_mock(lat, lng) |
|
|
|
|
|
def _reverse_geocode_google(self, lat: float, lng: float) -> Dict: |
|
|
"""Real Google Maps API reverse geocoding""" |
|
|
try: |
|
|
|
|
|
result = self.gmaps_client.reverse_geocode((lat, lng)) |
|
|
|
|
|
if not result: |
|
|
logger.warning(f"Google Maps API found no results for: ({lat}, {lng})") |
|
|
return self._reverse_geocode_mock(lat, lng) |
|
|
|
|
|
|
|
|
first_result = result[0] |
|
|
|
|
|
|
|
|
address_components = first_result.get('address_components', []) |
|
|
city = "" |
|
|
state = "" |
|
|
country = "" |
|
|
|
|
|
for component in address_components: |
|
|
types = component.get('types', []) |
|
|
if 'locality' in types: |
|
|
city = component.get('long_name', '') |
|
|
elif 'administrative_area_level_1' in types: |
|
|
state = component.get('short_name', '') |
|
|
elif 'country' in types: |
|
|
country = component.get('long_name', '') |
|
|
|
|
|
return { |
|
|
"address": first_result.get('formatted_address', f"{lat}, {lng}"), |
|
|
"city": city, |
|
|
"state": state, |
|
|
"country": country, |
|
|
"formatted_address": first_result.get('formatted_address', f"{lat}, {lng}"), |
|
|
"confidence": "high (Google Maps API)" |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Google Maps reverse geocoding error: {e}") |
|
|
raise |
|
|
|
|
|
def _reverse_geocode_mock(self, lat: float, lng: float) -> Dict: |
|
|
""" |
|
|
Mock reverse geocoding |
|
|
Tries to match coordinates to known cities |
|
|
""" |
|
|
|
|
|
min_distance = float('inf') |
|
|
closest_city = "Unknown Location" |
|
|
|
|
|
for city, coords in CITY_COORDINATES.items(): |
|
|
|
|
|
distance = ((lat - coords[0]) ** 2 + (lng - coords[1]) ** 2) ** 0.5 |
|
|
if distance < min_distance: |
|
|
min_distance = distance |
|
|
closest_city = city |
|
|
|
|
|
|
|
|
if min_distance < 0.1: |
|
|
logger.info(f"Mock reverse geocoding: Matched to {closest_city}") |
|
|
return { |
|
|
"address": f"Near {closest_city.title()}", |
|
|
"city": closest_city.title(), |
|
|
"state": "CA" if "san francisco" in closest_city or "la" in closest_city else "", |
|
|
"country": "USA", |
|
|
"formatted_address": f"Near {closest_city.title()}, USA", |
|
|
"confidence": f"medium (mock - near {closest_city})" |
|
|
} |
|
|
else: |
|
|
logger.info(f"Mock reverse geocoding: Unknown location at ({lat}, {lng})") |
|
|
return { |
|
|
"address": f"{lat}, {lng}", |
|
|
"city": "", |
|
|
"state": "", |
|
|
"country": "", |
|
|
"formatted_address": f"Coordinates: {lat}, {lng}", |
|
|
"confidence": "low (mock - no match)" |
|
|
} |
|
|
|
|
|
def get_status(self) -> str: |
|
|
"""Get geocoding service status""" |
|
|
if self.use_mock: |
|
|
return "⚠️ Using mock geocoding (add GOOGLE_MAPS_API_KEY for real)" |
|
|
else: |
|
|
return "✅ Google Maps API connected" |
|
|
|