from __future__ import annotations import hashlib import math import random import time from abc import ABC, abstractmethod 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", "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, 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 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, 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" 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), 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)): 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}") 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)