Improve the traffic fetching system
This commit is contained in:
10
services/data/app/external/apis/__init__.py
vendored
Normal file
10
services/data/app/external/apis/__init__.py
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# ================================================================
|
||||
# services/data/app/external/apis/__init__.py
|
||||
# ================================================================
|
||||
"""
|
||||
External API clients module - Scalable architecture for multiple cities
|
||||
"""
|
||||
|
||||
from .traffic import TrafficAPIClientFactory
|
||||
|
||||
__all__ = ["TrafficAPIClientFactory"]
|
||||
1689
services/data/app/external/apis/madrid_traffic_client.py
vendored
Normal file
1689
services/data/app/external/apis/madrid_traffic_client.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
257
services/data/app/external/apis/traffic.py
vendored
Normal file
257
services/data/app/external/apis/traffic.py
vendored
Normal file
@@ -0,0 +1,257 @@
|
||||
# ================================================================
|
||||
# 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"
|
||||
}
|
||||
28
services/data/app/external/base_client.py
vendored
28
services/data/app/external/base_client.py
vendored
@@ -54,6 +54,19 @@ class BaseAPIClient:
|
||||
logger.error("Unexpected error", error=str(e), url=url)
|
||||
return None
|
||||
|
||||
async def get(self, url: str, headers: Optional[Dict] = None, timeout: Optional[int] = None) -> httpx.Response:
|
||||
"""
|
||||
Public GET method for direct HTTP requests
|
||||
Returns the raw httpx Response object for maximum flexibility
|
||||
"""
|
||||
request_headers = headers or {}
|
||||
request_timeout = httpx.Timeout(timeout if timeout else 30.0)
|
||||
|
||||
async with httpx.AsyncClient(timeout=request_timeout, follow_redirects=True) as client:
|
||||
response = await client.get(url, headers=request_headers)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
async def _fetch_url_directly(self, url: str, headers: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch data directly from a full URL (for AEMET datos URLs)"""
|
||||
try:
|
||||
@@ -123,4 +136,17 @@ class BaseAPIClient:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error", error=str(e), url=url)
|
||||
return None
|
||||
return None
|
||||
|
||||
async def get(self, url: str, headers: Optional[Dict] = None, timeout: Optional[int] = None) -> httpx.Response:
|
||||
"""
|
||||
Public GET method for direct HTTP requests
|
||||
Returns the raw httpx Response object for maximum flexibility
|
||||
"""
|
||||
request_headers = headers or {}
|
||||
request_timeout = httpx.Timeout(timeout if timeout else 30.0)
|
||||
|
||||
async with httpx.AsyncClient(timeout=request_timeout, follow_redirects=True) as client:
|
||||
response = await client.get(url, headers=request_headers)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
1409
services/data/app/external/madrid_opendata.py
vendored
1409
services/data/app/external/madrid_opendata.py
vendored
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user