UPDATE
This commit is contained in:
+173
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user