Improve the dahboard 4

This commit is contained in:
Urtzi Alfaro
2025-08-18 20:50:41 +02:00
parent 523fc663e8
commit 18355cd8be
10 changed files with 1133 additions and 152 deletions

View File

@@ -13,7 +13,9 @@ from app.schemas.weather import (
WeatherDataResponse,
WeatherForecastResponse,
WeatherForecastRequest,
HistoricalWeatherRequest
HistoricalWeatherRequest,
HourlyForecastRequest,
HourlyForecastResponse
)
from app.services.weather_service import WeatherService
from app.services.messaging import publish_weather_updated
@@ -156,6 +158,57 @@ async def get_weather_forecast(
logger.error("Failed to get weather forecast", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post("/tenants/{tenant_id}/weather/hourly-forecast", response_model=List[HourlyForecastResponse])
async def get_hourly_weather_forecast(
request: HourlyForecastRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
):
"""Get hourly weather forecast for location using AEMET API
This endpoint provides hourly weather predictions for up to 48 hours,
perfect for detailed bakery operations planning and weather-based recommendations.
"""
try:
logger.debug("Getting hourly weather forecast",
lat=request.latitude,
lon=request.longitude,
hours=request.hours,
tenant_id=tenant_id)
hourly_forecast = await weather_service.get_hourly_forecast(
request.latitude, request.longitude, request.hours
)
if not hourly_forecast:
raise HTTPException(
status_code=404,
detail="Hourly weather forecast not available. Please check AEMET API configuration."
)
# Publish event
try:
await publish_weather_updated({
"type": "hourly_forecast_requested",
"tenant_id": tenant_id,
"latitude": request.latitude,
"longitude": request.longitude,
"hours": request.hours,
"requested_by": current_user["user_id"],
"forecast_count": len(hourly_forecast),
"timestamp": datetime.utcnow().isoformat()
})
except Exception as e:
logger.warning("Failed to publish hourly forecast event", error=str(e))
return hourly_forecast
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get hourly weather forecast", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.get("/weather/status")
async def get_weather_status():
"""Get AEMET API status and diagnostics"""

View File

@@ -194,6 +194,33 @@ class WeatherDataParser:
return forecast
def parse_hourly_forecast_data(self, data: List[Dict[str, Any]], hours: int) -> List[Dict[str, Any]]:
"""Parse AEMET hourly forecast data"""
hourly_forecast = []
base_datetime = datetime.now()
if not isinstance(data, list):
logger.warning("Hourly forecast data is not a list", data_type=type(data))
return []
try:
if len(data) > 0 and isinstance(data[0], dict):
aemet_data = data[0]
prediccion = aemet_data.get('prediccion', {})
dias = prediccion.get('dia', [])
if isinstance(dias, list):
hourly_forecast = self._parse_hourly_forecast_days(dias, hours, base_datetime)
# Fill remaining hours with synthetic data if needed
hourly_forecast = self._ensure_hourly_forecast_completeness(hourly_forecast, hours)
except Exception as e:
logger.error("Error parsing AEMET hourly forecast data", error=str(e))
hourly_forecast = []
return hourly_forecast
def _parse_single_historical_record(self, record: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Parse a single historical weather record"""
fecha_str = record.get('fecha')
@@ -324,16 +351,112 @@ class WeatherDataParser:
else:
return "Soleado"
def _ensure_forecast_completeness(self, forecast: List[Dict[str, Any]], days: int) -> List[Dict[str, Any]]:
"""Ensure forecast has the requested number of days"""
if len(forecast) < days:
remaining_days = days - len(forecast)
synthetic_generator = SyntheticWeatherGenerator()
synthetic_forecast = synthetic_generator.generate_forecast_sync(remaining_days, len(forecast))
forecast.extend(synthetic_forecast)
def _parse_hourly_forecast_days(self, dias: List[Dict[str, Any]], hours: int, base_datetime: datetime) -> List[Dict[str, Any]]:
"""Parse hourly forecast days from AEMET data"""
hourly_forecast = []
current_hour = 0
for day_data in dias:
if current_hour >= hours:
break
if not isinstance(day_data, dict):
continue
# Parse hourly data from this day
day_hourly = self._parse_single_day_hourly_forecast(day_data, base_datetime, current_hour, hours)
hourly_forecast.extend(day_hourly)
current_hour += len(day_hourly)
return hourly_forecast[:hours]
def _parse_single_day_hourly_forecast(self, day_data: Dict[str, Any], base_datetime: datetime, start_hour: int, max_hours: int) -> List[Dict[str, Any]]:
"""Parse hourly data for a single day"""
hourly_data = []
# Extract hourly temperature data
temperatura = day_data.get('temperatura', [])
if not isinstance(temperatura, list):
temperatura = []
# Extract hourly precipitation data
precipitacion = day_data.get('precipitacion', [])
if not isinstance(precipitacion, list):
precipitacion = []
# Extract hourly wind data
viento = day_data.get('viento', [])
if not isinstance(viento, list):
viento = []
# Extract hourly humidity data
humedadRelativa = day_data.get('humedadRelativa', [])
if not isinstance(humedadRelativa, list):
humedadRelativa = []
# Process up to 24 hours for this day
hours_in_day = min(24, max_hours - start_hour)
for hour in range(hours_in_day):
forecast_datetime = base_datetime + timedelta(hours=start_hour + hour)
# Extract hourly values
temp = self._extract_hourly_value(temperatura, hour)
precip = self._extract_hourly_value(precipitacion, hour)
wind = self._extract_hourly_value(viento, hour, 'velocidad')
humidity = self._extract_hourly_value(humedadRelativa, hour)
hourly_data.append({
"forecast_datetime": forecast_datetime,
"generated_at": datetime.now(),
"temperature": temp if temp is not None else 15.0 + hour * 0.5,
"precipitation": precip if precip is not None else 0.0,
"humidity": humidity if humidity is not None else 50.0 + (hour % 20),
"wind_speed": wind if wind is not None else 10.0 + (hour % 10),
"description": self._generate_hourly_description(temp, precip),
"source": WeatherSource.AEMET.value,
"hour": forecast_datetime.hour
})
return hourly_data
def _extract_hourly_value(self, data: List[Dict[str, Any]], hour: int, key: str = 'value') -> Optional[float]:
"""Extract hourly value from AEMET hourly data structure"""
if not data or hour >= len(data):
return None
hour_data = data[hour] if hour < len(data) else {}
if not isinstance(hour_data, dict):
return None
if key == 'velocidad' and 'velocidad' in hour_data:
velocidad_list = hour_data.get('velocidad', [])
if isinstance(velocidad_list, list) and len(velocidad_list) > 0:
return self.safe_float(velocidad_list[0])
return self.safe_float(hour_data.get(key))
def _generate_hourly_description(self, temp: Optional[float], precip: Optional[float]) -> str:
"""Generate description for hourly forecast"""
if precip and precip > 0.5:
return "Lluvia"
elif precip and precip > 0.1:
return "Llovizna"
elif temp and temp > 25:
return "Soleado"
elif temp and temp < 5:
return "Frío"
else:
return "Variable"
def _ensure_forecast_completeness(self, forecast: List[Dict[str, Any]], days: int) -> List[Dict[str, Any]]:
"""Return forecast as is - no synthetic data filling"""
return forecast[:days]
def _ensure_hourly_forecast_completeness(self, forecast: List[Dict[str, Any]], hours: int) -> List[Dict[str, Any]]:
"""Return hourly forecast as is - no synthetic data filling"""
return forecast[:hours]
def _get_default_weather_data(self) -> Dict[str, Any]:
"""Get default weather data structure"""
return {
@@ -348,102 +471,6 @@ class WeatherDataParser:
}
class SyntheticWeatherGenerator:
"""Generates realistic synthetic weather data for Madrid"""
def generate_current_weather(self) -> Dict[str, Any]:
"""Generate realistic synthetic current weather for Madrid"""
now = datetime.now()
month = now.month
hour = now.hour
# Madrid climate simulation
temperature = self._calculate_current_temperature(month, hour)
precipitation = self._calculate_current_precipitation(now, month)
return {
"date": now,
"temperature": round(temperature, 1),
"precipitation": precipitation,
"humidity": 45 + (month % 6) * 5,
"wind_speed": 8 + (hour % 12),
"pressure": 1013 + math.sin(now.day * 0.2) * 15,
"description": "Lluvioso" if precipitation > 0 else "Soleado",
"source": WeatherSource.SYNTHETIC.value
}
def generate_forecast_sync(self, days: int, start_offset: int = 0) -> List[Dict[str, Any]]:
"""Generate synthetic forecast data synchronously"""
forecast = []
base_date = datetime.now().date()
for i in range(days):
forecast_date = base_date + timedelta(days=start_offset + i)
forecast_day = self._generate_forecast_day(forecast_date, start_offset + i)
forecast.append(forecast_day)
return forecast
async def generate_forecast(self, days: int) -> List[Dict[str, Any]]:
"""Generate synthetic forecast data (async version for compatibility)"""
return self.generate_forecast_sync(days, 0)
def generate_historical_data(self, start_date: datetime, end_date: datetime) -> List[Dict[str, Any]]:
"""Generate synthetic historical weather data"""
historical_data = []
current_date = start_date
while current_date <= end_date:
historical_day = self._generate_historical_day(current_date)
historical_data.append(historical_day)
current_date += timedelta(days=1)
return historical_data
def _calculate_current_temperature(self, month: int, hour: int) -> float:
"""Calculate current temperature based on seasonal and daily patterns"""
base_temp = AEMETConstants.BASE_TEMPERATURE_SEASONAL + (month - 1) * AEMETConstants.TEMPERATURE_SEASONAL_MULTIPLIER
temp_variation = math.sin((hour - 6) * math.pi / 12) * AEMETConstants.DAILY_TEMPERATURE_AMPLITUDE
return base_temp + temp_variation
def _calculate_current_precipitation(self, now: datetime, month: int) -> float:
"""Calculate current precipitation based on seasonal patterns"""
rain_prob = 0.3 if month in [11, 12, 1, 2, 3] else 0.1
return 2.5 if hash(now.date()) % 100 < rain_prob * 100 else 0.0
def _generate_forecast_day(self, forecast_date: datetime.date, day_offset: int) -> Dict[str, Any]:
"""Generate a single forecast day"""
month = forecast_date.month
base_temp = AEMETConstants.BASE_TEMPERATURE_SEASONAL + (month - 1) * AEMETConstants.TEMPERATURE_SEASONAL_MULTIPLIER
temp_variation = ((day_offset) % 7 - 3) * 2 # Weekly variation
return {
"forecast_date": datetime.combine(forecast_date, datetime.min.time()),
"generated_at": datetime.now(),
"temperature": round(base_temp + temp_variation, 1),
"precipitation": 2.0 if day_offset % 5 == 0 else 0.0,
"humidity": 50 + (day_offset % 30),
"wind_speed": 10 + (day_offset % 15),
"description": "Lluvioso" if day_offset % 5 == 0 else "Soleado",
"source": WeatherSource.SYNTHETIC.value
}
def _generate_historical_day(self, date: datetime) -> Dict[str, Any]:
"""Generate a single historical day"""
month = date.month
base_temp = AEMETConstants.BASE_TEMPERATURE_SEASONAL + (month - 1) * AEMETConstants.TEMPERATURE_SEASONAL_MULTIPLIER
temp_variation = math.sin(date.day * 0.3) * 5
return {
"date": date,
"temperature": round(base_temp + temp_variation, 1),
"precipitation": 1.5 if date.day % 7 == 0 else 0.0,
"humidity": 45 + (date.day % 40),
"wind_speed": 8 + (date.day % 20),
"pressure": 1013 + math.sin(date.day * 0.2) * 20,
"description": "Variable",
"source": WeatherSource.SYNTHETIC.value
}
class LocationService:
@@ -520,7 +547,6 @@ class AEMETClient(BaseAPIClient):
self.timeout = httpx.Timeout(float(settings.AEMET_TIMEOUT))
self.retries = settings.AEMET_RETRY_ATTEMPTS
self.parser = WeatherDataParser()
self.synthetic_generator = SyntheticWeatherGenerator()
self.location_service = LocationService()
async def get_current_weather(self, latitude: float, longitude: float) -> Optional[Dict[str, Any]]:
@@ -531,15 +557,15 @@ class AEMETClient(BaseAPIClient):
# Check if API key is configured
if not self.api_key:
logger.error("❌ AEMET API key not configured - falling back to synthetic data")
return await self._get_synthetic_current_weather()
logger.error("❌ AEMET API key not configured")
return None
station_id = self.location_service.find_nearest_station(latitude, longitude)
if not station_id:
logger.warning("❌ No weather station found for coordinates",
lat=latitude, lon=longitude,
madrid_bounds=f"{AEMETConstants.MADRID_BOUNDS.min_lat}-{AEMETConstants.MADRID_BOUNDS.max_lat}")
return await self._get_synthetic_current_weather()
return None
logger.info("✅ Found nearest weather station", station_id=station_id)
@@ -555,22 +581,22 @@ class AEMETClient(BaseAPIClient):
description=parsed_data.get("description"))
return parsed_data
logger.warning("⚠️ AEMET API connectivity issues - using synthetic data",
logger.warning("⚠️ AEMET API connectivity issues",
station_id=station_id, reason="aemet_api_unreachable")
return await self._get_synthetic_current_weather()
return None
except Exception as e:
logger.error("❌ AEMET API failed - falling back to synthetic",
logger.error("❌ AEMET API failed",
error=str(e), error_type=type(e).__name__)
return await self._get_synthetic_current_weather()
return None
async def get_forecast(self, latitude: float, longitude: float, days: int = 7) -> List[Dict[str, Any]]:
"""Get weather forecast for coordinates"""
try:
municipality_code = self.location_service.get_municipality_code(latitude, longitude)
if not municipality_code:
logger.info("No municipality code found, using synthetic data")
return await self.synthetic_generator.generate_forecast(days)
logger.error("No municipality code found for coordinates", lat=latitude, lon=longitude)
return []
forecast_data = await self._fetch_forecast_data(municipality_code)
if forecast_data:
@@ -578,12 +604,47 @@ class AEMETClient(BaseAPIClient):
if parsed_forecast:
return parsed_forecast
logger.info("Falling back to synthetic forecast data", reason="invalid_forecast_data")
return await self.synthetic_generator.generate_forecast(days)
logger.error("Invalid forecast data received from AEMET API")
return []
except Exception as e:
logger.error("Failed to get weather forecast", error=str(e))
return await self.synthetic_generator.generate_forecast(days)
return []
async def get_hourly_forecast(self, latitude: float, longitude: float, hours: int = 48) -> List[Dict[str, Any]]:
"""Get hourly weather forecast using AEMET's hourly prediction API"""
try:
logger.info("🕒 Getting hourly forecast from AEMET",
lat=latitude, lon=longitude, hours=hours)
# Check if API key is configured
if not self.api_key:
logger.error("❌ AEMET API key not configured")
return []
municipality_code = self.location_service.get_municipality_code(latitude, longitude)
if not municipality_code:
logger.error("❌ No municipality code found for coordinates",
lat=latitude, lon=longitude)
return []
logger.info("✅ Found municipality code", municipality_code=municipality_code)
hourly_data = await self._fetch_hourly_forecast_data(municipality_code)
if hourly_data:
logger.info("🎉 SUCCESS: Real AEMET hourly data retrieved!", municipality_code=municipality_code)
parsed_data = self.parser.parse_hourly_forecast_data(hourly_data, hours)
if parsed_data:
return parsed_data
logger.error("⚠️ AEMET hourly API connectivity issues",
municipality_code=municipality_code, reason="aemet_hourly_api_unreachable")
return []
except Exception as e:
logger.error("❌ AEMET hourly API failed",
error=str(e), error_type=type(e).__name__)
return []
async def get_historical_weather(self,
latitude: float,
@@ -598,9 +659,9 @@ class AEMETClient(BaseAPIClient):
station_id = self.location_service.find_nearest_station(latitude, longitude)
if not station_id:
logger.warning("No weather station found for historical data",
logger.error("No weather station found for historical data",
lat=latitude, lon=longitude)
return self.synthetic_generator.generate_historical_data(start_date, end_date)
return []
historical_data = await self._fetch_historical_data_in_chunks(
station_id, start_date, end_date
@@ -611,12 +672,12 @@ class AEMETClient(BaseAPIClient):
total_count=len(historical_data))
return historical_data
else:
logger.info("No real historical data available, using synthetic data")
return self.synthetic_generator.generate_historical_data(start_date, end_date)
logger.error("No real historical data available from AEMET")
return []
except Exception as e:
logger.error("Failed to get historical weather from AEMET API", error=str(e))
return self.synthetic_generator.generate_historical_data(start_date, end_date)
return []
async def _fetch_current_weather_data(self, station_id: str) -> Optional[Dict[str, Any]]:
"""Fetch current weather data from AEMET API"""
@@ -646,6 +707,17 @@ class AEMETClient(BaseAPIClient):
datos_url = initial_response.get("datos")
return await self._fetch_from_url(datos_url)
async def _fetch_hourly_forecast_data(self, municipality_code: str) -> Optional[List[Dict[str, Any]]]:
"""Fetch hourly forecast data from AEMET API"""
endpoint = f"/prediccion/especifica/municipio/horaria/{municipality_code}"
initial_response = await self._get(endpoint)
if not self._is_valid_initial_response(initial_response):
return None
datos_url = initial_response.get("datos")
return await self._fetch_from_url(datos_url)
async def _fetch_historical_data_in_chunks(self,
station_id: str,
start_date: datetime,
@@ -725,6 +797,3 @@ class AEMETClient(BaseAPIClient):
return (response and isinstance(response, dict) and
response.get("datos") and isinstance(response.get("datos"), str))
async def _get_synthetic_current_weather(self) -> Dict[str, Any]:
"""Get synthetic current weather data"""
return self.synthetic_generator.generate_current_weather()

View File

@@ -158,4 +158,20 @@ class HistoricalWeatherRequest(BaseModel):
class WeatherForecastRequest(BaseModel):
latitude: float
longitude: float
days: int
days: int
class HourlyForecastRequest(BaseModel):
latitude: float
longitude: float
hours: int = Field(default=48, ge=1, le=48, description="Number of hours to forecast (1-48)")
class HourlyForecastResponse(BaseModel):
forecast_datetime: datetime
generated_at: datetime
temperature: Optional[float]
precipitation: Optional[float]
humidity: Optional[float]
wind_speed: Optional[float]
description: Optional[str]
source: str
hour: int

View File

@@ -9,7 +9,7 @@ import structlog
from app.models.weather import WeatherData, WeatherForecast
from app.external.aemet import AEMETClient
from app.schemas.weather import WeatherDataResponse, WeatherForecastResponse
from app.schemas.weather import WeatherDataResponse, WeatherForecastResponse, HourlyForecastResponse
from app.repositories.weather_repository import WeatherRepository
logger = structlog.get_logger()
@@ -79,6 +79,48 @@ class WeatherService:
logger.error("Failed to get weather forecast", error=str(e), lat=latitude, lon=longitude)
return []
async def get_hourly_forecast(self, latitude: float, longitude: float, hours: int = 48) -> List[HourlyForecastResponse]:
"""Get hourly weather forecast for location"""
try:
logger.debug("Getting hourly weather forecast", lat=latitude, lon=longitude, hours=hours)
hourly_data = await self.aemet_client.get_hourly_forecast(latitude, longitude, hours)
if hourly_data:
logger.debug("Hourly forecast data received", count=len(hourly_data))
# Validate each hourly forecast item before creating response
valid_forecasts = []
for item in hourly_data:
try:
if isinstance(item, dict):
# Ensure required fields are present
hourly_item = {
"forecast_datetime": item.get("forecast_datetime", datetime.now()),
"generated_at": item.get("generated_at", datetime.now()),
"temperature": float(item.get("temperature", 15.0)),
"precipitation": float(item.get("precipitation", 0.0)),
"humidity": float(item.get("humidity", 50.0)),
"wind_speed": float(item.get("wind_speed", 10.0)),
"description": str(item.get("description", "Variable")),
"source": str(item.get("source", "unknown")),
"hour": int(item.get("hour", 0))
}
valid_forecasts.append(HourlyForecastResponse(**hourly_item))
else:
logger.warning("Invalid hourly forecast item type", item_type=type(item))
except Exception as item_error:
logger.warning("Error processing hourly forecast item", error=str(item_error), item=item)
continue
logger.debug("Valid hourly forecasts processed", count=len(valid_forecasts))
return valid_forecasts
else:
logger.warning("No hourly forecast data received from AEMET client")
return []
except Exception as e:
logger.error("Failed to get hourly weather forecast", error=str(e), lat=latitude, lon=longitude)
return []
async def get_historical_weather(self,
latitude: float,
longitude: float,