1+ import logging
2+ from fastapi import HTTPException
13import httpx
2- from datetime import date , datetime
3- from typing import List , Optional , Protocol
4+ from datetime import date , datetime , timezone
5+ from typing import Dict , List , Optional , Protocol , Union
46import os
57
68from src .core import config
79from src .schemas .history_data import DailyObservationOut , HourlyObservationOut
810
911
12+ logger = logging .getLogger (__name__ )
13+
14+
1015class WeatherProvider (Protocol ):
11- async def get_hourly_history (self , lat : float , lon : float , start : date , end : date , variables : List [str ]) -> List [HourlyObservationOut ]:
16+ async def get_hourly_history (
17+ self , lat : float , lon : float , start : date , end : date , variables : List [str ]
18+ ) -> List [HourlyObservationOut ]:
1219 ...
1320
14- async def get_daily_history (self , lat : float , lon : float , start : date , end : date , variables : List [str ]) -> List [DailyObservationOut ]:
21+ async def get_daily_history (
22+ self , lat : float , lon : float , start : date , end : date , variables : List [str ]
23+ ) -> List [DailyObservationOut ]:
24+ ...
25+
26+ async def get_hourly_forecast (
27+ self , lat : float , lon : float , days : int = 5
28+ ) -> List [HourlyObservationOut ]:
1529 ...
1630
1731
1832class OpenMeteoClient :
1933 BASE_URL = "https://archive-api.open-meteo.com/v1/archive"
34+ FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
35+
36+ async def _fetch_data (self , params : dict , url : Optional [str ] = None ) -> dict :
37+ """Fetch data from Open-Meteo API with error handling."""
38+ target_url = url or self .BASE_URL
39+ try :
40+ async with httpx .AsyncClient () as client :
41+ response = await client .get (target_url , params = params , timeout = 10.0 )
42+ response .raise_for_status ()
43+ return response .json ()
44+ except (httpx .NetworkError , httpx .ConnectError , httpx .TimeoutException ) as e :
45+ logger .error (f"Internet connection is not available: { e } " )
46+ raise HTTPException (
47+ status_code = 503 ,
48+ detail = "Internet connection is not available. Please cache data for the specified location"
49+ ) from e
50+ except httpx .HTTPStatusError as e :
51+ logger .error (f"HTTP error: { e } " )
52+ raise e
2053
2154 async def get_hourly_history (self , lat : float , lon : float , start : date , end : date , variables : List [str ]) -> List [HourlyObservationOut ]:
2255 params = {
@@ -28,11 +61,7 @@ async def get_hourly_history(self, lat: float, lon: float, start: date, end: dat
2861 "end_date" : end .isoformat ()
2962 }
3063
31- async with httpx .AsyncClient () as client :
32- response = await client .get (self .BASE_URL , params = params )
33- response .raise_for_status ()
34- data = response .json ()
35-
64+ data = await self ._fetch_data (params )
3665 timestamps = data ["hourly" ]["time" ]
3766 results = []
3867
@@ -52,11 +81,7 @@ async def get_daily_history(self, lat: float, lon: float, start: date, end: date
5281 "end_date" : end .isoformat ()
5382 }
5483
55- async with httpx .AsyncClient () as client :
56- response = await client .get (self .BASE_URL , params = params )
57- response .raise_for_status ()
58- data = response .json ()
59-
84+ data = await self ._fetch_data (params )
6085 timestamps = data ["daily" ]["time" ]
6186 results = []
6287
@@ -71,6 +96,75 @@ async def get_single_day_history(self, lat: float, lon: float, day: date, variab
7196 daily = await self .get_daily_history (lat , lon , day , day , variables .get ("daily" , []))
7297 return hourly , daily
7398
99+ # ---- Hourly forecast (Open-Meteo Forecast API) ----
100+
101+ # The list of hourly variables requested from the Open-Meteo Forecast API.
102+ HOURLY_FORECAST_VARIABLES = [
103+ "temperature_2m" ,
104+ "relative_humidity_2m" ,
105+ "dew_point_2m" ,
106+ "apparent_temperature" ,
107+ "precipitation" ,
108+ "rain" ,
109+ "snowfall" ,
110+ "snow_depth" ,
111+ "pressure_msl" ,
112+ "surface_pressure" ,
113+ "cloud_cover" ,
114+ "wind_speed_10m" ,
115+ "wind_direction_10m" ,
116+ "wind_gusts_10m" ,
117+ "visibility" ,
118+ "uv_index" ,
119+ ]
120+
121+ async def get_hourly_forecast (
122+ self , lat : float , lon : float , days : int = 5
123+ ) -> List [HourlyObservationOut ]:
124+ """
125+ Fetch hourly weather data for today (all 24 h, past + future) plus
126+ the next ``days−1`` days from the Open-Meteo **Forecast** API.
127+
128+ Returns one :class:`HourlyObservationOut` per hour (up to ``days * 24``
129+ entries). Past hours of the current day are **never** filtered out so
130+ the caller always gets a complete day timeline.
131+ """
132+ params = {
133+ "latitude" : lat ,
134+ "longitude" : lon ,
135+ "hourly" : "," .join (self .HOURLY_FORECAST_VARIABLES ),
136+ "timezone" : "auto" ,
137+ "past_days" : 0 ,
138+ "forecast_days" : days ,
139+ }
140+
141+ data = await self ._fetch_data (params , url = self .FORECAST_URL )
142+
143+ if "hourly" not in data :
144+ logger .warning ("Open-Meteo returned no hourly forecast data" )
145+ return []
146+
147+ timestamps = data ["hourly" ]["time" ]
148+ results : List [HourlyObservationOut ] = []
149+
150+ for i , t in enumerate (timestamps ):
151+ values : Dict [str , Union [float , None ]] = {}
152+ for var in self .HOURLY_FORECAST_VARIABLES :
153+ if var in data ["hourly" ]:
154+ values [var ] = data ["hourly" ][var ][i ]
155+ results .append (
156+ HourlyObservationOut (
157+ timestamp = datetime .fromisoformat (t ),
158+ values = values ,
159+ )
160+ )
161+
162+ logger .info (
163+ "Open-Meteo hourly forecast: %d hours for (%s, %s), %d days" ,
164+ len (results ), lat , lon , days ,
165+ )
166+ return results
167+
74168
75169# Factory using environment variable
76170class WeatherClientFactory :
0 commit comments