# ================================================================ # services/data/app/external/apis/traffic.py # ================================================================ """ Traffic API abstraction layer for multiple cities """ import asyncio from abc import ABC, abstractmethod from datetime import datetime from enum import Enum from typing import Dict, List, Any, Optional, Tuple import structlog logger = structlog.get_logger() class SupportedCity(Enum): """Supported cities for traffic data collection""" MADRID = "madrid" BARCELONA = "barcelona" VALENCIA = "valencia" class BaseTrafficClient(ABC): """ Abstract base class for city-specific traffic clients Defines the contract that all traffic clients must implement """ def __init__(self, city: SupportedCity): self.city = city self.logger = structlog.get_logger().bind(city=city.value) @abstractmethod async def get_current_traffic(self, latitude: float, longitude: float) -> Optional[Dict[str, Any]]: """Get current traffic data for location""" pass @abstractmethod async def get_historical_traffic(self, latitude: float, longitude: float, start_date: datetime, end_date: datetime) -> List[Dict[str, Any]]: """Get historical traffic data""" pass @abstractmethod async def get_events(self, latitude: float, longitude: float, radius_km: float = 5.0) -> List[Dict[str, Any]]: """Get traffic incidents and events""" pass @abstractmethod def supports_location(self, latitude: float, longitude: float) -> bool: """Check if this client supports the given location""" pass class TrafficAPIClientFactory: """ Factory class to create appropriate traffic clients based on location """ # City geographical bounds CITY_BOUNDS = { SupportedCity.MADRID: { 'lat_min': 40.31, 'lat_max': 40.56, 'lon_min': -3.89, 'lon_max': -3.51 }, SupportedCity.BARCELONA: { 'lat_min': 41.32, 'lat_max': 41.47, 'lon_min': 2.05, 'lon_max': 2.25 }, SupportedCity.VALENCIA: { 'lat_min': 39.42, 'lat_max': 39.52, 'lon_min': -0.42, 'lon_max': -0.32 } } @classmethod def get_client_for_location(cls, latitude: float, longitude: float) -> Optional[BaseTrafficClient]: """ Get appropriate traffic client for given location Args: latitude: Query location latitude longitude: Query location longitude Returns: BaseTrafficClient instance or None if location not supported """ try: # Check each city's bounds for city, bounds in cls.CITY_BOUNDS.items(): if (bounds['lat_min'] <= latitude <= bounds['lat_max'] and bounds['lon_min'] <= longitude <= bounds['lon_max']): logger.info("Location matched to city", city=city.value, lat=latitude, lon=longitude) return cls._create_client(city) # If no specific city matches, try to find closest supported city closest_city = cls._find_closest_city(latitude, longitude) if closest_city: logger.info("Using closest city for location", closest_city=closest_city.value, lat=latitude, lon=longitude) return cls._create_client(closest_city) logger.warning("No traffic client available for location", lat=latitude, lon=longitude) return None except Exception as e: logger.error("Error getting traffic client for location", lat=latitude, lon=longitude, error=str(e)) return None @classmethod def _create_client(cls, city: SupportedCity) -> BaseTrafficClient: """Create traffic client for specific city""" if city == SupportedCity.MADRID: from .madrid_traffic_client import MadridTrafficClient return MadridTrafficClient() elif city == SupportedCity.BARCELONA: # Future implementation raise NotImplementedError(f"Traffic client for {city.value} not yet implemented") elif city == SupportedCity.VALENCIA: # Future implementation raise NotImplementedError(f"Traffic client for {city.value} not yet implemented") else: raise ValueError(f"Unsupported city: {city}") @classmethod def _find_closest_city(cls, latitude: float, longitude: float) -> Optional[SupportedCity]: """Find closest supported city to given coordinates""" import math def distance(lat1, lon1, lat2, lon2): """Calculate distance between two coordinates""" R = 6371 # Earth's radius in km dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) a = (math.sin(dlat/2) * math.sin(dlat/2) + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2) * math.sin(dlon/2)) c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) return R * c min_distance = float('inf') closest_city = None # City centers for distance calculation city_centers = { SupportedCity.MADRID: (40.4168, -3.7038), SupportedCity.BARCELONA: (41.3851, 2.1734), SupportedCity.VALENCIA: (39.4699, -0.3763) } for city, (city_lat, city_lon) in city_centers.items(): dist = distance(latitude, longitude, city_lat, city_lon) if dist < min_distance and dist < 100: # Within 100km min_distance = dist closest_city = city return closest_city @classmethod def get_supported_cities(cls) -> List[Dict[str, Any]]: """Get list of supported cities with their bounds""" cities = [] for city, bounds in cls.CITY_BOUNDS.items(): cities.append({ "city": city.value, "bounds": bounds, "status": "active" if city == SupportedCity.MADRID else "planned" }) return cities class UniversalTrafficClient: """ Universal traffic client that delegates to appropriate city-specific clients This is the main interface that external services should use """ def __init__(self): self.factory = TrafficAPIClientFactory() self.client_cache = {} # Cache clients for performance async def get_current_traffic(self, latitude: float, longitude: float) -> Optional[Dict[str, Any]]: """Get current traffic data for any supported location""" try: client = self._get_client_for_location(latitude, longitude) if client: return await client.get_current_traffic(latitude, longitude) else: logger.warning("No traffic data available for location", lat=latitude, lon=longitude) return None except Exception as e: logger.error("Error getting current traffic", lat=latitude, lon=longitude, error=str(e)) return None async def get_historical_traffic(self, latitude: float, longitude: float, start_date: datetime, end_date: datetime) -> List[Dict[str, Any]]: """Get historical traffic data for any supported location""" try: client = self._get_client_for_location(latitude, longitude) if client: return await client.get_historical_traffic(latitude, longitude, start_date, end_date) else: logger.warning("No historical traffic data available for location", lat=latitude, lon=longitude) return [] except Exception as e: logger.error("Error getting historical traffic", lat=latitude, lon=longitude, error=str(e)) return [] async def get_events(self, latitude: float, longitude: float, radius_km: float = 5.0) -> List[Dict[str, Any]]: """Get traffic events for any supported location""" try: client = self._get_client_for_location(latitude, longitude) if client: return await client.get_events(latitude, longitude, radius_km) else: return [] except Exception as e: logger.error("Error getting traffic events", lat=latitude, lon=longitude, error=str(e)) return [] def _get_client_for_location(self, latitude: float, longitude: float) -> Optional[BaseTrafficClient]: """Get cached or create new client for location""" cache_key = f"{latitude:.4f},{longitude:.4f}" if cache_key not in self.client_cache: client = self.factory.get_client_for_location(latitude, longitude) self.client_cache[cache_key] = client return self.client_cache[cache_key] def get_location_info(self, latitude: float, longitude: float) -> Dict[str, Any]: """Get information about traffic data availability for location""" client = self._get_client_for_location(latitude, longitude) if client: return { "supported": True, "city": client.city.value, "features": ["current_traffic", "historical_traffic", "events"] } else: return { "supported": False, "city": None, "features": [], "message": "No traffic data available for this location" }