-
Notifications
You must be signed in to change notification settings - Fork 0
Add AEMET integration for /api/conditions with Open‑Meteo fallback; update README and add frontend lockfile #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,12 +8,13 @@ | |
| from pydantic import BaseModel, Field | ||
| from typing import List, Optional, Dict, Any, Tuple | ||
| import uuid | ||
| from datetime import datetime, timezone | ||
| from datetime import datetime, timezone, timedelta | ||
| import httpx | ||
| import asyncio | ||
| import json | ||
| import re | ||
| import math | ||
| from urllib.parse import urlencode | ||
| from shapely.geometry import Point, Polygon, LineString | ||
| from shapely.ops import unary_union | ||
|
|
||
|
|
@@ -735,6 +736,133 @@ def compute_flight_score(wind_speed: float, visibility_km: float, weather_code: | |
| return score, "not_recommended" | ||
|
|
||
|
|
||
| AEMET_API_KEY = os.getenv("AEMET_API_KEY") | ||
| AEMET_BASE_URL = "https://opendata.aemet.es/opendata/api" | ||
| _AEMET_STATIONS_CACHE: Dict[str, Any] = {"stations": None, "expires_at": None} | ||
|
|
||
|
|
||
| def to_float(value: Any, default: float) -> float: | ||
| try: | ||
| return float(value) | ||
| except (TypeError, ValueError): | ||
| return default | ||
|
|
||
|
|
||
| def parse_aemet_coordinate(value: str, is_latitude: bool) -> Optional[float]: | ||
| """Parse AEMET compact DMS format (e.g., 412342N, 0021034W).""" | ||
| if not value: | ||
| return None | ||
|
|
||
| text = value.strip().upper() | ||
| match = re.match(r"^(\d+)([NSEW])$", text) | ||
| if not match: | ||
| return None | ||
|
|
||
| raw_digits, hemisphere = match.groups() | ||
| expected_length = 6 if is_latitude else 7 | ||
| if len(raw_digits) != expected_length: | ||
| return None | ||
|
|
||
| deg_len = 2 if is_latitude else 3 | ||
| degrees = int(raw_digits[:deg_len]) | ||
| minutes = int(raw_digits[deg_len:deg_len + 2]) | ||
| seconds = int(raw_digits[deg_len + 2:deg_len + 4]) | ||
|
|
||
| decimal = degrees + (minutes / 60.0) + (seconds / 3600.0) | ||
| if hemisphere in {"S", "W"}: | ||
| decimal *= -1 | ||
|
|
||
| return decimal | ||
|
|
||
|
|
||
| async def fetch_aemet_dataset(path: str, client: httpx.AsyncClient) -> Any: | ||
| """AEMET API uses a two-step retrieval: metadata URL then dataset URL.""" | ||
| if not AEMET_API_KEY: | ||
| raise ValueError("AEMET_API_KEY is not configured") | ||
|
|
||
| params = urlencode({"api_key": AEMET_API_KEY}) | ||
| metadata_url = f"{AEMET_BASE_URL}{path}?{params}" | ||
| metadata_response = await client.get(metadata_url) | ||
| metadata_response.raise_for_status() | ||
| metadata = metadata_response.json() | ||
|
|
||
| data_url = metadata.get("datos") | ||
| if not data_url: | ||
| raise ValueError("AEMET response does not include a data URL") | ||
|
|
||
| dataset_response = await client.get(data_url) | ||
| dataset_response.raise_for_status() | ||
| return dataset_response.json() | ||
|
|
||
|
|
||
| async def get_aemet_stations(client: httpx.AsyncClient) -> List[Dict[str, Any]]: | ||
| """Get and cache AEMET stations for 6 hours.""" | ||
| now = datetime.now(timezone.utc) | ||
| cached_stations = _AEMET_STATIONS_CACHE.get("stations") | ||
| expires_at = _AEMET_STATIONS_CACHE.get("expires_at") | ||
|
|
||
| if cached_stations and expires_at and now < expires_at: | ||
| return cached_stations | ||
|
|
||
| stations_raw = await fetch_aemet_dataset("/valores/climatologicos/inventarioestaciones/todasestaciones", client) | ||
| stations: List[Dict[str, Any]] = [] | ||
|
|
||
| for station in stations_raw: | ||
| station_lat = parse_aemet_coordinate(station.get("latitud"), is_latitude=True) | ||
| station_lng = parse_aemet_coordinate(station.get("longitud"), is_latitude=False) | ||
| if station_lat is None or station_lng is None: | ||
| continue | ||
| stations.append({ | ||
| "indicativo": station.get("indicativo"), | ||
| "nombre": station.get("nombre", "Estación AEMET"), | ||
| "lat": station_lat, | ||
| "lng": station_lng, | ||
| }) | ||
|
|
||
| _AEMET_STATIONS_CACHE["stations"] = stations | ||
| _AEMET_STATIONS_CACHE["expires_at"] = now.replace(microsecond=0) + timedelta(hours=6) | ||
| return stations | ||
|
|
||
|
|
||
| def parse_aemet_weather_description(observation: Dict[str, Any]) -> str: | ||
| if observation.get("prec") not in (None, "Ip", ""): # "Ip" = inappreciable precipitation | ||
| return "Rain observed" | ||
|
|
||
| humidity = observation.get("hr") | ||
| try: | ||
| if humidity is not None and float(humidity) > 90: | ||
| return "High humidity" | ||
| except (TypeError, ValueError): | ||
| pass | ||
|
|
||
| return "Stable conditions" | ||
|
|
||
|
|
||
| def parse_aemet_observation(observation: Dict[str, Any], lat: float, lng: float) -> FlightCondition: | ||
| temperature_c = to_float(observation.get("ta"), 20.0) | ||
| wind_speed = to_float(observation.get("v"), 0.0) | ||
| wind_direction = to_float(observation.get("dv"), 0.0) | ||
| visibility_km = to_float(observation.get("vis"), 10000.0) / 1000.0 | ||
|
|
||
| weather_code = 63 if observation.get("prec") not in (None, "Ip", "") else 1 | ||
| score, recommendation = compute_flight_score(wind_speed, visibility_km, weather_code) | ||
| runway_heading = (wind_direction + 180) % 360 | ||
|
|
||
| return FlightCondition( | ||
| lat=lat, | ||
| lng=lng, | ||
| temperature_c=temperature_c, | ||
| weather_description=parse_aemet_weather_description(observation), | ||
| wind_speed_ms=wind_speed, | ||
| wind_direction_deg=wind_direction, | ||
| visibility_km=visibility_km, | ||
| flight_score=score, | ||
| recommendation=recommendation, | ||
| takeoff_heading_deg=runway_heading, | ||
| landing_heading_deg=runway_heading, | ||
| ) | ||
|
|
||
|
|
||
| # Ultra-Detailed Spanish Airspace Data + European Coverage | ||
| EUROPEAN_AIRSPACE_DATA = """ | ||
| * Ultra-Detailed Spanish Airspace Database for Paramotoring | ||
|
|
@@ -1742,43 +1870,63 @@ async def create_route(route_data: RouteCreate): | |
| async def get_flight_conditions(lat: float, lng: float): | ||
| """Get current weather/wind/visibility and flight recommendation for a location.""" | ||
| try: | ||
| weather_url = ( | ||
| "https://api.open-meteo.com/v1/forecast" | ||
| f"?latitude={lat}&longitude={lng}" | ||
| "¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m,visibility" | ||
| ) | ||
| async with httpx.AsyncClient(timeout=10) as client: | ||
| # Primary source: AEMET observations from nearest station | ||
| if AEMET_API_KEY: | ||
| try: | ||
| stations = await get_aemet_stations(client) | ||
| if stations: | ||
| nearest_station = min( | ||
| stations, | ||
| key=lambda station: haversine_distance(lat, lng, station["lat"], station["lng"]) | ||
| ) | ||
|
Comment on lines
+1879
to
+1882
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With Useful? React with 👍 / 👎. |
||
| station_id = nearest_station.get("indicativo") | ||
| if station_id: | ||
| observation_list = await fetch_aemet_dataset( | ||
| f"/observacion/convencional/datos/estacion/{station_id}", | ||
| client | ||
| ) | ||
| if isinstance(observation_list, list) and observation_list: | ||
| return parse_aemet_observation(observation_list[0], lat, lng) | ||
| except Exception as aemet_error: | ||
| logger.warning(f"AEMET unavailable, falling back to Open-Meteo: {aemet_error}") | ||
| else: | ||
| logger.info("AEMET_API_KEY not configured. Using Open-Meteo fallback for /api/conditions") | ||
|
|
||
| # Fallback source: Open-Meteo | ||
| weather_url = ( | ||
| "https://api.open-meteo.com/v1/forecast" | ||
| f"?latitude={lat}&longitude={lng}" | ||
| "¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m,visibility" | ||
| ) | ||
| response = await client.get(weather_url) | ||
| response.raise_for_status() | ||
| payload = response.json() | ||
|
|
||
| current = payload.get("current", {}) | ||
| temperature_c = float(current.get("temperature_2m", 20.0)) | ||
| weather_code = int(current.get("weather_code", 0)) | ||
| wind_speed = float(current.get("wind_speed_10m", 0.0)) | ||
| wind_direction = float(current.get("wind_direction_10m", 0.0)) | ||
| visibility_m = float(current.get("visibility", 10000.0)) | ||
| visibility_km = visibility_m / 1000.0 | ||
|
|
||
| score, recommendation = compute_flight_score(wind_speed, visibility_km, weather_code) | ||
|
|
||
| # Aviation rule of thumb: takeoff/landing against wind direction | ||
| takeoff_heading = (wind_direction + 180) % 360 | ||
| landing_heading = (wind_direction + 180) % 360 | ||
|
|
||
| return FlightCondition( | ||
| lat=lat, | ||
| lng=lng, | ||
| temperature_c=temperature_c, | ||
| weather_description=weather_code_to_text(weather_code), | ||
| wind_speed_ms=wind_speed, | ||
| wind_direction_deg=wind_direction, | ||
| visibility_km=visibility_km, | ||
| flight_score=score, | ||
| recommendation=recommendation, | ||
| takeoff_heading_deg=takeoff_heading, | ||
| landing_heading_deg=landing_heading, | ||
| ) | ||
| current = payload.get("current", {}) | ||
| temperature_c = float(current.get("temperature_2m", 20.0)) | ||
| weather_code = int(current.get("weather_code", 0)) | ||
| wind_speed = float(current.get("wind_speed_10m", 0.0)) | ||
| wind_direction = float(current.get("wind_direction_10m", 0.0)) | ||
| visibility_m = float(current.get("visibility", 10000.0)) | ||
| visibility_km = visibility_m / 1000.0 | ||
|
|
||
| score, recommendation = compute_flight_score(wind_speed, visibility_km, weather_code) | ||
| runway_heading = (wind_direction + 180) % 360 | ||
|
|
||
| return FlightCondition( | ||
| lat=lat, | ||
| lng=lng, | ||
| temperature_c=temperature_c, | ||
| weather_description=weather_code_to_text(weather_code), | ||
| wind_speed_ms=wind_speed, | ||
| wind_direction_deg=wind_direction, | ||
| visibility_km=visibility_km, | ||
| flight_score=score, | ||
| recommendation=recommendation, | ||
| takeoff_heading_deg=runway_heading, | ||
| landing_heading_deg=runway_heading, | ||
| ) | ||
| except Exception as e: | ||
| logger.error(f"Error getting flight conditions: {e}") | ||
| raise HTTPException(status_code=500, detail="Failed to get flight conditions") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This condition marks weather as rainy whenever
precis any non-empty value, so numeric zero values like0/0.0are treated as rain and mapped to code63. In those cases, dry observations are scored and recommended as worse-than-actual conditions, which skewsflight_scoreandrecommendation; precipitation should be parsed numerically and only considered rain when it is greater than zero (while still handlingIp).Useful? React with 👍 / 👎.