Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Aplicación con frontend en React y backend en FastAPI.

## Ejecutar backend

Variables recomendadas:
- `AEMET_API_KEY`: API key de AEMET (si no está definida, el backend usa Open-Meteo como fallback automático para `/api/conditions`).

```bash
cd backend
pip install -r requirements.txt
Expand Down
214 changes: 181 additions & 33 deletions backend/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse zero precipitation as non-rain

This condition marks weather as rainy whenever prec is any non-empty value, so numeric zero values like 0/0.0 are treated as rain and mapped to code 63. In those cases, dry observations are scored and recommended as worse-than-actual conditions, which skews flight_score and recommendation; precipitation should be parsed numerically and only considered rain when it is greater than zero (while still handling Ip).

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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}"
"&current=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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back when nearest AEMET station is out of range

With AEMET_API_KEY configured, this path always picks the mathematically nearest AEMET station and immediately uses it, but there is no maximum-distance check before returning. For coordinates outside AEMET coverage (or far from any station), /api/conditions will return weather from a distant station instead of the Open-Meteo fallback, producing incorrect flight recommendations for those locations.

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}"
"&current=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")
Expand Down
Loading