2026-04-29 01:27:29 +03:30
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import math
|
|
|
|
|
import random
|
|
|
|
|
import time
|
|
|
|
|
from abc import ABC, abstractmethod
|
2026-05-10 02:02:48 +03:30
|
|
|
from datetime import date, datetime, timedelta, timezone
|
2026-04-29 01:27:29 +03:30
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import requests
|
|
|
|
|
except ImportError: # pragma: no cover - handled when live adapter is used
|
|
|
|
|
requests = None
|
|
|
|
|
|
2026-05-10 02:02:48 +03:30
|
|
|
from config.proxy import build_requests_proxies
|
|
|
|
|
|
2026-04-29 01:27:29 +03:30
|
|
|
|
|
|
|
|
DEFAULT_FORECAST_DAYS = 7
|
2026-05-10 02:02:48 +03:30
|
|
|
DEFAULT_WEATHER_PROXY_URL = "socks5h://host.docker.internal:10808"
|
2026-04-29 01:27:29 +03:30
|
|
|
DAILY_FIELDS = [
|
|
|
|
|
"temperature_2m_max",
|
|
|
|
|
"temperature_2m_min",
|
|
|
|
|
"temperature_2m_mean",
|
|
|
|
|
"precipitation_sum",
|
|
|
|
|
"precipitation_probability_max",
|
|
|
|
|
"relative_humidity_2m_mean",
|
|
|
|
|
"wind_speed_10m_max",
|
|
|
|
|
"et0_fao_evapotranspiration",
|
|
|
|
|
"weather_code",
|
|
|
|
|
]
|
|
|
|
|
WMO_CODES = [0, 1, 2, 3, 45, 51, 61, 63, 65, 80, 95]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clamp(value: float, lower: float, upper: float) -> float:
|
|
|
|
|
return max(lower, min(upper, value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BaseWeatherAdapter(ABC):
|
|
|
|
|
source_name = "base"
|
|
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
|
|
|
|
|
"""Return daily forecast data in Open-Meteo compatible shape."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OpenMeteoWeatherAdapter(BaseWeatherAdapter):
|
|
|
|
|
source_name = "open-meteo"
|
|
|
|
|
|
2026-05-10 02:02:48 +03:30
|
|
|
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60, proxy_url: str = ""):
|
2026-04-29 01:27:29 +03:30
|
|
|
self.base_url = base_url
|
|
|
|
|
self.api_key = api_key
|
|
|
|
|
self.timeout = timeout
|
2026-05-10 02:02:48 +03:30
|
|
|
self.proxy_url = proxy_url
|
2026-04-29 01:27:29 +03:30
|
|
|
|
|
|
|
|
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 OpenMeteoWeatherAdapter")
|
|
|
|
|
|
|
|
|
|
params = {
|
|
|
|
|
"latitude": latitude,
|
|
|
|
|
"longitude": longitude,
|
|
|
|
|
"forecast_days": days,
|
|
|
|
|
"timezone": "auto",
|
|
|
|
|
"daily": DAILY_FIELDS,
|
|
|
|
|
}
|
|
|
|
|
headers = {"accept": "application/json"}
|
|
|
|
|
if self.api_key:
|
|
|
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
|
|
|
|
|
|
|
|
response = requests.get(
|
|
|
|
|
self.base_url,
|
|
|
|
|
params=params,
|
|
|
|
|
headers=headers,
|
2026-05-10 02:02:48 +03:30
|
|
|
proxies=build_requests_proxies(self.proxy_url),
|
2026-04-29 01:27:29 +03:30
|
|
|
timeout=self.timeout,
|
|
|
|
|
)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 02:02:48 +03:30
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 01:27:29 +03:30
|
|
|
class MockWeatherAdapter(BaseWeatherAdapter):
|
|
|
|
|
source_name = "mock"
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
delay_seconds: float = 0.8,
|
|
|
|
|
seed_namespace: str = "croplogic-weather",
|
|
|
|
|
):
|
|
|
|
|
self.delay_seconds = max(0.0, delay_seconds)
|
|
|
|
|
self.seed_namespace = seed_namespace
|
|
|
|
|
|
|
|
|
|
def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict:
|
|
|
|
|
if self.delay_seconds:
|
|
|
|
|
time.sleep(self.delay_seconds)
|
|
|
|
|
|
|
|
|
|
climate = self._layered_noise(latitude, longitude, "climate")
|
|
|
|
|
humidity_bias = self._layered_noise(latitude, longitude, "humidity")
|
|
|
|
|
rain_bias = self._layered_noise(latitude, longitude, "rain")
|
|
|
|
|
wind_bias = self._layered_noise(latitude, longitude, "wind")
|
|
|
|
|
temp_bias = self._layered_noise(latitude, longitude, "temp")
|
|
|
|
|
|
|
|
|
|
start = date.today()
|
|
|
|
|
payload = {field: [] for field in DAILY_FIELDS}
|
|
|
|
|
payload["time"] = []
|
|
|
|
|
|
|
|
|
|
for day_index in range(days):
|
|
|
|
|
current_date = start + timedelta(days=day_index)
|
|
|
|
|
seasonal_wave = math.sin(((current_date.timetuple().tm_yday / 365.0) * math.tau) - 0.55)
|
|
|
|
|
daily_wave = math.sin((day_index / max(days, 1)) * math.tau)
|
|
|
|
|
short_term = self._layered_noise(
|
|
|
|
|
latitude + (day_index * 0.11),
|
|
|
|
|
longitude - (day_index * 0.09),
|
|
|
|
|
f"day:{day_index}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
temp_mean = _clamp(
|
|
|
|
|
17.0
|
|
|
|
|
+ (seasonal_wave * 11.0)
|
|
|
|
|
+ ((temp_bias - 0.5) * 8.0)
|
|
|
|
|
+ (daily_wave * 2.8)
|
|
|
|
|
+ ((short_term - 0.5) * 2.5),
|
|
|
|
|
-6.0,
|
|
|
|
|
43.0,
|
|
|
|
|
)
|
|
|
|
|
diurnal_range = _clamp(
|
|
|
|
|
8.0 + ((1 - humidity_bias) * 4.2) + ((1 - rain_bias) * 2.0) + (short_term * 1.1),
|
|
|
|
|
5.0,
|
|
|
|
|
16.0,
|
|
|
|
|
)
|
|
|
|
|
temperature_max = _clamp(temp_mean + (diurnal_range / 2.0), -3.0, 48.0)
|
|
|
|
|
temperature_min = _clamp(temp_mean - (diurnal_range / 2.0), -12.0, 35.0)
|
|
|
|
|
|
|
|
|
|
humidity_mean = _clamp(
|
|
|
|
|
34.0
|
|
|
|
|
+ (humidity_bias * 34.0)
|
|
|
|
|
+ (rain_bias * 12.0)
|
|
|
|
|
- ((temperature_max - 22.0) * 0.9),
|
|
|
|
|
18.0,
|
|
|
|
|
96.0,
|
|
|
|
|
)
|
|
|
|
|
precipitation_probability = _clamp(
|
|
|
|
|
10.0
|
|
|
|
|
+ (rain_bias * 45.0)
|
|
|
|
|
+ ((humidity_mean - 45.0) * 0.45)
|
|
|
|
|
+ (max(0.0, 0.5 - temp_bias) * 18.0)
|
|
|
|
|
+ ((short_term - 0.5) * 18.0),
|
|
|
|
|
0.0,
|
|
|
|
|
100.0,
|
|
|
|
|
)
|
|
|
|
|
precipitation = self._precipitation_amount(
|
|
|
|
|
precipitation_probability=precipitation_probability,
|
|
|
|
|
rain_bias=rain_bias,
|
|
|
|
|
humidity_mean=humidity_mean,
|
|
|
|
|
short_term=short_term,
|
|
|
|
|
)
|
|
|
|
|
wind_speed = _clamp(
|
|
|
|
|
8.0
|
|
|
|
|
+ (wind_bias * 17.0)
|
|
|
|
|
+ ((1 - rain_bias) * 2.5)
|
|
|
|
|
+ (abs(daily_wave) * 3.0)
|
|
|
|
|
+ (short_term * 2.0),
|
|
|
|
|
3.0,
|
|
|
|
|
42.0,
|
|
|
|
|
)
|
|
|
|
|
et0 = _clamp(
|
|
|
|
|
1.0
|
|
|
|
|
+ (max(temp_mean, 0.0) * 0.11)
|
|
|
|
|
+ ((1 - (humidity_mean / 100.0)) * 1.7)
|
|
|
|
|
+ (wind_speed * 0.03)
|
|
|
|
|
- (precipitation * 0.05),
|
|
|
|
|
0.3,
|
|
|
|
|
11.0,
|
|
|
|
|
)
|
|
|
|
|
weather_code = self._weather_code(
|
|
|
|
|
precipitation=precipitation,
|
|
|
|
|
probability=precipitation_probability,
|
|
|
|
|
humidity=humidity_mean,
|
|
|
|
|
wind_speed=wind_speed,
|
|
|
|
|
cloudiness=(humidity_bias + rain_bias + (1 - temp_bias)) / 3.0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
payload["time"].append(current_date.isoformat())
|
|
|
|
|
payload["temperature_2m_max"].append(round(temperature_max, 1))
|
|
|
|
|
payload["temperature_2m_min"].append(round(temperature_min, 1))
|
|
|
|
|
payload["temperature_2m_mean"].append(round(temp_mean, 1))
|
|
|
|
|
payload["precipitation_sum"].append(round(precipitation, 1))
|
|
|
|
|
payload["precipitation_probability_max"].append(round(precipitation_probability, 0))
|
|
|
|
|
payload["relative_humidity_2m_mean"].append(round(humidity_mean, 1))
|
|
|
|
|
payload["wind_speed_10m_max"].append(round(wind_speed, 1))
|
|
|
|
|
payload["et0_fao_evapotranspiration"].append(round(et0, 2))
|
|
|
|
|
payload["weather_code"].append(weather_code)
|
|
|
|
|
|
|
|
|
|
return {"latitude": latitude, "longitude": longitude, "daily": payload}
|
|
|
|
|
|
|
|
|
|
def _precipitation_amount(
|
|
|
|
|
self,
|
|
|
|
|
precipitation_probability: float,
|
|
|
|
|
rain_bias: float,
|
|
|
|
|
humidity_mean: float,
|
|
|
|
|
short_term: float,
|
|
|
|
|
) -> float:
|
|
|
|
|
trigger = precipitation_probability / 100.0
|
|
|
|
|
if trigger < 0.24:
|
|
|
|
|
return 0.0
|
|
|
|
|
|
|
|
|
|
amount = (
|
|
|
|
|
((trigger - 0.2) ** 1.55) * 18.0
|
|
|
|
|
+ (rain_bias * 1.6)
|
|
|
|
|
+ ((humidity_mean - 50.0) * 0.035)
|
|
|
|
|
+ (short_term * 1.3)
|
|
|
|
|
)
|
|
|
|
|
return _clamp(amount, 0.0, 34.0)
|
|
|
|
|
|
|
|
|
|
def _weather_code(
|
|
|
|
|
self,
|
|
|
|
|
precipitation: float,
|
|
|
|
|
probability: float,
|
|
|
|
|
humidity: float,
|
|
|
|
|
wind_speed: float,
|
|
|
|
|
cloudiness: float,
|
|
|
|
|
) -> int:
|
|
|
|
|
if precipitation >= 10:
|
|
|
|
|
return 65
|
|
|
|
|
if precipitation >= 4:
|
|
|
|
|
return 63
|
|
|
|
|
if precipitation > 0.6:
|
|
|
|
|
return 61
|
|
|
|
|
if probability >= 65 and humidity >= 70:
|
|
|
|
|
return 51
|
|
|
|
|
if cloudiness >= 0.8:
|
|
|
|
|
return 3
|
|
|
|
|
if cloudiness >= 0.62:
|
|
|
|
|
return 2
|
|
|
|
|
if cloudiness >= 0.48 or wind_speed >= 28:
|
|
|
|
|
return 1
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
def _layered_noise(self, latitude: float, longitude: float, key: str) -> float:
|
|
|
|
|
regional = self._smooth_noise(latitude, longitude, f"{key}:regional", scale=2.4)
|
|
|
|
|
local = self._smooth_noise(latitude, longitude, f"{key}:local", scale=0.45)
|
|
|
|
|
micro = self._smooth_noise(latitude, longitude, f"{key}:micro", scale=0.12)
|
|
|
|
|
return _clamp((regional * 0.58) + (local * 0.27) + (micro * 0.15), 0.0, 1.0)
|
|
|
|
|
|
|
|
|
|
def _smooth_noise(self, latitude: float, longitude: float, key: str, scale: float) -> float:
|
|
|
|
|
grid_x = longitude / scale
|
|
|
|
|
grid_y = latitude / scale
|
|
|
|
|
x0 = math.floor(grid_x)
|
|
|
|
|
y0 = math.floor(grid_y)
|
|
|
|
|
tx = grid_x - x0
|
|
|
|
|
ty = grid_y - y0
|
|
|
|
|
|
|
|
|
|
v00 = self._cell_noise(key, x0, y0)
|
|
|
|
|
v10 = self._cell_noise(key, x0 + 1, y0)
|
|
|
|
|
v01 = self._cell_noise(key, x0, y0 + 1)
|
|
|
|
|
v11 = self._cell_noise(key, x0 + 1, y0 + 1)
|
|
|
|
|
|
|
|
|
|
tx = tx * tx * (3.0 - (2.0 * tx))
|
|
|
|
|
ty = ty * ty * (3.0 - (2.0 * ty))
|
|
|
|
|
top = (v00 * (1 - tx)) + (v10 * tx)
|
|
|
|
|
bottom = (v01 * (1 - tx)) + (v11 * tx)
|
|
|
|
|
return (top * (1 - ty)) + (bottom * ty)
|
|
|
|
|
|
|
|
|
|
def _cell_noise(self, key: str, grid_x: int, grid_y: int) -> float:
|
|
|
|
|
seed_input = f"{self.seed_namespace}:{key}:{grid_x}:{grid_y}"
|
|
|
|
|
digest = hashlib.sha256(seed_input.encode("ascii")).digest()
|
|
|
|
|
seed = int.from_bytes(digest[:8], "big", signed=False)
|
|
|
|
|
return random.Random(seed).random()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_weather_adapter() -> BaseWeatherAdapter:
|
|
|
|
|
from django.conf import settings
|
|
|
|
|
|
2026-05-05 21:02:12 +03:30
|
|
|
provider = getattr(settings, "WEATHER_DATA_PROVIDER", "open-meteo")
|
2026-04-29 01:27:29 +03:30
|
|
|
if provider == "open-meteo":
|
|
|
|
|
return OpenMeteoWeatherAdapter(
|
|
|
|
|
base_url=settings.WEATHER_API_BASE_URL,
|
|
|
|
|
api_key=settings.WEATHER_API_KEY,
|
|
|
|
|
timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60),
|
2026-05-10 02:02:48 +03:30
|
|
|
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),
|
2026-04-29 01:27:29 +03:30
|
|
|
)
|
|
|
|
|
if provider == "mock":
|
2026-05-05 21:02:12 +03:30
|
|
|
if not (getattr(settings, "DEBUG", False) or getattr(settings, "DEVELOP", False)):
|
|
|
|
|
raise RuntimeError("Mock weather provider is disabled outside dev/test environments.")
|
2026-04-29 01:27:29 +03:30
|
|
|
return MockWeatherAdapter(
|
|
|
|
|
delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8)
|
|
|
|
|
)
|
|
|
|
|
raise ValueError(f"Unsupported weather data provider: {provider}")
|
2026-05-10 02:02:48 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|