Skip to content

Commit ccc8a82

Browse files
authored
Add files via upload
Added weather API provider configuration file required for project initialization for new users
1 parent e001f77 commit ccc8a82

1 file changed

Lines changed: 201 additions & 0 deletions

File tree

src/weather_api/providers.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""
2+
Weather providers abstraction and Open-Meteo integration.
3+
"""
4+
from __future__ import annotations
5+
6+
from dataclasses import dataclass
7+
from datetime import datetime, timedelta
8+
import logging
9+
from typing import Protocol
10+
11+
import pandas as pd
12+
import requests
13+
from requests.adapters import HTTPAdapter
14+
from urllib3.util.retry import Retry
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class WeatherProvider(Protocol):
20+
def get_hourly_weather(
21+
self,
22+
*,
23+
latitude: float,
24+
longitude: float,
25+
start: datetime,
26+
end: datetime,
27+
prefer_historical: bool = True,
28+
) -> pd.DataFrame | None:
29+
"""Return hourly weather DataFrame indexed by datetime with required columns.
30+
31+
Expected columns: temperature_2m, relative_humidity_2m, cloud_cover,
32+
wind_speed_10m, shortwave_radiation
33+
"""
34+
...
35+
36+
37+
@dataclass
38+
class OpenMeteoWeatherProvider:
39+
cache_manager: object | None = None # DataCacheManager-like
40+
session: requests.Session | None = None
41+
timeout: int = 20
42+
max_retries: int = 3
43+
44+
BASE_FORECAST: str = "https://api.open-meteo.com/v1/forecast"
45+
BASE_ARCHIVE: str = "https://archive-api.open-meteo.com/v1/archive"
46+
47+
def _cache_id(self, lat: float, lon: float, start: datetime, end: datetime, source: str) -> str:
48+
return (
49+
f"openmeteo_{source}_{lat:.4f}_{lon:.4f}_{start.strftime('%Y%m%d%H')}_{end.strftime('%Y%m%d%H')}"
50+
)
51+
52+
def _get_session(self) -> requests.Session:
53+
if self.session:
54+
return self.session
55+
s = requests.Session()
56+
retry = Retry(
57+
total=self.max_retries,
58+
backoff_factor=0.5,
59+
status_forcelist=[429, 500, 502, 503, 504],
60+
allowed_methods=["GET"],
61+
raise_on_status=False,
62+
)
63+
adapter = HTTPAdapter(max_retries=retry)
64+
s.mount("http://", adapter)
65+
s.mount("https://", adapter)
66+
return s
67+
68+
def _request(self, url: str, params: dict) -> dict | None:
69+
s = self._get_session()
70+
try:
71+
resp = s.get(url, params=params, timeout=self.timeout)
72+
if resp.status_code == 200:
73+
return resp.json()
74+
logger.warning(f"Open-Meteo HTTP {resp.status_code}: {resp.text[:200]}")
75+
except Exception as e:
76+
logger.warning(f"Open-Meteo request failed: {e}")
77+
return None
78+
79+
def _normalize(self, payload: dict) -> pd.DataFrame | None:
80+
try:
81+
hourly = payload.get("hourly") or {}
82+
times = hourly.get("time")
83+
if not times:
84+
return None
85+
df = pd.DataFrame(hourly)
86+
df["datetime"] = pd.to_datetime(df["time"]) # timezone already applied by API
87+
df = df.set_index("datetime").drop(columns=["time"]) # type: ignore[arg-type]
88+
89+
# Rename to internal schema if needed
90+
rename_map = {
91+
# Open-Meteo already uses these names for requested variables
92+
}
93+
df = df.rename(columns=rename_map)
94+
95+
# Ensure required columns exist
96+
required = [
97+
"temperature_2m",
98+
"relative_humidity_2m",
99+
"cloud_cover",
100+
"wind_speed_10m",
101+
"shortwave_radiation",
102+
]
103+
for col in required:
104+
if col not in df.columns:
105+
df[col] = pd.NA
106+
107+
# Sort and drop duplicates
108+
df = df[required].sort_index()
109+
110+
# Reindex to complete hourly coverage
111+
full_index = pd.date_range(df.index.min(), df.index.max(), freq="h")
112+
df = df.reindex(full_index)
113+
return df
114+
except Exception as e:
115+
logger.error(f"Error normalizing Open-Meteo payload: {e}")
116+
return None
117+
118+
def get_hourly_weather(
119+
self,
120+
*,
121+
latitude: float,
122+
longitude: float,
123+
start: datetime,
124+
end: datetime,
125+
prefer_historical: bool = True,
126+
) -> pd.DataFrame | None:
127+
# Choose endpoint
128+
now = datetime.utcnow()
129+
use_archive = prefer_historical and end <= now
130+
base = self.BASE_ARCHIVE if use_archive else self.BASE_FORECAST
131+
source = "archive" if use_archive else "forecast"
132+
133+
cache_id = self._cache_id(latitude, longitude, start, end, source)
134+
if self.cache_manager and getattr(self.cache_manager, "is_cached", None):
135+
try:
136+
if self.cache_manager.is_cached("weather_api", cache_id):
137+
# For forecast, require freshness (e.g., <= 3 hours old)
138+
fresh_ok = True
139+
if source == "forecast" and getattr(self.cache_manager, "get_data_cache_entry", None):
140+
meta = self.cache_manager.get_data_cache_entry("weather_api", cache_id)
141+
if meta and meta.get("created_at"):
142+
try:
143+
created = pd.to_datetime(meta["created_at"]) # sqlite timestamp
144+
fresh_ok = (datetime.utcnow() - created.to_pydatetime()) <= timedelta(hours=3)
145+
except Exception:
146+
fresh_ok = True
147+
if fresh_ok:
148+
cached = self.cache_manager.load_cached_data("weather_api", cache_id)
149+
if isinstance(cached, pd.DataFrame):
150+
return cached
151+
except Exception:
152+
pass
153+
154+
params = {
155+
"latitude": latitude,
156+
"longitude": longitude,
157+
"hourly": ",".join(
158+
[
159+
"temperature_2m",
160+
"relative_humidity_2m",
161+
"cloud_cover",
162+
"wind_speed_10m",
163+
"shortwave_radiation",
164+
]
165+
),
166+
"start_date": start.strftime("%Y-%m-%d"),
167+
"end_date": end.strftime("%Y-%m-%d"),
168+
"timezone": "auto",
169+
}
170+
payload = self._request(base, params)
171+
if not payload:
172+
return None
173+
df = self._normalize(payload)
174+
if df is None or df.empty:
175+
return None
176+
177+
# Clip ranges and basic sanity
178+
df["relative_humidity_2m"] = pd.to_numeric(df["relative_humidity_2m"], errors="coerce").clip(0, 100)
179+
df["cloud_cover"] = pd.to_numeric(df["cloud_cover"], errors="coerce").clip(0, 100)
180+
df["wind_speed_10m"] = pd.to_numeric(df["wind_speed_10m"], errors="coerce").clip(lower=0)
181+
df["temperature_2m"] = pd.to_numeric(df["temperature_2m"], errors="coerce")
182+
df["shortwave_radiation"] = pd.to_numeric(df["shortwave_radiation"], errors="coerce").clip(lower=0)
183+
184+
if self.cache_manager and getattr(self.cache_manager, "cache_data", None):
185+
try:
186+
self.cache_manager.cache_data(
187+
df,
188+
"weather_api",
189+
cache_id,
190+
metadata={
191+
"provider": "open-meteo",
192+
"source": source,
193+
"lat": latitude,
194+
"lon": longitude,
195+
"start": start.isoformat(),
196+
"end": end.isoformat(),
197+
},
198+
)
199+
except Exception:
200+
pass
201+
return df

0 commit comments

Comments
 (0)