Marco310 commited on
Commit
a6744e0
·
1 Parent(s): 9dd04a1

build outside api server layers

Browse files
src/services/googlemap_api_service.py ADDED
@@ -0,0 +1,904 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Google Maps API Service
3
+ """
4
+
5
+ import requests
6
+ from typing import List, Dict, Any, Optional, Tuple, Union
7
+ from datetime import datetime, timedelta, timezone
8
+ import numpy as np
9
+ import polyline
10
+ from agno.run import RunContext
11
+
12
+ from src.infra.logger import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+ MAX_LOCATIONS_PER_SIDE = 50
17
+
18
+
19
+ def cleanup_str(key: str) -> str:
20
+ return key.strip('\'" ')
21
+
22
+ def clean_dict_list_keys(data_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
23
+ if not isinstance(data_list, list):
24
+ # 為了安全,如果輸入不是列表,直接返回原始輸入或空列表
25
+ print(f"Warning: Input is not a list. Received type: {type(data_list)}")
26
+ return data_list
27
+
28
+ cleaned_list = []
29
+
30
+ for item in data_list:
31
+ if isinstance(item, dict):
32
+ cleaned_item = {}
33
+ for original_key, value in item.items():
34
+ new_key = cleanup_str(original_key)
35
+ cleaned_item[new_key] = value
36
+
37
+ cleaned_list.append(cleaned_item)
38
+ else:
39
+ cleaned_list.append(item)
40
+
41
+ return cleaned_list
42
+
43
+ class GoogleMapAPIService:
44
+ def __init__(self, api_key: Optional[str] = None):
45
+ """
46
+ Initialize Google Maps API service
47
+
48
+ Args:
49
+ api_key: Google Maps API key (required)
50
+ """
51
+ # ✅ 改進: 更靈活的配置獲取
52
+ if api_key:
53
+ self.api_key = api_key
54
+ else:
55
+ import os
56
+ self.api_key = os.getenv("GOOGLE_MAPS_API_KEY")
57
+ try:
58
+ from src.infra.config import get_settings
59
+ settings = get_settings()
60
+ self.api_key = settings.google_maps_api_key
61
+ except (ImportError, AttributeError) as e:
62
+ raise e
63
+
64
+ if not self.api_key:
65
+ raise ValueError(
66
+ "Google Maps API key is required. "
67
+ "Set via parameter, config, or GOOGLE_MAPS_API_KEY env var"
68
+ )
69
+
70
+ # API endpoints
71
+ self.geolocation_url = "https://www.googleapis.com/geolocation/v1/geolocate"
72
+ self.places_text_search_url = "https://places.googleapis.com/v1/places:searchText"
73
+ self.compute_routes_url = "https://routes.googleapis.com/directions/v2:computeRoutes"
74
+ self.compute_route_matrix_url = "https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix"
75
+
76
+ logger.info("✅ Google Maps API service initialized")
77
+
78
+ # ========================================================================
79
+ # Geolocation
80
+ # ========================================================================
81
+
82
+ def current_location(self) -> Dict[str, Any]:
83
+ """
84
+ Get current location using Geolocation API
85
+
86
+ Returns:
87
+ Location info with lat/lng
88
+
89
+ Raises:
90
+ requests.RequestException: If API request fails
91
+ """
92
+ try:
93
+ response = requests.post(
94
+ self.geolocation_url,
95
+ params={"key": self.api_key},
96
+ headers={"Content-Type": "application/json"},
97
+ timeout=10
98
+ )
99
+ if not response.ok:
100
+ logger.error(f"❌ Geolocation API error: {response.text}")
101
+ response.raise_for_status()
102
+ data = response.json()
103
+
104
+ # ✅ 改進: 檢查響應有效性
105
+ if "location" not in data:
106
+ raise ValueError("Invalid geolocation response")
107
+
108
+ logger.debug(f"📍 Current location: {data['location']}")
109
+ return data
110
+
111
+ except requests.Timeout:
112
+ logger.error("⏰ Geolocation request timeout")
113
+ raise
114
+ except requests.RequestException as e:
115
+ logger.error(f"❌ Geolocation request failed: {e}")
116
+ raise
117
+ except Exception as e:
118
+ logger.error(f"❌ Unexpected error in current_location: {e}")
119
+ raise
120
+
121
+ # ========================================================================
122
+ # Places API - Text Search (New API v1)
123
+ # ========================================================================
124
+
125
+ def text_search(
126
+ self,
127
+ query: str,
128
+ location: Optional[Dict[str, float]] = None,
129
+ radius: Optional[int] = None,
130
+ limit: int = 3,
131
+ ) -> List[Dict[str, Any]]:
132
+ """
133
+ Search for a place to get its coordinates and details.
134
+ Use this to convert a user's vague query (e.g., "Gas station", "Taipei 101") into precise lat/lng coordinates.
135
+
136
+ Args:
137
+ query: The search text (e.g., "Din Tai Fung", "Hospital").
138
+ location: Optional bias location {"lat": float, "lng": float}.
139
+ radius: Search radius in meters (optional).
140
+ limit: Max results to return (default 3).
141
+
142
+ Returns:
143
+ List[Dict]: A list of places found. Each item contains:
144
+ - name: str
145
+ - address: str
146
+ - location: {"lat": float, "lng": float} (CRITICAL for routing)
147
+ - place_id: str
148
+ - rating: float
149
+ """
150
+ request_body = {
151
+ "textQuery": query,
152
+ "maxResultCount": min(limit, 20)
153
+ }
154
+
155
+ if location and radius:
156
+ request_body["locationBias"] = {
157
+ "circle": {
158
+ "center": {
159
+ "latitude": location["lat"],
160
+ "longitude": location["lng"]
161
+ },
162
+ "radius": float(radius)
163
+ }
164
+ }
165
+
166
+ headers = {
167
+ "Content-Type": "application/json",
168
+ "X-Goog-Api-Key": self.api_key,
169
+ "X-Goog-FieldMask": (
170
+ "places.id,"
171
+ "places.displayName,"
172
+ "places.formattedAddress,"
173
+ "places.location,"
174
+ "places.rating,"
175
+ "places.userRatingCount,"
176
+ "places.businessStatus,"
177
+ "places.currentOpeningHours,"
178
+ "places.userRatingCount,"
179
+ )
180
+ }
181
+
182
+ try:
183
+ logger.debug(f"🔍 Searching places: '{query}' (limit={limit})")
184
+
185
+ response = requests.post(
186
+ self.places_text_search_url,
187
+ json=request_body,
188
+ headers=headers,
189
+ timeout=10
190
+ )
191
+ response.raise_for_status()
192
+ data = response.json()
193
+
194
+ places = data.get("places", [])
195
+
196
+ if not places:
197
+ logger.warning(f"⚠️ No places found for query: '{query}'")
198
+ return []
199
+
200
+ logger.info(f"✅ Found {len(places)} places for '{query}'")
201
+
202
+ standardized_places = []
203
+ for place in places:
204
+ standardized_places.append({
205
+ "place_id": place.get("id"),
206
+ "name": place.get("displayName", {}).get("text", "Unknown"),
207
+ "address": place.get("formattedAddress", ""),
208
+ "location": place.get("location", {}),
209
+ "rating": place.get("rating"),
210
+ "user_ratings_total": place.get("userRatingCount", 0),
211
+ "business_status": place.get("businessStatus", "UNKNOWN"),
212
+ "opening_hours": place.get("currentOpeningHours", {})
213
+ })
214
+
215
+ return standardized_places
216
+
217
+ except requests.Timeout:
218
+ logger.error("⏰ Places search timeout")
219
+ raise
220
+ except requests.RequestException as e:
221
+ logger.error(f"❌ Places search failed: {e}")
222
+ raise
223
+ except Exception as e:
224
+ logger.error(f"❌ Unexpected error in text_search: {e}")
225
+ raise
226
+
227
+ # ========================================================================
228
+ # Routes API - Compute Routes
229
+ # ========================================================================
230
+
231
+ def _compute_routes(
232
+ self,
233
+ origin: Dict[str, Any],
234
+ destination: Dict[str, Any],
235
+ waypoints: Optional[List[Dict[str, float]]] = None,
236
+ travel_mode: str = "DRIVE",
237
+ routing_preference: str = "TRAFFIC_AWARE_OPTIMAL",
238
+ compute_alternative_routes: bool = False,
239
+ optimize_waypoint_order: bool = False,
240
+ departure_time: Optional[datetime] = None,
241
+ include_traffic_on_polyline: bool = True
242
+ ) -> Dict[str, Any]:
243
+ """
244
+ ✅ P0-1 & P0-3 修復: 正確的經緯度 + 完整的響應解析
245
+
246
+ Internal method to compute routes using Routes API v2
247
+
248
+ Args:
249
+ origin: Origin location dict with 'lat' and 'lng' keys
250
+ destination: Destination location dict with 'lat' and 'lng' keys
251
+ waypoints: Optional list of waypoint dicts with 'lat' and 'lng'
252
+ travel_mode: Travel mode (DRIVE, WALK, BICYCLE, TRANSIT)
253
+ routing_preference: Routing preference
254
+ compute_alternative_routes: Whether to compute alternatives
255
+ optimize_waypoint_order: Whether to optimize waypoint order
256
+ departure_time: Departure time as datetime object
257
+ include_traffic_on_polyline: Whether to include traffic info
258
+
259
+ Returns:
260
+ Parsed route information dict with:
261
+ - distance_meters: int
262
+ - duration_seconds: int
263
+ - encoded_polyline: str
264
+ - legs: List[Dict]
265
+ - optimized_waypoint_order: Optional[List[int]]
266
+
267
+ Raises:
268
+ ValueError: If route cannot be computed
269
+ requests.RequestException: If API request fails
270
+ """
271
+ # ✅ P0-1 修復: 正確使用 lng 而不是 lat
272
+ request_body = {
273
+ "origin": {
274
+ "location": {
275
+ "latLng": {
276
+ "latitude": origin["lat"],
277
+ "longitude": origin["lng"] # ✅ 修復: 原來是 origin["lat"]
278
+ }
279
+ }
280
+ },
281
+ "destination": {
282
+ "location": {
283
+ "latLng": {
284
+ "latitude": destination["lat"],
285
+ "longitude": destination["lng"] # ✅ 修復: 原來是 destination["lat"]
286
+ }
287
+ }
288
+ },
289
+ "travelMode": travel_mode,
290
+ "routingPreference": routing_preference,
291
+ "computeAlternativeRoutes": compute_alternative_routes,
292
+ "languageCode": "zh-TW",
293
+ "units": "METRIC"
294
+ }
295
+
296
+ # ✅ 添加中間點
297
+ if waypoints:
298
+ request_body["intermediates"] = [
299
+ {
300
+ "location": {
301
+ "latLng": {
302
+ "latitude": wp["lat"],
303
+ "longitude": wp["lng"]
304
+ }
305
+ }
306
+ }
307
+ for wp in waypoints
308
+ ]
309
+
310
+ if optimize_waypoint_order:
311
+ request_body["optimizeWaypointOrder"] = True
312
+
313
+ if departure_time:
314
+ if isinstance(departure_time, datetime):
315
+ request_body["departureTime"] = departure_time.isoformat()
316
+ elif departure_time == "now":
317
+ request_body["departureTime"] = datetime.now(timezone.utc).isoformat()
318
+
319
+ # ✅ Field Mask
320
+ field_mask_parts = [
321
+ "routes.duration",
322
+ "routes.distanceMeters",
323
+ "routes.polyline.encodedPolyline",
324
+ "routes.legs",
325
+ "routes.optimizedIntermediateWaypointIndex"
326
+ ]
327
+
328
+ if include_traffic_on_polyline:
329
+ field_mask_parts.extend([
330
+ "routes.travelAdvisory",
331
+ "routes.legs.travelAdvisory"
332
+ ])
333
+
334
+ field_mask = ",".join(field_mask_parts)
335
+
336
+ headers = {
337
+ "Content-Type": "application/json",
338
+ "X-Goog-Api-Key": self.api_key,
339
+ "X-Goog-FieldMask": field_mask
340
+ }
341
+
342
+ logger.debug(
343
+ f"🗺️ Computing route: {origin['lat']:.4f},{origin['lng']:.4f} → "
344
+ f"{destination['lat']:.4f},{destination['lng']:.4f} "
345
+ f"({len(waypoints) if waypoints else 0} waypoints)"
346
+ )
347
+
348
+ try:
349
+ response = requests.post(
350
+ self.compute_routes_url,
351
+ json=request_body,
352
+ headers=headers,
353
+ timeout=30 # ✅ 增加超時時間
354
+ )
355
+ if not response.ok:
356
+ logger.error(f"❌ Routes API error: {response.text}")
357
+ response.raise_for_status()
358
+ data = response.json()
359
+
360
+ # ✅ P0-3 修復: 完整解析 API 響應
361
+ if "routes" not in data or not data["routes"]:
362
+ logger.error("❌ No routes found in API response")
363
+ raise ValueError("No routes found")
364
+
365
+ route = data["routes"][0] # 取第一條路線
366
+
367
+ # ✅ 解析距離
368
+ distance_meters = route.get("distanceMeters", 0)
369
+
370
+ # ✅ 解析時間 (格式: "300s")
371
+ duration_str = route.get("duration", "0s")
372
+ duration_seconds = int(duration_str.rstrip("s"))
373
+
374
+ # ✅ 解析 polyline
375
+ polyline_data = route.get("polyline", {})
376
+ encoded_polyline = polyline_data.get("encodedPolyline", "")
377
+
378
+ # ✅ 解析 legs
379
+ legs = route.get("legs", [])
380
+
381
+ # ✅ 解析優化後的順序 (如果有)
382
+ optimized_order = route.get("optimizedIntermediateWaypointIndex")
383
+
384
+ result = {
385
+ "distance_meters": distance_meters,
386
+ "duration_seconds": duration_seconds,
387
+ "encoded_polyline": encoded_polyline,
388
+ "legs": legs,
389
+ "optimized_waypoint_order": optimized_order
390
+ }
391
+
392
+ logger.debug(
393
+ f"✅ Route computed: {distance_meters}m, {duration_seconds}s"
394
+ )
395
+
396
+ return result
397
+
398
+ except requests.Timeout:
399
+ logger.error("⏰ Routes request timeout")
400
+ raise
401
+ except requests.RequestException as e:
402
+ logger.error(f"❌ Routes request failed: {e}")
403
+ raise
404
+ except (KeyError, ValueError) as e:
405
+ logger.error(f"❌ Failed to parse route response: {e}")
406
+ raise ValueError(f"Invalid route response: {e}")
407
+ except Exception as e:
408
+ logger.error(f"❌ Unexpected error in _compute_routes: {e}")
409
+ raise
410
+
411
+ # ========================================================================
412
+ # Polyline Helper
413
+ # ========================================================================
414
+
415
+ def _merge_polylines(self, poly_lines: List[str]) -> str:
416
+ """
417
+ Merge multiple encoded polylines into a single polyline
418
+
419
+ Args:
420
+ poly_lines: List of encoded polyline strings
421
+
422
+ Returns:
423
+ Merged encoded polyline string
424
+ """
425
+ if not poly_lines:
426
+ return ""
427
+
428
+ all_points = []
429
+ for pl in poly_lines:
430
+ if pl: # ✅ 改進: 跳過空字符串
431
+ points = polyline.decode(pl)
432
+ all_points.extend(points)
433
+
434
+ return polyline.encode(all_points) if all_points else ""
435
+
436
+ # ========================================================================
437
+ # Multi-leg Route Computation
438
+ # ========================================================================
439
+
440
+ def compute_routes(
441
+ self,
442
+ place_points: List[Dict[str, float]],
443
+ start_time: Union[datetime, str],
444
+ stop_times: List[int],
445
+ travel_mode: Union[str, List[str]] = "DRIVE",
446
+ routing_preference: str = "TRAFFIC_AWARE_OPTIMAL"
447
+ ) -> Dict[str, Any]:
448
+ """
449
+ Compute a detailed multi-stop route. Supports MIXED travel modes.
450
+
451
+ Args:
452
+ place_points: Ordered list of locations. Min 2 points.
453
+ start_time: "now" or datetime object.
454
+ stop_times: List of stay durations (minutes).
455
+ CRITICAL: Length must match len(place_points).
456
+ e.g., [0, 60, 30, 0] means:
457
+ - Start at P0 (stay 0)
458
+ - Arrive P1 (stay 60) -> Depart P1
459
+ - Arrive P2 (stay 30) -> Depart P2
460
+ - Arrive P3 (End)
461
+ travel_mode: Single string or List of strings.
462
+ routing_preference: "TRAFFIC_AWARE_OPTIMAL" (ignored for non-DRIVE modes).
463
+
464
+ Returns:
465
+ Dict: Route summary.
466
+ """
467
+
468
+ if len(place_points) < 2:
469
+ raise ValueError("At least 2 places required for route computation")
470
+
471
+ # ✅ 強制驗證:stop_times 長度必須與地點數量一致,避免邏輯混亂
472
+ if len(stop_times) != len(place_points):
473
+ raise ValueError(
474
+ f"stop_times length ({len(stop_times)}) must match "
475
+ f"place_points length ({len(place_points)})"
476
+ )
477
+
478
+ num_legs = len(place_points) - 1
479
+
480
+ # 驗證 travel_mode 列表長度
481
+ if isinstance(travel_mode, list):
482
+ if len(travel_mode) != num_legs:
483
+ raise ValueError(f"travel_mode list length ({len(travel_mode)}) must match legs count ({num_legs})")
484
+
485
+ # 處理開始時間
486
+ if start_time == "now" or start_time is None:
487
+ start_time = datetime.now(timezone.utc)
488
+ current_time = datetime.now(timezone.utc)
489
+ elif isinstance(start_time, datetime):
490
+ current_time = start_time.astimezone(timezone.utc)
491
+ else:
492
+ raise ValueError(f"Invalid start_time type: {type(start_time)}")
493
+
494
+ legs_info = []
495
+ total_distance = 0
496
+ total_duration = 0
497
+ place_points = clean_dict_list_keys(place_points)
498
+
499
+ logger.debug(f"🚦 Computing route. Points: {len(place_points)}, Modes: {travel_mode}")
500
+
501
+ for i in range(num_legs):
502
+ origin = place_points[i]
503
+ destination = place_points[i + 1]
504
+
505
+ # 1. 決定當前模式
506
+ if isinstance(travel_mode, list):
507
+ current_mode = travel_mode[i].upper()
508
+ else:
509
+ current_mode = travel_mode.upper()
510
+
511
+ # 2. 決定 Traffic Preference
512
+ if current_mode in ["DRIVE", "TWO_WHEELER"]:
513
+ current_preference = None if routing_preference == "UNDRIVE" else routing_preference.upper()
514
+ else:
515
+ current_preference = None
516
+
517
+ departure_time = current_time if i > 0 else None
518
+
519
+ try:
520
+ route = self._compute_routes(
521
+ origin=origin,
522
+ destination=destination,
523
+ waypoints=None,
524
+ travel_mode=current_mode,
525
+ routing_preference=current_preference,
526
+ include_traffic_on_polyline=True,
527
+ departure_time=departure_time
528
+ )
529
+
530
+ leg_distance = route["distance_meters"]
531
+ leg_duration = route["duration_seconds"]
532
+
533
+ total_distance += leg_distance
534
+ total_duration += leg_duration
535
+
536
+ legs_info.append({
537
+ "from_index": i,
538
+ "to_index": i + 1,
539
+ "travel_mode": current_mode,
540
+ "distance_meters": leg_distance,
541
+ "duration_seconds": leg_duration,
542
+ "departure_time": current_time.isoformat(),
543
+ "polyline": route["encoded_polyline"]
544
+ })
545
+
546
+ current_time += timedelta(seconds=leg_duration)
547
+
548
+ stop_duration = stop_times[i + 1]
549
+
550
+ current_time += timedelta(minutes=stop_duration)
551
+
552
+ except Exception as e:
553
+ logger.error(f"❌ Leg {i + 1} computation failed: {e}")
554
+ raise e
555
+
556
+ return {
557
+ "total_distance_meters": total_distance,
558
+ "total_duration_seconds": total_duration, # 純交通時間
559
+ "total_residence_time_minutes": sum(stop_times), # 總停留時間
560
+ "total_time_seconds": int((current_time - start_time).total_seconds()), # 總行程時間 (含交通+停留)
561
+ "start_time": start_time,
562
+ "end_time": current_time,
563
+ "stops": place_points,
564
+ "legs": legs_info,
565
+ }
566
+
567
+
568
+ # ========================================================================
569
+ # Distance Matrix API
570
+ # ========================================================================
571
+
572
+ def _compute_route_matrix(
573
+ self,
574
+ origins: List[Dict[str, float]],
575
+ destinations: List[Dict[str, float]],
576
+ travel_mode: str = "DRIVE",
577
+ routing_preference: str = "TRAFFIC_UNAWARE",
578
+ departure_time: Optional[datetime] = None
579
+ ) -> Dict[str, Any]:
580
+ """
581
+ Internal method to compute distance matrix
582
+
583
+ Args:
584
+ origins: List of origin location dicts
585
+ destinations: List of destination location dicts
586
+ travel_mode: Travel mode
587
+ routing_preference: Routing preference
588
+ departure_time: Departure time
589
+
590
+ Returns:
591
+ API response with distance matrix data
592
+
593
+ Raises:
594
+ requests.RequestException: If API request fails
595
+ """
596
+ request_body = {
597
+ "origins": [
598
+ {
599
+ "waypoint": {
600
+ "location": {
601
+ "latLng": {
602
+ "latitude": origin["lat"],
603
+ "longitude": origin["lng"]
604
+ }
605
+ }
606
+ }
607
+ }
608
+ for origin in origins
609
+ ],
610
+ "destinations": [
611
+ {
612
+ "waypoint": {
613
+ "location": {
614
+ "latLng": {
615
+ "latitude": dest["lat"],
616
+ "longitude": dest["lng"]
617
+ }
618
+ }
619
+ }
620
+ }
621
+ for dest in destinations
622
+ ],
623
+ "travelMode": travel_mode.upper(),
624
+ "routingPreference": routing_preference.upper()
625
+ }
626
+
627
+ # ✅ 添加 departure_time
628
+ if departure_time:
629
+ if isinstance(departure_time, datetime):
630
+ request_body["departureTime"] = departure_time.isoformat()
631
+ elif departure_time == "now":
632
+ request_body["departureTime"] = datetime.now(timezone.utc).isoformat()
633
+
634
+ field_mask = "originIndex,destinationIndex,distanceMeters,duration"
635
+
636
+ headers = {
637
+ "Content-Type": "application/json",
638
+ "X-Goog-Api-Key": self.api_key,
639
+ "X-Goog-FieldMask": field_mask
640
+ }
641
+
642
+ logger.debug(
643
+ f"📏 Computing route matrix: {len(origins)} × {len(destinations)}"
644
+ )
645
+
646
+ try:
647
+ response = requests.post(
648
+ self.compute_route_matrix_url,
649
+ json=request_body,
650
+ headers=headers,
651
+ timeout=30
652
+ )
653
+ response.raise_for_status()
654
+ data = response.json()
655
+
656
+ if "error" in data:
657
+ raise ValueError(f"API Error: {data['error']}")
658
+
659
+ if isinstance(data, list):
660
+ return data
661
+ elif "results" in data:
662
+ return data["results"]
663
+ else:
664
+ # 兜底:返回空數組
665
+ logger.warning(f"⚠️ Unexpected Distance Matrix response format: {data.keys()}")
666
+ return []
667
+
668
+ except requests.Timeout:
669
+ logger.error("⏰ Route matrix request timeout")
670
+ raise
671
+ except requests.RequestException as e:
672
+ logger.error(f"❌ Route matrix request failed: {e}")
673
+ raise e
674
+ except Exception as e:
675
+ logger.error(f"❌ Unexpected error in _compute_route_matrix: {e}")
676
+ raise
677
+
678
+ def _get_max_elements(self, travel_mode: str, routing_preference: str) -> int:
679
+ """
680
+ Get max elements per request based on travel mode and preference
681
+
682
+ Args:
683
+ travel_mode: Travel mode
684
+ routing_preference: Routing preference
685
+
686
+ Returns:
687
+ Max elements allowed
688
+ """
689
+ if travel_mode.upper() == "TRANSIT":
690
+ return 100
691
+ if routing_preference and routing_preference.upper() == "TRAFFIC_AWARE_OPTIMAL":
692
+ return 100
693
+ return 625
694
+
695
+ def compute_route_matrix(
696
+ self,
697
+ origins: List[Dict[str, float]],
698
+ destinations: List[Dict[str, float]],
699
+ travel_mode: str = "DRIVE",
700
+ routing_preference: Optional[str] = None,
701
+ departure_time: Optional[datetime] = None,
702
+ ) -> Dict[str, np.ndarray]:
703
+ """
704
+ ✅ P1-1 修復: 正確調用 _compute_route_matrix
705
+
706
+ Compute distance and duration matrix between multiple locations
707
+
708
+ Args:
709
+ origins: List of origin location dicts with 'lat' and 'lng'
710
+ destinations: List of destination location dicts with 'lat' and 'lng'
711
+ travel_mode: Travel mode (DRIVE, WALK, BICYCLE, TRANSIT)
712
+ routing_preference: Routing preference (optional)
713
+ departure_time: Departure time as datetime object (optional)
714
+
715
+ Returns:
716
+ Dict with numpy arrays:
717
+ - duration_matrix: Duration in seconds (shape: O×D)
718
+ - distance_matrix: Distance in meters (shape: O×D)
719
+
720
+ Raises:
721
+ ValueError: If input is invalid
722
+ requests.RequestException: If API request fails
723
+ """
724
+ if not origins or not destinations:
725
+ raise ValueError("Origins and destinations cannot be empty")
726
+
727
+ max_elements = self._get_max_elements(travel_mode, routing_preference or "")
728
+
729
+ O_len, D_len = len(origins), len(destinations)
730
+ duration_matrix = np.full((O_len, D_len), -1, dtype=np.int32)
731
+ distance_matrix = np.full((O_len, D_len), -1, dtype=np.int32)
732
+
733
+ logger.info(
734
+ f"📊 Computing {O_len}×{D_len} matrix "
735
+ f"(max_elements={max_elements})"
736
+ )
737
+
738
+ # ✅ 分批處理
739
+ for o_start in range(0, O_len, MAX_LOCATIONS_PER_SIDE):
740
+ o_end = min(o_start + MAX_LOCATIONS_PER_SIDE, O_len)
741
+ origin_batch = origins[o_start:o_end]
742
+ origin_batch_size = len(origin_batch)
743
+
744
+ max_dest_batch_size = max(
745
+ 1,
746
+ min(
747
+ MAX_LOCATIONS_PER_SIDE,
748
+ min(D_len, max_elements // origin_batch_size)
749
+ ),
750
+ )
751
+
752
+ for d_start in range(0, D_len, max_dest_batch_size):
753
+ d_end = min(d_start + max_dest_batch_size, D_len)
754
+ dest_batch = destinations[d_start:d_end]
755
+
756
+ try:
757
+ elements = self._compute_route_matrix(
758
+ origins=origin_batch,
759
+ destinations=dest_batch,
760
+ travel_mode=travel_mode,
761
+ routing_preference=routing_preference or "TRAFFIC_UNAWARE",
762
+ departure_time=departure_time,
763
+ )
764
+
765
+ if not elements:
766
+ logger.warning(
767
+ f"⚠️ No elements in batch "
768
+ f"[{o_start}:{o_end}, {d_start}:{d_end}]"
769
+ )
770
+ continue
771
+
772
+ for el in elements:
773
+ oi = o_start + el["originIndex"]
774
+ di = d_start + el["destinationIndex"]
775
+
776
+ # ✅ 解析時間 (格式: "300s")
777
+ duration_str = el.get("duration", "0s")
778
+ duration_seconds = int(duration_str.rstrip("s"))
779
+
780
+ # ✅ 解析距離
781
+ distance_meters = el.get("distanceMeters", 0)
782
+
783
+ duration_matrix[oi, di] = duration_seconds
784
+ distance_matrix[oi, di] = distance_meters
785
+
786
+ except Exception as e:
787
+ logger.error(
788
+ f"❌ Failed to compute matrix batch "
789
+ f"[{o_start}:{o_end}, {d_start}:{d_end}]: {e}"
790
+ )
791
+ raise e
792
+
793
+ logger.info(
794
+ f"✅ Route matrix computed: "
795
+ f"{np.sum(duration_matrix >= 0)} / {O_len * D_len} elements"
796
+ )
797
+
798
+ return {
799
+ "duration_matrix": duration_matrix,
800
+ "distance_matrix": distance_matrix,
801
+ }
802
+
803
+
804
+ # ============================================================================
805
+ # Usage Example
806
+ # ============================================================================
807
+
808
+ if __name__ == "__main__":
809
+ import os
810
+ from src.infra.config import get_settings
811
+ # Initialize service
812
+ setting = get_settings()
813
+ api_key = setting.google_maps_api_key
814
+ if not api_key:
815
+ print("❌ Please set GOOGLE_MAPS_API_KEY environment variable")
816
+ exit(1)
817
+
818
+ service = GoogleMapAPIService(api_key=api_key)
819
+
820
+ # Example 1: Current location
821
+ try:
822
+ print("\n" + "="*60)
823
+ print("Example 1: Current Location")
824
+ print("="*60)
825
+ location = service.current_location()
826
+ print(f"📍 Current: {location['location']}")
827
+ except Exception as e:
828
+ print(f"❌ Failed: {e}")
829
+
830
+ # Example 2: Text search
831
+ try:
832
+ print("\n" + "="*60)
833
+ print("Example 2: Text Search")
834
+ print("="*60)
835
+ places = service.text_search(
836
+ query="台大醫院",
837
+ location={"lat": 25.0408, "lng": 121.5318},
838
+ radius=5000,
839
+ limit=3
840
+ )
841
+ for i, place in enumerate(places, 1):
842
+ print(f"{i}. {place['name']} - {place['address']}, {place['place_id']}")
843
+ except Exception as e:
844
+ print(f"❌ Failed: {e}")
845
+
846
+ # Example 3: Compute route
847
+ try:
848
+ print("\n" + "="*60)
849
+ print("Example 3: Compute Route")
850
+ print("="*60)
851
+
852
+ places = [
853
+ {"lat": 25.0408, "lng": 121.5318}, # 台北車站
854
+ {"lat": 25.0330, "lng": 121.5654}, # 台北101
855
+ {"lat": 25.0478, "lng": 121.5170}, # 西門町
856
+ ]
857
+
858
+ route = service.compute_routes(
859
+ place_points=places,
860
+ start_time=datetime.now(timezone.utc),
861
+ stop_times=[30], # 停留時間 (分鐘)
862
+ travel_mode="DRIVE",
863
+ routing_preference="TRAFFIC_AWARE" ##"TRAFFIC_AWARE_OPTIMAL"
864
+ )
865
+
866
+ print(f"📊 Total distance: {route['total_distance_meters']}m")
867
+ print(f"⏱️ Total duration: {route['total_duration_seconds']}s")
868
+ print(f"🏁 End time: {route['end_time']}")
869
+
870
+ except Exception as e:
871
+ print(f"❌ Failed: {e}")
872
+ raise e
873
+
874
+ # Example 4: Distance matrix
875
+ try:
876
+ print("\n" + "="*60)
877
+ print("Example 4: Distance Matrix")
878
+ print("="*60)
879
+
880
+ origins = [
881
+ {"lat": 25.0408, "lng": 121.5318},
882
+ {"lat": 25.0330, "lng": 121.5654},
883
+ ]
884
+
885
+ destinations = [
886
+ {"lat": 25.0478, "lng": 121.5170},
887
+ {"lat": 25.0360, "lng": 121.5000},
888
+ ]
889
+
890
+ matrices = service.compute_route_matrix(
891
+ origins=origins,
892
+ destinations=destinations,
893
+ travel_mode="WALK"
894
+ )
895
+
896
+ print("Duration matrix (seconds):")
897
+ print(matrices["duration_matrix"])
898
+ print("\nDistance matrix (meters):")
899
+ print(matrices["distance_matrix"])
900
+
901
+ except Exception as e:
902
+ print(f"❌ Failed: {e}")
903
+
904
+ print("\n✅ All examples completed!")
src/services/openweather_service.py ADDED
@@ -0,0 +1,606 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenWeatherMap API Service - Improved Version
3
+
4
+ """
5
+
6
+ import requests
7
+ from typing import Dict, Any, Optional, List
8
+ from datetime import datetime, timezone, timedelta
9
+
10
+ from src.infra.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class OpenWeatherMapService:
16
+ """
17
+ OpenWeatherMap API Service
18
+
19
+ Provides unified interface to:
20
+ - Current Weather API
21
+ - 5 Day Forecast API (3-hour step)
22
+ - Air Pollution API (Current)
23
+ - Air Pollution Forecast API (Hourly)
24
+
25
+ Free tier limits:
26
+ - 1,000 calls/day
27
+ - 60 calls/minute
28
+ """
29
+
30
+ def __init__(self, api_key: Optional[str] = None):
31
+ """
32
+ Initialize OpenWeatherMap service
33
+
34
+ Args:
35
+ api_key: OpenWeatherMap API key
36
+
37
+ Raises:
38
+ ValueError: If API key is not provided
39
+ """
40
+ # ✅ 改進: 簡化 API key 獲取
41
+ if api_key:
42
+ self.api_key = api_key
43
+ else:
44
+ import os
45
+ self.api_key = os.getenv("OPENWEATHER_API_KEY")
46
+
47
+ # 如果環境變量沒有,嘗試從配置讀取
48
+ if not self.api_key:
49
+ try:
50
+ from src.infra.config import get_settings
51
+ settings = get_settings()
52
+ self.api_key = settings.openweather_api_key
53
+ except (ImportError, AttributeError):
54
+ pass
55
+
56
+ # ✅ 改進: 在 __init__ 時就檢查,後面不用重複檢查
57
+ if not self.api_key:
58
+ raise ValueError(
59
+ "OpenWeatherMap API key is required. " # ✅ 修復錯誤訊息
60
+ "Set via parameter, config, or OPENWEATHER_API_KEY env var"
61
+ )
62
+
63
+ # API endpoints
64
+ self.current_weather_url = "https://api.openweathermap.org/data/2.5/weather"
65
+ self.forecast_weather_url = "https://api.openweathermap.org/data/2.5/forecast"
66
+ self.air_pollution_url = "https://api.openweathermap.org/data/2.5/air_pollution"
67
+ self.forecast_air_pollution_url = "https://api.openweathermap.org/data/2.5/air_pollution/forecast"
68
+
69
+ logger.info("✅ OpenWeatherMap service initialized")
70
+
71
+ # ========================================================================
72
+ # Helper Functions
73
+ # ========================================================================
74
+
75
+ @staticmethod
76
+ def _compute_future_index(
77
+ future_minutes: int,
78
+ time_step_hours: int = 3
79
+ ) -> int:
80
+ """
81
+ Compute the index in forecast array for a future time
82
+
83
+ Args:
84
+ future_minutes: Minutes into the future
85
+ time_step_hours: Time step in hours (3 for weather, 1 for air pollution)
86
+
87
+ Returns:
88
+ Index in forecast array
89
+ """
90
+ future_hours = future_minutes / 60.0
91
+ index = int(future_hours / time_step_hours)
92
+ return index
93
+
94
+ @staticmethod
95
+ def _find_closest_forecast(
96
+ forecast_list: List[Dict],
97
+ target_time: datetime
98
+ ) -> Optional[Dict]:
99
+ """
100
+ Find the forecast entry closest to target time
101
+
102
+ Args:
103
+ forecast_list: List of forecast entries with 'timestamp' key
104
+ target_time: Target datetime
105
+
106
+ Returns:
107
+ Closest forecast entry or None
108
+ """
109
+ if not forecast_list:
110
+ return None
111
+
112
+ closest = min(
113
+ forecast_list,
114
+ key=lambda x: abs((x['timestamp'] - target_time).total_seconds())
115
+ )
116
+ return closest
117
+
118
+ # ========================================================================
119
+ # Current Weather API
120
+ # ========================================================================
121
+
122
+ def get_current_weather(
123
+ self,
124
+ location: Dict[str, float],
125
+ units: str = "metric",
126
+ lang: str = "zh_tw"
127
+ ) -> Dict[str, Any]:
128
+ """
129
+ Get current weather at location
130
+
131
+ Args:
132
+ location: Location dict with 'lat' and 'lng' keys
133
+ units: Units (metric/imperial/standard)
134
+ lang: Language code (zh_tw, en, etc.)
135
+
136
+ Returns:
137
+ Weather data dict with keys:
138
+ - condition: str (e.g., "Clear", "Rain")
139
+ - description: str (e.g., "clear sky")
140
+ - temperature: float (°C if metric)
141
+ - feels_like: float
142
+ - temp_min: float
143
+ - temp_max: float
144
+ - pressure: int (hPa)
145
+ - humidity: int (%)
146
+ - wind_speed: float (m/s if metric)
147
+ - wind_direction: int (degrees)
148
+ - cloudiness: int (%)
149
+ - rain_1h: float (mm)
150
+ - timestamp: datetime
151
+
152
+ Raises:
153
+ requests.RequestException: If API request fails
154
+
155
+ Reference:
156
+ https://openweathermap.org/current
157
+ """
158
+ try:
159
+ params = {
160
+ "lat": location["lat"],
161
+ "lon": location["lng"],
162
+ "appid": self.api_key,
163
+ "units": units,
164
+ "lang": lang
165
+ }
166
+
167
+ logger.debug(
168
+ f"🌤️ Getting current weather for "
169
+ f"({location['lat']:.4f}, {location['lng']:.4f})"
170
+ )
171
+
172
+ response = requests.get(
173
+ self.current_weather_url,
174
+ params=params,
175
+ timeout=10
176
+ )
177
+
178
+ response.raise_for_status()
179
+ data = response.json()
180
+
181
+ # ✅ 檢查錯誤
182
+ if "cod" in data and data["cod"] != 200:
183
+ raise ValueError(f"API Error: {data.get('message', 'Unknown error')}")
184
+
185
+ # Parse response
186
+ weather = self._parse_current_weather(data)
187
+
188
+ logger.info(
189
+ f"✅ Current weather: {weather['condition']}, "
190
+ f"{weather['temperature']}°C"
191
+ )
192
+
193
+ return weather
194
+
195
+ except requests.Timeout:
196
+ logger.error("⏰ Weather API request timeout")
197
+ raise
198
+ except requests.RequestException as e:
199
+ logger.error(f"❌ Weather API request error: {e}")
200
+ raise
201
+ except Exception as e:
202
+ logger.error(f"❌ Error getting current weather: {e}")
203
+ raise
204
+
205
+ @staticmethod
206
+ def _parse_current_weather(data: Dict[str, Any]) -> Dict[str, Any]:
207
+ """Parse current weather API response"""
208
+ weather_list = data.get("weather", [])
209
+ main_weather = weather_list[0] if weather_list else {}
210
+
211
+ main_data = data.get("main", {})
212
+ wind_data = data.get("wind", {})
213
+ clouds_data = data.get("clouds", {})
214
+ rain_data = data.get("rain", {})
215
+
216
+ return {
217
+ "condition": main_weather.get("main", "Unknown"),
218
+ "description": main_weather.get("description", ""),
219
+ "temperature": main_data.get("temp", 0),
220
+ "feels_like": main_data.get("feels_like", 0),
221
+ "temp_min": main_data.get("temp_min", 0),
222
+ "temp_max": main_data.get("temp_max", 0),
223
+ "pressure": main_data.get("pressure", 0),
224
+ "humidity": main_data.get("humidity", 0),
225
+ "wind_speed": wind_data.get("speed", 0),
226
+ "wind_direction": wind_data.get("deg", 0),
227
+ "cloudiness": clouds_data.get("all", 0),
228
+ "rain_1h": rain_data.get("1h", 0),
229
+ "timestamp": datetime.fromtimestamp(
230
+ data.get("dt", 0),
231
+ tz=timezone.utc
232
+ )
233
+ }
234
+
235
+ # ========================================================================
236
+ # 5 Day Forecast API (3-hour step)
237
+ # ========================================================================
238
+ @staticmethod
239
+ def _parse_forecast_weather(data: Dict[str, Any]) -> Dict[str, Any]:
240
+ """Parse 5-day forecast API response"""
241
+ forecast_list = data.get("list", [])
242
+
243
+ hourly = []
244
+ for item in forecast_list:
245
+ weather_list = item.get("weather", [])
246
+ main_weather = weather_list[0] if weather_list else {}
247
+
248
+ main_data = item.get("main", {})
249
+ wind_data = item.get("wind", {})
250
+ rain_data = item.get("rain", {})
251
+
252
+ hourly.append({
253
+ "timestamp": datetime.fromtimestamp(
254
+ item.get("dt", 0),
255
+ tz=timezone.utc
256
+ ),
257
+ "condition": main_weather.get("main", "Unknown"),
258
+ "description": main_weather.get("description", ""),
259
+ "temperature": main_data.get("temp", 0),
260
+ "feels_like": main_data.get("feels_like", 0),
261
+ "humidity": main_data.get("humidity", 0),
262
+ "wind_speed": wind_data.get("speed", 0),
263
+ "rain_3h": rain_data.get("3h", 0),
264
+ "pop": item.get("pop", 0) # Probability of precipitation
265
+ })
266
+
267
+ return {
268
+ "hourly": hourly,
269
+ "city": data.get("city", {})
270
+ }
271
+
272
+ # ========================================================================
273
+ # Current Air Pollution API
274
+ # ========================================================================
275
+
276
+ def get_air_pollution(
277
+ self,
278
+ location: Dict[str, float]
279
+ ) -> Dict[str, Any]:
280
+ """
281
+ Get current air pollution data
282
+
283
+ Args:
284
+ location: Location dict with 'lat' and 'lng' keys
285
+
286
+ Returns:
287
+ Air quality dict with keys:
288
+ - aqi: int (1-5, 1=Good, 5=Very Poor)
289
+ - aqi_label: str ("Good", "Fair", etc.)
290
+ - co: float (Carbon monoxide, μg/m³)
291
+ - no: float (Nitrogen monoxide, μg/m³)
292
+ - no2: float (Nitrogen dioxide, μg/m³)
293
+ - o3: float (Ozone, μg/m³)
294
+ - so2: float (Sulphur dioxide, μg/m³)
295
+ - pm2_5: float (Fine particles, μg/m³)
296
+ - pm10: float (Coarse particles, μg/m³)
297
+ - nh3: float (Ammonia, μg/m³)
298
+ - timestamp: datetime
299
+
300
+ Raises:
301
+ requests.RequestException: If API request fails
302
+
303
+ Reference:
304
+ https://openweathermap.org/api/air-pollution
305
+ """
306
+ try:
307
+ params = {
308
+ "lat": location["lat"],
309
+ "lon": location["lng"],
310
+ "appid": self.api_key
311
+ }
312
+
313
+ logger.debug(
314
+ f"🌫️ Getting air pollution for "
315
+ f"({location['lat']:.4f}, {location['lng']:.4f})"
316
+ )
317
+
318
+ response = requests.get(
319
+ self.air_pollution_url,
320
+ params=params,
321
+ timeout=10
322
+ )
323
+
324
+ response.raise_for_status()
325
+ data = response.json()
326
+
327
+ # Parse response
328
+ pollution = self._parse_air_pollution(data)
329
+
330
+ logger.info(
331
+ f"✅ Air quality: AQI {pollution['aqi']} ({pollution['aqi_label']})"
332
+ )
333
+
334
+ return pollution
335
+
336
+ except requests.Timeout:
337
+ logger.error("⏰ Air Pollution API request timeout")
338
+ raise
339
+ except requests.RequestException as e:
340
+ logger.error(f"❌ Air Pollution API request error: {e}")
341
+ raise
342
+ except Exception as e:
343
+ logger.error(f"❌ Error getting air pollution: {e}")
344
+ raise
345
+
346
+ def _parse_air_pollution(self, data: Dict[str, Any]) -> Dict[str, Any]:
347
+ """Parse air pollution API response"""
348
+ list_data = data.get("list", [])
349
+ if not list_data:
350
+ return {
351
+ "aqi": 0,
352
+ "aqi_label": "Unknown",
353
+ "co": 0,
354
+ "no": 0,
355
+ "no2": 0,
356
+ "o3": 0,
357
+ "so2": 0,
358
+ "pm2_5": 0,
359
+ "pm10": 0,
360
+ "nh3": 0,
361
+ "timestamp": datetime.now(timezone.utc)
362
+ }
363
+
364
+ item = list_data[0]
365
+ main = item.get("main", {})
366
+ components = item.get("components", {})
367
+
368
+ # AQI levels: 1 = Good, 2 = Fair, 3 = Moderate, 4 = Poor, 5 = Very Poor
369
+ aqi = main.get("aqi", 0)
370
+ aqi_labels = {
371
+ 1: "Good",
372
+ 2: "Fair",
373
+ 3: "Moderate",
374
+ 4: "Poor",
375
+ 5: "Very Poor"
376
+ }
377
+
378
+ return {
379
+ "aqi": aqi,
380
+ "aqi_label": aqi_labels.get(aqi, "Unknown"),
381
+ "co": components.get("co", 0),
382
+ "no": components.get("no", 0),
383
+ "no2": components.get("no2", 0),
384
+ "o3": components.get("o3", 0),
385
+ "so2": components.get("so2", 0),
386
+ "pm2_5": components.get("pm2_5", 0),
387
+ "pm10": components.get("pm10", 0),
388
+ "nh3": components.get("nh3", 0),
389
+ "timestamp": datetime.fromtimestamp(
390
+ item.get("dt", 0),
391
+ tz=timezone.utc
392
+ )
393
+ }
394
+
395
+ # ========================================================================
396
+ # ✅ 新增: Air Pollution Forecast API (Hourly)
397
+ # ========================================================================
398
+
399
+ def get_forecast_weather(
400
+ self,
401
+ location: Dict[str, float],
402
+ future_minutes: int = 0,
403
+ ) -> Dict[str, Any]:
404
+ """
405
+ Get weather forecast for a specific time in the future.
406
+ Use this to check conditions for planned activities (e.g., "Will it rain at 3 PM?").
407
+
408
+ Args:
409
+ location: Target location {"lat": float, "lng": float}.
410
+ future_minutes: Minutes from NOW. (e.g., 0=now, 180=in 3 hours).
411
+
412
+ Returns:
413
+ Dict: Forecast data including:
414
+ - timestamp: datetime
415
+ - condition: str
416
+ - temperature: float
417
+ - pop: float (Probability of Precipitation 0-1)
418
+ """
419
+ try:
420
+ params = {
421
+ "lat": location["lat"],
422
+ "lon": location["lng"],
423
+ "appid": self.api_key,
424
+ "units": "metric",
425
+ "lang": "zh_tw"
426
+ }
427
+
428
+ response = requests.get(self.forecast_weather_url, params=params, timeout=10)
429
+ response.raise_for_status()
430
+ data = response.json()
431
+
432
+ if "cod" in data and data["cod"] != "200":
433
+ raise ValueError(f"API Error: {data.get('message', 'Unknown error')}")
434
+
435
+ forecast = self._parse_forecast_weather(data)
436
+
437
+ if future_minutes == 0:
438
+ return forecast['hourly'][0] if forecast['hourly'] else {}
439
+ else:
440
+ target_time = datetime.now(timezone.utc) + timedelta(minutes=future_minutes)
441
+ closest = self._find_closest_forecast(forecast['hourly'], target_time)
442
+ return closest if closest else {}
443
+
444
+ except Exception as e:
445
+ logger.error(f"❌ Error getting forecast: {e}")
446
+ raise
447
+
448
+ @staticmethod
449
+ def _parse_forecast_air_pollution(data: Dict[str, Any]) -> Dict[str, Any]:
450
+ """
451
+ ✅ 新增方法: Parse air pollution forecast API response
452
+ """
453
+ list_data = data.get("list", [])
454
+
455
+ hourly = []
456
+ aqi_labels = {
457
+ 1: "Good",
458
+ 2: "Fair",
459
+ 3: "Moderate",
460
+ 4: "Poor",
461
+ 5: "Very Poor"
462
+ }
463
+
464
+ for item in list_data:
465
+ main = item.get("main", {})
466
+ components = item.get("components", {})
467
+ aqi = main.get("aqi", 0)
468
+
469
+ hourly.append({
470
+ "timestamp": datetime.fromtimestamp(
471
+ item.get("dt", 0),
472
+ tz=timezone.utc
473
+ ),
474
+ "aqi": aqi,
475
+ "aqi_label": aqi_labels.get(aqi, "Unknown"),
476
+ "co": components.get("co", 0),
477
+ "no": components.get("no", 0),
478
+ "no2": components.get("no2", 0),
479
+ "o3": components.get("o3", 0),
480
+ "so2": components.get("so2", 0),
481
+ "pm2_5": components.get("pm2_5", 0),
482
+ "pm10": components.get("pm10", 0),
483
+ "nh3": components.get("nh3", 0),
484
+ })
485
+
486
+ return {
487
+ "hourly": hourly
488
+ }
489
+
490
+ def get_forecast_air_pollution(
491
+ self,
492
+ location: Dict[str, float],
493
+ future_minutes: int = 0
494
+ ) -> Dict[str, Any]:
495
+ """
496
+ Get air quality (AQI) forecast for a future time.
497
+
498
+ Args:
499
+ location: Target location {"lat": float, "lng": float}.
500
+ future_minutes: Minutes from NOW.
501
+
502
+ Returns:
503
+ Dict: Air quality data including:
504
+ - aqi: int (1=Good, 5=Very Poor)
505
+ - pm2_5: float
506
+ - timestamp: datetime
507
+ """
508
+ try:
509
+ params = {
510
+ "lat": location["lat"],
511
+ "lon": location["lng"],
512
+ "appid": self.api_key
513
+ }
514
+
515
+ response = requests.get(self.forecast_air_pollution_url, params=params, timeout=10)
516
+ response.raise_for_status()
517
+ data = response.json()
518
+
519
+ forecast = self._parse_forecast_air_pollution(data)
520
+
521
+ if future_minutes == 0:
522
+ return forecast['hourly'][0] if forecast['hourly'] else {}
523
+ else:
524
+ target_time = datetime.now(timezone.utc) + timedelta(minutes=future_minutes)
525
+ closest = self._find_closest_forecast(forecast['hourly'], target_time)
526
+ return closest if closest else {}
527
+
528
+ except Exception as e:
529
+ logger.error(f"❌ Error getting air pollution forecast: {e}")
530
+ raise
531
+
532
+
533
+ # ============================================================================
534
+ # Usage Examples
535
+ # ============================================================================
536
+
537
+ if __name__ == "__main__":
538
+ import os
539
+ from src.infra.config import get_settings
540
+ settings = get_settings()
541
+
542
+ # Initialize service
543
+ api_key = settings.openweather_api_key
544
+ if not api_key:
545
+ print("❌ Please set OPENWEATHER_API_KEY environment variable")
546
+ print(" Get your free API key at: https://openweathermap.org/api")
547
+ exit(1)
548
+
549
+ service = OpenWeatherMapService(api_key=api_key)
550
+
551
+ # Test location (Taipei 101)
552
+ taipei_101 = {"lat": 25.0330, "lng": 121.5654}
553
+
554
+ # Example 1: Current weather
555
+ try:
556
+ print("\n" + "="*60)
557
+ print("Example 1: Current Weather")
558
+ print("="*60)
559
+ weather = service.get_current_weather(taipei_101)
560
+ print(f"🌡️ Temperature: {weather['temperature']}°C")
561
+ print(f"💧 Humidity: {weather['humidity']}%")
562
+ print(f"🌤️ Condition: {weather['condition']} ({weather['description']})")
563
+ print(f"💨 Wind: {weather['wind_speed']} m/s")
564
+ except Exception as e:
565
+ print(f"❌ Failed: {e}")
566
+
567
+ # Example 2: Weather forecast (3 hours from now)
568
+ try:
569
+ print("\n" + "="*60)
570
+ print("Example 2: Weather Forecast (3 hours)")
571
+ print("="*60)
572
+ forecast = service.get_forecast_weather(taipei_101, future_minutes=180)
573
+ print(f"⏰ Time: {forecast['timestamp']}")
574
+ print(f"🌡️ Temperature: {forecast['temperature']}°C")
575
+ print(f"🌧️ Precipitation probability: {forecast['pop']*100:.0f}%")
576
+ print(f"🌤️ Condition: {forecast['condition']}")
577
+ except Exception as e:
578
+ print(f"❌ Failed: {e}")
579
+
580
+ # Example 3: Current air quality
581
+ try:
582
+ print("\n" + "="*60)
583
+ print("Example 3: Current Air Quality")
584
+ print("="*60)
585
+ air = service.get_air_pollution(taipei_101)
586
+ print(f"🌫️ AQI: {air['aqi']} ({air['aqi_label']})")
587
+ print(f" PM2.5: {air['pm2_5']:.1f} μg/m³")
588
+ print(f" PM10: {air['pm10']:.1f} μg/m³")
589
+ print(f" O3: {air['o3']:.1f} μg/m³")
590
+ except Exception as e:
591
+ print(f"❌ Failed: {e}")
592
+
593
+ # Example 4: Air quality forecast (6 hours from now)
594
+ try:
595
+ print("\n" + "="*60)
596
+ print("Example 4: Air Quality Forecast (6 hours)")
597
+ print("="*60)
598
+ forecast_air = service.get_forecast_air_pollution(taipei_101, future_minutes=360)
599
+ print(f"⏰ Time: {forecast_air['timestamp']}")
600
+ print(f"🌫️ AQI: {forecast_air['aqi']} ({forecast_air['aqi_label']})")
601
+ print(f" PM2.5: {forecast_air['pm2_5']:.1f} μg/m³")
602
+ print(f" PM10: {forecast_air['pm10']:.1f} μg/m³")
603
+ except Exception as e:
604
+ print(f"❌ Failed: {e}")
605
+
606
+ print("\n✅ All examples completed!")