feat: Real-time traffic via OLA Maps API with Indian city fallback patterns

#21
by MouleeswaranM - opened
brain/app/services/traffic_integration.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Real-Time Traffic Integration via OLA Maps API.
3
+ Provides traffic-aware route factors for Indian logistics.
4
+
5
+ API: OLA Maps (by Ola Krutrim) β€” https://maps.olakrutrim.com/
6
+ FREE for Indian developers β€” real-time traffic across all Indian cities.
7
+
8
+ Fallback: Indian city empirical traffic patterns by hour (no API key needed).
9
+ """
10
+
11
+ import os
12
+ import math
13
+ import logging
14
+ from datetime import datetime
15
+ from typing import Dict, Any, Optional, Tuple, List
16
+ from functools import lru_cache
17
+
18
+ import httpx
19
+
20
+ logger = logging.getLogger("fairrelay.traffic")
21
+
22
+ OLA_MAPS_BASE = "https://api.olamaps.io"
23
+ OLA_API_KEY = os.getenv("OLA_MAPS_API_KEY", "")
24
+
25
+ # ═══════════════════════════════════════════════════════════════
26
+ # INDIAN CITY TRAFFIC PATTERNS (empirical, by hour)
27
+ # Used as fallback when OLA Maps API is unavailable
28
+ # ═══════════════════════════════════════════════════════════════
29
+
30
+ # Congestion multiplier by hour (0-23) for Indian metros
31
+ INDIAN_METRO_TRAFFIC = {
32
+ 0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0, 4: 1.0, 5: 1.05,
33
+ 6: 1.15, 7: 1.35, 8: 1.55, 9: 1.65, 10: 1.45, 11: 1.30,
34
+ 12: 1.25, 13: 1.20, 14: 1.15, 15: 1.20, 16: 1.35,
35
+ 17: 1.55, 18: 1.70, 19: 1.60, 20: 1.40, 21: 1.25, 22: 1.10, 23: 1.05,
36
+ }
37
+
38
+ # City-specific multipliers (relative to metro baseline)
39
+ CITY_FACTORS = {
40
+ "mumbai": 1.25, # Worst traffic in India
41
+ "bangalore": 1.20, # Tech corridor congestion
42
+ "delhi": 1.15, # NCR sprawl
43
+ "chennai": 1.10, # Moderate
44
+ "hyderabad": 1.08, # Improving infra
45
+ "pune": 1.05, # Medium city
46
+ "kolkata": 1.12, # Dense but compact
47
+ "ahmedabad": 0.95, # Good roads, lower density
48
+ "jaipur": 0.90, # Less congestion
49
+ "default": 1.0,
50
+ }
51
+
52
+ # Haversine to road distance multiplier (Indian roads are not straight)
53
+ INDIA_ROAD_FACTOR = 1.35
54
+
55
+
56
+ # ═══════════════════════════════════════════════════════════════
57
+ # CORE FUNCTIONS
58
+ # ═══════════════════════════════════════════════════════════════
59
+
60
+ def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
61
+ """Haversine distance in km."""
62
+ R = 6371
63
+ dlat = math.radians(lat2 - lat1)
64
+ dlng = math.radians(lng2 - lng1)
65
+ a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlng/2)**2
66
+ return R * 2 * math.asin(math.sqrt(a))
67
+
68
+
69
+ def get_fallback_traffic_factor(
70
+ lat1: float = 0, lng1: float = 0,
71
+ lat2: float = 0, lng2: float = 0,
72
+ city: str = "default",
73
+ hour: Optional[int] = None,
74
+ ) -> Dict[str, Any]:
75
+ """
76
+ Fallback traffic factor using Indian city patterns.
77
+ No API call β€” pure empirical data.
78
+ """
79
+ if hour is None:
80
+ hour = datetime.now().hour
81
+
82
+ base_factor = INDIAN_METRO_TRAFFIC.get(hour, 1.2)
83
+ city_mult = CITY_FACTORS.get(city.lower(), CITY_FACTORS["default"])
84
+
85
+ traffic_factor = base_factor * city_mult
86
+
87
+ # Estimate road distance from Haversine
88
+ haversine_dist = haversine_km(lat1, lng1, lat2, lng2) if (lat1 and lng1 and lat2 and lng2) else 0
89
+ road_distance = haversine_dist * INDIA_ROAD_FACTOR
90
+
91
+ # Effective speed (avg Indian logistics: 25-45 km/h depending on traffic)
92
+ base_speed = 40.0 # km/h on clear roads
93
+ effective_speed = base_speed / traffic_factor
94
+
95
+ return {
96
+ "traffic_factor": round(traffic_factor, 3),
97
+ "road_distance_km": round(road_distance, 2),
98
+ "haversine_distance_km": round(haversine_dist, 2),
99
+ "effective_speed_kmh": round(effective_speed, 1),
100
+ "estimated_time_minutes": round((road_distance / effective_speed) * 60, 1) if effective_speed > 0 else 0,
101
+ "congestion_level": "heavy" if traffic_factor > 1.5 else "moderate" if traffic_factor > 1.2 else "light",
102
+ "source": "fallback_empirical",
103
+ "hour": hour,
104
+ "city": city,
105
+ }
106
+
107
+
108
+ async def get_traffic_factor(
109
+ lat1: float, lng1: float,
110
+ lat2: float, lng2: float,
111
+ city: str = "default",
112
+ ) -> Dict[str, Any]:
113
+ """
114
+ Get real-time traffic factor between two points.
115
+
116
+ Tries OLA Maps API first, falls back to empirical patterns.
117
+
118
+ Returns dict with:
119
+ - traffic_factor: float (1.0 = no traffic, 2.0 = severe)
120
+ - road_distance_km: float
121
+ - effective_speed_kmh: float
122
+ - estimated_time_minutes: float
123
+ - congestion_level: "light" | "moderate" | "heavy"
124
+ - source: "ola_maps" | "fallback_empirical"
125
+ """
126
+ # Try OLA Maps API if key is configured
127
+ if OLA_API_KEY:
128
+ try:
129
+ result = await _call_ola_directions(lat1, lng1, lat2, lng2)
130
+ if result:
131
+ return result
132
+ except Exception as e:
133
+ logger.warning(f"OLA Maps API failed: {e}, using fallback")
134
+
135
+ # Fallback to empirical patterns
136
+ return get_fallback_traffic_factor(lat1, lng1, lat2, lng2, city)
137
+
138
+
139
+ async def get_traffic_matrix(
140
+ origins: List[Tuple[float, float]],
141
+ destinations: List[Tuple[float, float]],
142
+ city: str = "default",
143
+ ) -> List[List[Dict[str, Any]]]:
144
+ """
145
+ Get traffic factors for a matrix of origin-destination pairs.
146
+ Uses OLA Maps Distance Matrix API if available, else computes individually.
147
+ """
148
+ if OLA_API_KEY and len(origins) <= 25 and len(destinations) <= 25:
149
+ try:
150
+ result = await _call_ola_distance_matrix(origins, destinations)
151
+ if result:
152
+ return result
153
+ except Exception as e:
154
+ logger.warning(f"OLA Distance Matrix failed: {e}")
155
+
156
+ # Fallback: compute individually
157
+ matrix = []
158
+ for o_lat, o_lng in origins:
159
+ row = []
160
+ for d_lat, d_lng in destinations:
161
+ factor = get_fallback_traffic_factor(o_lat, o_lng, d_lat, d_lng, city)
162
+ row.append(factor)
163
+ matrix.append(row)
164
+ return matrix
165
+
166
+
167
+ # ═══════════════════════════════════════════════════════════════
168
+ # OLA MAPS API CALLS
169
+ # ═══════════════════════════════════════════════════════════════
170
+
171
+ async def _call_ola_directions(
172
+ lat1: float, lng1: float,
173
+ lat2: float, lng2: float,
174
+ ) -> Optional[Dict[str, Any]]:
175
+ """
176
+ Call OLA Maps Directions API with traffic metadata.
177
+ Returns traffic-aware route info or None on failure.
178
+ """
179
+ url = f"{OLA_MAPS_BASE}/routing/v1/directions"
180
+ params = {
181
+ "origin": f"{lat1},{lng1}",
182
+ "destination": f"{lat2},{lng2}",
183
+ "mode": "driving",
184
+ "alternatives": "false",
185
+ "traffic_metadata": "true",
186
+ "api_key": OLA_API_KEY,
187
+ }
188
+
189
+ async with httpx.AsyncClient(timeout=10.0) as client:
190
+ response = await client.get(url, params=params)
191
+
192
+ if response.status_code != 200:
193
+ logger.warning(f"OLA Directions API returned {response.status_code}")
194
+ return None
195
+
196
+ data = response.json()
197
+
198
+ if data.get("status") != "SUCCESS" or not data.get("routes"):
199
+ return None
200
+
201
+ route = data["routes"][0]
202
+ legs = route.get("legs", [{}])
203
+ leg = legs[0] if legs else {}
204
+
205
+ # Extract distance and duration
206
+ distance_m = leg.get("distance", {}).get("value", 0)
207
+ duration_s = leg.get("duration", {}).get("value", 0)
208
+ duration_traffic_s = leg.get("duration_in_traffic", {}).get("value", duration_s)
209
+
210
+ road_distance_km = distance_m / 1000
211
+ haversine_dist = haversine_km(lat1, lng1, lat2, lng2)
212
+
213
+ # Traffic factor = actual time / free-flow time
214
+ traffic_factor = (duration_traffic_s / duration_s) if duration_s > 0 else 1.2
215
+ traffic_factor = max(1.0, min(3.0, traffic_factor)) # Clamp to reasonable range
216
+
217
+ effective_speed = (road_distance_km / (duration_traffic_s / 3600)) if duration_traffic_s > 0 else 30.0
218
+
219
+ return {
220
+ "traffic_factor": round(traffic_factor, 3),
221
+ "road_distance_km": round(road_distance_km, 2),
222
+ "haversine_distance_km": round(haversine_dist, 2),
223
+ "effective_speed_kmh": round(effective_speed, 1),
224
+ "estimated_time_minutes": round(duration_traffic_s / 60, 1),
225
+ "free_flow_time_minutes": round(duration_s / 60, 1),
226
+ "congestion_level": "heavy" if traffic_factor > 1.5 else "moderate" if traffic_factor > 1.2 else "light",
227
+ "source": "ola_maps",
228
+ "polyline": route.get("overview_polyline", {}).get("points", ""),
229
+ }
230
+
231
+
232
+ async def _call_ola_distance_matrix(
233
+ origins: List[Tuple[float, float]],
234
+ destinations: List[Tuple[float, float]],
235
+ ) -> Optional[List[List[Dict[str, Any]]]]:
236
+ """
237
+ Call OLA Maps Distance Matrix API for batch traffic computations.
238
+ Max 25 origins Γ— 25 destinations per call.
239
+ """
240
+ url = f"{OLA_MAPS_BASE}/routing/v1/distanceMatrix"
241
+
242
+ origins_str = "|".join(f"{lat},{lng}" for lat, lng in origins)
243
+ destinations_str = "|".join(f"{lat},{lng}" for lat, lng in destinations)
244
+
245
+ params = {
246
+ "origins": origins_str,
247
+ "destinations": destinations_str,
248
+ "mode": "driving",
249
+ "api_key": OLA_API_KEY,
250
+ }
251
+
252
+ async with httpx.AsyncClient(timeout=15.0) as client:
253
+ response = await client.get(url, params=params)
254
+
255
+ if response.status_code != 200:
256
+ return None
257
+
258
+ data = response.json()
259
+
260
+ if data.get("status") != "OK":
261
+ return None
262
+
263
+ rows = data.get("rows", [])
264
+ matrix = []
265
+
266
+ for i, row in enumerate(rows):
267
+ elements = row.get("elements", [])
268
+ matrix_row = []
269
+ for j, elem in enumerate(elements):
270
+ if elem.get("status") == "OK":
271
+ distance_m = elem.get("distance", {}).get("value", 0)
272
+ duration_s = elem.get("duration", {}).get("value", 1)
273
+ duration_traffic_s = elem.get("duration_in_traffic", {}).get("value", duration_s)
274
+
275
+ road_km = distance_m / 1000
276
+ traffic_factor = max(1.0, min(3.0, duration_traffic_s / duration_s)) if duration_s > 0 else 1.2
277
+ effective_speed = (road_km / (duration_traffic_s / 3600)) if duration_traffic_s > 0 else 30.0
278
+
279
+ matrix_row.append({
280
+ "traffic_factor": round(traffic_factor, 3),
281
+ "road_distance_km": round(road_km, 2),
282
+ "effective_speed_kmh": round(effective_speed, 1),
283
+ "estimated_time_minutes": round(duration_traffic_s / 60, 1),
284
+ "congestion_level": "heavy" if traffic_factor > 1.5 else "moderate" if traffic_factor > 1.2 else "light",
285
+ "source": "ola_maps_matrix",
286
+ })
287
+ else:
288
+ # Element failed β€” use fallback
289
+ o_lat, o_lng = origins[i]
290
+ d_lat, d_lng = destinations[j]
291
+ matrix_row.append(get_fallback_traffic_factor(o_lat, o_lng, d_lat, d_lng))
292
+
293
+ matrix.append(matrix_row)
294
+
295
+ return matrix
296
+
297
+
298
+ # ═══════════════════════════════════════════════════════════════
299
+ # UTILITY FUNCTIONS
300
+ # ═══════════════════════════════════════════════════════════════
301
+
302
+ def detect_city_from_coords(lat: float, lng: float) -> str:
303
+ """Detect Indian city from coordinates (approximate bounding boxes)."""
304
+ city_bounds = {
305
+ "mumbai": (18.85, 72.75, 19.30, 73.05),
306
+ "delhi": (28.40, 76.80, 28.90, 77.35),
307
+ "bangalore": (12.75, 77.40, 13.20, 77.80),
308
+ "chennai": (12.80, 80.05, 13.30, 80.40),
309
+ "hyderabad": (17.20, 78.20, 17.60, 78.70),
310
+ "pune": (18.40, 73.70, 18.70, 74.00),
311
+ "kolkata": (22.40, 88.20, 22.70, 88.50),
312
+ "ahmedabad": (22.90, 72.45, 23.15, 72.75),
313
+ "jaipur": (26.75, 75.65, 27.05, 75.95),
314
+ }
315
+
316
+ for city, (lat_min, lng_min, lat_max, lng_max) in city_bounds.items():
317
+ if lat_min <= lat <= lat_max and lng_min <= lng <= lng_max:
318
+ return city
319
+
320
+ return "default"
321
+
322
+
323
+ def get_effective_speed(
324
+ lat1: float, lng1: float,
325
+ lat2: float, lng2: float,
326
+ hour: Optional[int] = None,
327
+ ) -> float:
328
+ """
329
+ Quick synchronous function to get traffic-aware effective speed.
330
+ Uses fallback patterns (no async, no API call).
331
+
332
+ Returns: effective speed in km/h
333
+ """
334
+ city = detect_city_from_coords(lat1, lng1)
335
+ result = get_fallback_traffic_factor(lat1, lng1, lat2, lng2, city, hour)
336
+ return result["effective_speed_kmh"]