This commit is contained in:
2026-05-10 02:02:48 +03:30
parent cead7dafe2
commit 2d1f7da89e
30 changed files with 1195 additions and 320 deletions
+173 -2
View File
@@ -5,15 +5,18 @@ import math
import random
import time
from abc import ABC, abstractmethod
from datetime import date, timedelta
from datetime import date, datetime, timedelta, timezone
try:
import requests
except ImportError: # pragma: no cover - handled when live adapter is used
requests = None
from config.proxy import build_requests_proxies
DEFAULT_FORECAST_DAYS = 7
DEFAULT_WEATHER_PROXY_URL = "socks5h://host.docker.internal:10808"
DAILY_FIELDS = [
"temperature_2m_max",
"temperature_2m_min",
@@ -43,10 +46,11 @@ class BaseWeatherAdapter(ABC):
class OpenMeteoWeatherAdapter(BaseWeatherAdapter):
source_name = "open-meteo"
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60):
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60, proxy_url: str = ""):
self.base_url = base_url
self.api_key = api_key
self.timeout = timeout
self.proxy_url = proxy_url
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
if requests is None:
@@ -67,12 +71,155 @@ class OpenMeteoWeatherAdapter(BaseWeatherAdapter):
self.base_url,
params=params,
headers=headers,
proxies=build_requests_proxies(self.proxy_url),
timeout=self.timeout,
)
response.raise_for_status()
return response.json()
class OpenWeatherOneCallAdapter(BaseWeatherAdapter):
source_name = "openweather"
def __init__(self, base_url: str, api_key: str, timeout: float = 60, proxy_url: str = ""):
self.base_url = base_url
self.api_key = api_key
self.timeout = timeout
self.proxy_url = proxy_url
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
if requests is None:
raise RuntimeError("requests package is required for OpenWeatherOneCallAdapter")
if not self.api_key:
raise RuntimeError("WEATHER_API_KEY is required for OpenWeatherOneCallAdapter")
params = {
"lat": latitude,
"lon": longitude,
"appid": self.api_key,
"exclude": "minutely,hourly,alerts",
"units": "metric",
}
response = requests.get(
self.base_url,
params=params,
headers={"accept": "application/json"},
proxies=build_requests_proxies(self.proxy_url),
timeout=self.timeout,
)
response.raise_for_status()
payload = response.json()
return self._to_open_meteo_shape(payload, days=days)
def _to_open_meteo_shape(self, payload: dict, *, days: int) -> dict:
daily_items = list(payload.get("daily") or [])[:days]
daily = {field: [] for field in DAILY_FIELDS}
daily["time"] = []
for item in daily_items:
temp = item.get("temp") or {}
humidity = item.get("humidity")
wind_speed_ms = item.get("wind_speed")
rain = item.get("rain")
snow = item.get("snow")
precipitation = _safe_float(rain, 0.0) + _safe_float(snow, 0.0)
weather_id = None
weather = item.get("weather") or []
if weather and isinstance(weather[0], dict):
weather_id = weather[0].get("id")
daily["time"].append(datetime.fromtimestamp(int(item["dt"]), tz=timezone.utc).date().isoformat())
temp_min = _safe_float(temp.get("min"))
temp_max = _safe_float(temp.get("max"))
temp_day = _safe_float(temp.get("day"))
daily["temperature_2m_min"].append(temp_min)
daily["temperature_2m_max"].append(temp_max)
daily["temperature_2m_mean"].append(
temp_day if temp_day is not None else _mean_of_pair(temp_min, temp_max)
)
daily["precipitation_sum"].append(round(precipitation, 2))
daily["precipitation_probability_max"].append(round(_safe_float(item.get("pop"), 0.0) * 100.0, 1))
daily["relative_humidity_2m_mean"].append(_safe_float(humidity))
daily["wind_speed_10m_max"].append(
round(_safe_float(wind_speed_ms, 0.0) * 3.6, 2)
)
daily["et0_fao_evapotranspiration"].append(
self._estimate_et0(
temp_min=temp_min,
temp_max=temp_max,
humidity=_safe_float(humidity, 55.0),
wind_speed_ms=_safe_float(wind_speed_ms, 0.0),
uvi=_safe_float(item.get("uvi"), 0.0),
clouds=_safe_float(item.get("clouds"), 0.0),
precipitation=precipitation,
)
)
daily["weather_code"].append(self._map_weather_code(weather_id))
return {
"latitude": payload.get("lat"),
"longitude": payload.get("lon"),
"timezone": payload.get("timezone"),
"daily": daily,
}
def _estimate_et0(
self,
*,
temp_min: float | None,
temp_max: float | None,
humidity: float,
wind_speed_ms: float,
uvi: float,
clouds: float,
precipitation: float,
) -> float:
temp_mean = _mean_of_pair(temp_min, temp_max) or 20.0
radiation_factor = max(uvi * 0.22, 0.35)
dryness_factor = max(0.2, 1.0 - (humidity / 100.0))
cloud_factor = max(0.3, 1.0 - (clouds / 140.0))
rain_penalty = min(max(precipitation * 0.04, 0.0), 0.8)
et0 = (
0.9
+ max(temp_mean, 0.0) * 0.11
+ wind_speed_ms * 0.18
+ radiation_factor
+ dryness_factor * 1.6
) * cloud_factor
return round(_clamp(et0 - rain_penalty, 0.3, 11.0), 2)
def _map_weather_code(self, weather_id: int | None) -> int:
if weather_id is None:
return 0
if 200 <= weather_id < 300:
return 95
if 300 <= weather_id < 400:
return 51
if weather_id in {500, 520}:
return 61
if weather_id in {501, 521, 522, 531}:
return 63
if 502 <= weather_id < 600:
return 65
if weather_id in {600, 615, 620}:
return 71
if 601 <= weather_id < 700:
return 71
if weather_id in {701, 711, 721, 741}:
return 45
if weather_id in {731, 751, 761, 762}:
return 3
if weather_id == 800:
return 0
if weather_id == 801:
return 1
if weather_id == 802:
return 2
if weather_id in {803, 804}:
return 3
return 0
class MockWeatherAdapter(BaseWeatherAdapter):
source_name = "mock"
@@ -271,6 +418,14 @@ def get_weather_adapter() -> BaseWeatherAdapter:
base_url=settings.WEATHER_API_BASE_URL,
api_key=settings.WEATHER_API_KEY,
timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60),
proxy_url=getattr(settings, "WEATHER_PROXY_URL", DEFAULT_WEATHER_PROXY_URL),
)
if provider == "openweather":
return OpenWeatherOneCallAdapter(
base_url=settings.WEATHER_API_BASE_URL,
api_key=settings.WEATHER_API_KEY,
timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60),
proxy_url=getattr(settings, "WEATHER_PROXY_URL", DEFAULT_WEATHER_PROXY_URL),
)
if provider == "mock":
if not (getattr(settings, "DEBUG", False) or getattr(settings, "DEVELOP", False)):
@@ -279,3 +434,19 @@ def get_weather_adapter() -> BaseWeatherAdapter:
delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8)
)
raise ValueError(f"Unsupported weather data provider: {provider}")
def _safe_float(value, default=None):
try:
if value in (None, ""):
return default
return float(value)
except (TypeError, ValueError):
return default
def _mean_of_pair(first: float | None, second: float | None) -> float | None:
values = [value for value in (first, second) if value is not None]
if not values:
return None
return round(sum(values) / len(values), 2)