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", "mock") 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": return MockWeatherAdapter( delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8) ) raise ValueError(f"Unsupported weather data provider: {provider}")