Files
2026-05-11 03:27:21 +03:30

282 lines
9.8 KiB
Python

from __future__ import annotations
import hashlib
import math
import random
import time
from abc import ABC, abstractmethod
from datetime import date, timedelta
try:
import requests
except ImportError: # pragma: no cover - handled when live adapter is used
requests = None
DEFAULT_FORECAST_DAYS = 7
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"
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60):
self.base_url = base_url
self.api_key = api_key
self.timeout = timeout
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,
timeout=self.timeout,
)
response.raise_for_status()
return response.json()
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
provider = getattr(settings, "WEATHER_DATA_PROVIDER", "open-meteo")
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),
)
if provider == "mock":
if not (getattr(settings, "DEBUG", False) or getattr(settings, "DEVELOP", False)):
raise RuntimeError("Mock weather provider is disabled outside dev/test environments.")
return MockWeatherAdapter(
delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8)
)
raise ValueError(f"Unsupported weather data provider: {provider}")