From f7e6a8080eb64d4937ba395c9d13efdfc51bfc19 Mon Sep 17 00:00:00 2001 From: fedjo Date: Mon, 2 Feb 2026 16:35:56 +0200 Subject: [PATCH 1/3] Treat empty location responses from FarmCalendar, handle open-meteo error when connection lost (#14) * Handle error on no internet * Handle empty json response from FC * Add a safer check on empty graph list * Add farm name and parcel to the obs title and desc * Eagerly fetch parcels from FC --- src/external_services/openmeteo.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/external_services/openmeteo.py b/src/external_services/openmeteo.py index 5ec26b3..fa34c69 100644 --- a/src/external_services/openmeteo.py +++ b/src/external_services/openmeteo.py @@ -50,6 +50,23 @@ async def _fetch_data(self, params: dict, url: Optional[str] = None) -> dict: logger.error(f"HTTP error: {e}") raise e + async def _fetch_data(self, params: dict) -> dict: + """Fetch data from Open-Meteo API with error handling.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get(self.BASE_URL, params=params, timeout=10.0) + response.raise_for_status() + return response.json() + except (httpx.NetworkError, httpx.ConnectError, httpx.TimeoutException) as e: + logger.error(f"Internet connection is not available: {e}") + raise HTTPException( + status_code=503, + detail="Internet connection is not available. Please cache data for the specified location" + ) from e + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error: {e}") + raise e + async def get_hourly_history(self, lat: float, lon: float, start: date, end: date, variables: List[str]) -> List[HourlyObservationOut]: params = { "latitude": lat, From ced64a2ddad6ec88c25d7570f77c558fa96000b3 Mon Sep 17 00:00:00 2001 From: Yiorgos Marinellis Date: Tue, 3 Mar 2026 13:48:36 +0200 Subject: [PATCH 2/3] Fix duplicate method declaration --- src/external_services/openmeteo.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/external_services/openmeteo.py b/src/external_services/openmeteo.py index fa34c69..5ec26b3 100644 --- a/src/external_services/openmeteo.py +++ b/src/external_services/openmeteo.py @@ -50,23 +50,6 @@ async def _fetch_data(self, params: dict, url: Optional[str] = None) -> dict: logger.error(f"HTTP error: {e}") raise e - async def _fetch_data(self, params: dict) -> dict: - """Fetch data from Open-Meteo API with error handling.""" - try: - async with httpx.AsyncClient() as client: - response = await client.get(self.BASE_URL, params=params, timeout=10.0) - response.raise_for_status() - return response.json() - except (httpx.NetworkError, httpx.ConnectError, httpx.TimeoutException) as e: - logger.error(f"Internet connection is not available: {e}") - raise HTTPException( - status_code=503, - detail="Internet connection is not available. Please cache data for the specified location" - ) from e - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error: {e}") - raise e - async def get_hourly_history(self, lat: float, lon: float, start: date, end: date, variables: List[str]) -> List[HourlyObservationOut]: params = { "latitude": lat, From abeeca60d8e7392564a41ad20312e40feeff0d66 Mon Sep 17 00:00:00 2001 From: Yiorgos Marinellis Date: Fri, 3 Apr 2026 15:10:02 +0300 Subject: [PATCH 3/3] Add daily irrigation api call --- src/api/api_v1/endpoints/forecast.py | 49 ++++++++++++++++++- src/external_services/openmeteo.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/api/api_v1/endpoints/forecast.py b/src/api/api_v1/endpoints/forecast.py index 80db6db..69bebe8 100644 --- a/src/api/api_v1/endpoints/forecast.py +++ b/src/api/api_v1/endpoints/forecast.py @@ -22,7 +22,7 @@ from src.external_services.openmeteo import WeatherClientFactory from src.schemas.point import GeoJSONOut -from src.schemas.history_data import HourlyObservationOut, HourlyResponse +from src.schemas.history_data import DailyResponse, HourlyObservationOut, HourlyResponse from src.schemas.spray import SprayForecastResponse from src.models.spray import SprayStatus from src import utils @@ -165,3 +165,50 @@ async def get_hourly_spray_forecast( ) return results + + +# --------------------------------------------------------------------------- +# Daily irrigation forecast +# --------------------------------------------------------------------------- + +@router.get( + "/daily/irrigation/", + response_model=DailyResponse, + summary="Daily weather forecast for smart irrigation (7–16 days)", +) +async def get_daily_irrigation_forecast( + lat: float = Query(..., description="Latitude", example=38.25), + lon: float = Query(..., description="Longitude", example=21.74), + days: int = Query( + 16, ge=1, le=16, + description="Number of forecast days (including today)", + ), +): + """ + Returns **daily** weather data optimized for smart irrigation systems. + + Variables returned per day: + - **precipitation_sum** – total precipitation (mm) + - **precipitation_probability_max** – maximum precipitation probability (%) + - **temperature_2m_min** – minimum temperature (°C) + - **temperature_2m_max** – maximum temperature (°C) + - **et0_fao_evapotranspiration** – reference evapotranspiration ET₀ (mm) + + Data comes from the Open-Meteo Forecast API. + Supports up to 16 days of forecast. + """ + client = WeatherClientFactory.get_provider() + try: + results = await client.get_daily_forecast(lat, lon, days=days) + except Exception as e: + logger.error(f"Error fetching daily forecast from Open-Meteo: {e}") + raise HTTPException( + status_code=502, + detail="Could not retrieve daily forecast from Open-Meteo", + ) from e + + return DailyResponse( + location={"lat": lat, "lon": lon}, + data=results, + source="open-meteo", + ) diff --git a/src/external_services/openmeteo.py b/src/external_services/openmeteo.py index 5ec26b3..d33d7a5 100644 --- a/src/external_services/openmeteo.py +++ b/src/external_services/openmeteo.py @@ -27,6 +27,11 @@ async def get_hourly_forecast( ) -> List[HourlyObservationOut]: ... + async def get_daily_forecast( + self, lat: float, lon: float, days: int = 16 + ) -> List[DailyObservationOut]: + ... + class OpenMeteoClient: BASE_URL = "https://archive-api.open-meteo.com/v1/archive" @@ -165,6 +170,73 @@ async def get_hourly_forecast( return results +# ---- Daily forecast (Open-Meteo Forecast API) ---- + # Variables for smart irrigation: precipitation, probability, temp range, ET0 + DAILY_FORECAST_VARIABLES = [ + "precipitation_sum", + "precipitation_probability_max", + "temperature_2m_min", + "temperature_2m_max", + "et0_fao_evapotranspiration", + ] + + async def get_daily_forecast( + self, lat: float, lon: float, days: int = 16 + ) -> List[DailyObservationOut]: + """ + Fetch daily weather forecast for irrigation planning from the + Open-Meteo **Forecast** API. + + Returns one :class:`DailyObservationOut` per day with: + - precipitation_sum (mm) + - precipitation_probability_max (%) + - temperature_2m_min (°C) + - temperature_2m_max (°C) + - et0_fao_evapotranspiration (mm) + + Parameters + ---------- + lat, lon : float + Location coordinates. + days : int + Number of forecast days (1–16, default 16). + """ + params = { + "latitude": lat, + "longitude": lon, + "daily": ",".join(self.DAILY_FORECAST_VARIABLES), + "timezone": "auto", + "forecast_days": days, + } + + data = await self._fetch_data(params, url=self.FORECAST_URL) + + if "daily" not in data: + logger.warning("Open-Meteo returned no daily forecast data") + return [] + + timestamps = data["daily"]["time"] + results: List[DailyObservationOut] = [] + + for i, t in enumerate(timestamps): + values: Dict[str, Union[float, None]] = {} + for var in self.DAILY_FORECAST_VARIABLES: + if var in data["daily"]: + values[var] = data["daily"][var][i] + results.append( + DailyObservationOut( + date=date.fromisoformat(t), + values=values, + ) + ) + + logger.info( + "Open-Meteo daily forecast: %d days for (%s, %s)", + len(results), lat, lon, + ) + return results + + # Factory using environment variable class WeatherClientFactory: _provider: Optional[WeatherProvider] = None