Download app/utils/climate_intelligence.py from nexusbert/Aglimate: direct link, hf CLI and curl.
- Browser
- Download file 41.3 kB
-
https://huggingface.co/spaces/nexusbert/Aglimate/resolve/main/app/utils/climate_intelligence.py
- Command line
-
hf download hf://spaces/nexusbert/Aglimate/app/utils/climate_intelligence.py
-
curl -L -o climate_intelligence.py https://huggingface.co/spaces/nexusbert/Aglimate/resolve/main/app/utils/climate_intelligence.py
41.3 kB
| import logging | |
| import re | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Any, Dict, List, Optional, Sequence, Tuple | |
| from app.utils.text_cleaning import dedupe_recommendations, join_risk_reason | |
| logger = logging.getLogger(__name__) | |
| try: | |
| import openmeteo_requests | |
| import pandas as pd | |
| import requests_cache | |
| from retry_requests import retry | |
| except Exception: # pragma: no cover - optional dependency fallback for offline use | |
| openmeteo_requests = None | |
| pd = None | |
| requests_cache = None | |
| retry = None | |
| NIGERIA_LOCATION_COORDS: Dict[str, Tuple[float, float]] = { | |
| "lagos": (6.5244, 3.3792), | |
| "ikeja": (6.596, 3.342), | |
| "abuja": (9.0765, 7.3986), | |
| "kaduna": (10.5222, 7.4383), | |
| "kano": (12.0022, 8.5920), | |
| "ibadan": (7.3775, 3.9470), | |
| "port harcourt": (4.8156, 7.0498), | |
| "enugu": (6.5244, 7.4954), | |
| "jos": (9.8965, 8.8583), | |
| "owerri": (5.4833, 7.0353), | |
| "katsina": (12.9894, 7.6000), | |
| "bauchi": (10.3158, 9.8433), | |
| "gombe": (10.2897, 11.1670), | |
| "sokoto": (13.0627, 5.2430), | |
| "minna": (9.6150, 6.5469), | |
| "akure": (7.2508, 5.1950), | |
| "ilorin": (8.4966, 4.5421), | |
| "oyo": (7.8500, 3.9300), | |
| "niger": (9.6500, 6.3500), | |
| "kogi": (7.8000, 6.7400), | |
| "benue": (7.7300, 8.5200), | |
| "nasarawa": (8.5333, 8.5200), | |
| "plateau": (9.9200, 8.9000), | |
| "rivers": (4.8156, 7.0498), | |
| "ebonyi": (6.2600, 8.1300), | |
| "anambra": (6.2100, 7.0700), | |
| "bayelsa": (4.9240, 6.0890), | |
| "delta": (5.8940, 5.8620), | |
| "edo": (6.5000, 5.7500), | |
| "kwara": (8.5000, 4.5500), | |
| "ogun": (7.0000, 3.3500), | |
| "osun": (7.7700, 4.5600), | |
| "ondo": (7.1000, 4.8400), | |
| "taraba": (7.8700, 9.7800), | |
| "zamfara": (12.1700, 6.6600), | |
| "kebbi": (12.4530, 4.1970), | |
| "yobe": (11.7333, 11.0833), | |
| "jigawa": (12.2280, 9.9960), | |
| "adamawa": (9.3260, 12.3950), | |
| "akwa ibom": (5.0300, 7.9200), | |
| "cross river": (5.8800, 8.3400), | |
| } | |
| CROP_REQUIREMENTS: Dict[str, Dict[str, str]] = { | |
| "maize": { | |
| "ideal": "Moderate rainfall during establishment; avoid prolonged dry spells before tasselling.", | |
| "warning": "Poor emergence and yield loss if rainfall is delayed or dry spells persist.", | |
| }, | |
| "rice": { | |
| "ideal": "Saturated soils or consistent moisture during early growth and tillering.", | |
| "warning": "Waterlogging and nutrient losses can occur during intense rainfall events.", | |
| }, | |
| "tomato": { | |
| "ideal": "Regular moisture, but avoid persistent wet conditions that encourage fungal disease.", | |
| "warning": "Heat stress and excess humidity raise disease and blossom-drop risk.", | |
| }, | |
| "cassava": { | |
| "ideal": "Well-distributed rainfall and adequate soil moisture during establishment.", | |
| "warning": "Severe drought and heat can suppress rooting and tuber formation.", | |
| }, | |
| "yam": { | |
| "ideal": "Deep, well-drained fertile soil with moderate moisture during vine development.", | |
| "warning": "Excess water causes sett rot, while dry soil hardens mounds and stunts tubers.", | |
| }, | |
| "sorghum": { | |
| "ideal": "Tolerant to heat and drought; requires moderate rain at establishment and grain filling.", | |
| "warning": "Excessive wetness at maturity causes grain mold and lodging.", | |
| }, | |
| "millet": { | |
| "ideal": "Highly drought-hardy; thrives in well-drained sandy loams with modest rains.", | |
| "warning": "Poor seedling emergence if soil crusts form after brief hard rains.", | |
| }, | |
| "cowpea": { | |
| "ideal": "Warm temperatures and moderate rainfall; well-drained soils for nitrogen fixation.", | |
| "warning": "Excessive humidity and rain during flowering/podding trigger pod borers and fungal rot.", | |
| }, | |
| "soybean": { | |
| "ideal": "Adequate soil moisture from emergence to pod filling with good drainage.", | |
| "warning": "Drought during flowering causes flower abortion and poor pod set.", | |
| }, | |
| "groundnut": { | |
| "ideal": "Moisture at germination and flowering; avoid prolonged waterlogging.", | |
| "warning": "Dry spells during pegging and pod filling severely reduce pod yield.", | |
| }, | |
| "cocoa": { | |
| "ideal": "Warm, humid tropical climate with evenly distributed rainfall and partial shade for young trees.", | |
| "warning": "Prolonged wetness without sun increases black pod disease; severe dry season causes leaf fall.", | |
| }, | |
| "plantain": { | |
| "ideal": "High humidity, continuous soil moisture, and rich organic mulch with wind protection.", | |
| "warning": "Drought stunts bunch size, while strong winds cause pseudo-stem snapping.", | |
| }, | |
| "pepper": { | |
| "ideal": "Warm temperatures, well-drained soil, and consistent moderate watering.", | |
| "warning": "Standing water causes root rot and bacterial wilt; intense heat causes blossom drop.", | |
| }, | |
| "onion": { | |
| "ideal": "Cooler early vegetative stage and warm dry conditions for bulb maturity and curing.", | |
| "warning": "High rainfall and humidity during bulb development induce purple blotch and rot.", | |
| }, | |
| } | |
| CROP_KEYWORDS: Dict[str, List[str]] = { | |
| "maize": ["maize", "corn"], | |
| "rice": ["rice", "paddy"], | |
| "tomato": ["tomato", "tomatoes"], | |
| "cassava": ["cassava", "manioc", "tuber"], | |
| "yam": ["yam", "yams"], | |
| "sorghum": ["sorghum", "guinea corn"], | |
| "millet": ["millet"], | |
| "cowpea": ["cowpea", "beans", "black-eyed pea"], | |
| "soybean": ["soybean", "soya"], | |
| "groundnut": ["groundnut", "peanut", "peanuts"], | |
| "cocoa": ["cocoa", "cacao"], | |
| "plantain": ["plantain", "banana"], | |
| "pepper": ["pepper", "peppers", "chili", "habanero", "ata rodo"], | |
| "onion": ["onion", "onions"], | |
| } | |
| GROWTH_STAGE_PATTERNS: Dict[str, List[str]] = { | |
| "pre-planting": ["before planting", "pre planting", "pre-planting", "planting soon", "ready to plant", "sowing soon"], | |
| "planting": ["planting", "sowing", "seedbed", "direct sowing"], | |
| "early-growth": ["seedling", "early growth", "germination", "seedling stage"], | |
| "flowering": ["flowering", "tasselling", "blooming"], | |
| "fruiting": ["fruiting", "grain filling", "pod filling", "harvest soon"], | |
| } | |
| def extract_nigeria_location(query: str) -> Tuple[str, Optional[float], Optional[float]]: | |
| if not query: | |
| return "", None, None | |
| q = query.strip().lower() | |
| ordered_locations = sorted(NIGERIA_LOCATION_COORDS.keys(), key=lambda item: len(item), reverse=True) | |
| for location in ordered_locations: | |
| pattern = rf"\b{re.escape(location)}\b" | |
| if re.search(pattern, q): | |
| lat, lon = NIGERIA_LOCATION_COORDS[location] | |
| return location.title(), lat, lon | |
| return "", None, None | |
| def infer_crop_and_growth_stage(query: str) -> Tuple[Optional[str], Optional[str]]: | |
| if not query: | |
| return None, None | |
| q = query.strip().lower() | |
| crop = None | |
| for key, aliases in CROP_KEYWORDS.items(): | |
| if any(alias in q for alias in aliases): | |
| crop = key | |
| break | |
| stage = None | |
| for key, patterns in GROWTH_STAGE_PATTERNS.items(): | |
| if any(pattern in q for pattern in patterns): | |
| stage = key | |
| break | |
| if crop is None and "maize" in q: | |
| crop = "maize" | |
| if stage is None and any(term in q for term in [ | |
| "before planting", | |
| "pre planting", | |
| "plant soon", | |
| "ready to sow", | |
| "before the rains", | |
| "before rains", | |
| "before rain", | |
| ]): | |
| stage = "pre-planting" | |
| if stage is None and any(term in q for term in ["planting", "sowing", "plant maize", "plant rice", "plant cassava"]): | |
| stage = "planting" | |
| if stage is None and "should i plant" in q: | |
| stage = "pre-planting" | |
| return crop, stage | |
| def _normalize_crop(crop: Optional[str]) -> str: | |
| if crop is None: | |
| return "general" | |
| key = crop.strip().lower().replace(" ", "") | |
| if key in CROP_REQUIREMENTS: | |
| return key | |
| return "general" | |
| def _safe_float(value: Any, default: float = 0.0) -> float: | |
| try: | |
| return float(value) | |
| except (TypeError, ValueError): | |
| return default | |
| def _get_openmeteo_client(): | |
| if openmeteo_requests is None: | |
| return None | |
| try: | |
| cache_session = requests_cache.CachedSession(".cache", expire_after=3600) | |
| retry_session = retry(cache_session, retries=5, backoff_factor=0.2) | |
| return openmeteo_requests.Client(session=retry_session) | |
| except Exception: | |
| return None | |
| # WMO weather codes → human text. | |
| # Open-Meteo returns WMO codes as an integer; we map them to short text | |
| # so the climate-first prompt reads as climate context, not weather trivia. | |
| # The single value here is never the headline — climate picture is. | |
| WMO_WEATHER_CODES: Dict[int, str] = { | |
| 0: "clear sky", | |
| 1: "mainly clear", | |
| 2: "partly cloudy", | |
| 3: "overcast", | |
| 45: "fog", | |
| 48: "depositing rime fog", | |
| 51: "light drizzle", | |
| 53: "moderate drizzle", | |
| 55: "dense drizzle", | |
| 56: "light freezing drizzle", | |
| 57: "dense freezing drizzle", | |
| 61: "light rain", | |
| 63: "moderate rain", | |
| 65: "heavy rain", | |
| 66: "light freezing rain", | |
| 67: "heavy freezing rain", | |
| 71: "light snow", | |
| 73: "moderate snow", | |
| 75: "heavy snow", | |
| 77: "snow grains", | |
| 80: "light rain showers", | |
| 81: "moderate rain showers", | |
| 82: "violent rain showers", | |
| 85: "light snow showers", | |
| 86: "heavy snow showers", | |
| 95: "thunderstorm", | |
| 96: "thunderstorm with light hail", | |
| 99: "thunderstorm with heavy hail", | |
| } | |
| def _wmo_text(code: Optional[int]) -> str: | |
| if code is None: | |
| return "unknown" | |
| return WMO_WEATHER_CODES.get(int(code), f"unclassified (code {int(code)})") | |
| def _forecast_from_openmeteo(latitude: float, longitude: float, days: int = 7) -> Dict[str, Any]: | |
| client = _get_openmeteo_client() | |
| if client is None: | |
| raise RuntimeError("Open-Meteo client is unavailable") | |
| url = "https://api.open-meteo.com/v1/forecast" | |
| # NOTE: variable order in this list is the contract for the numeric | |
| # indices used to read the flatbuffers response below. Re-ordering | |
| # silently breaks the climate layer. | |
| params = { | |
| "latitude": latitude, | |
| "longitude": longitude, | |
| "current": [ | |
| "temperature_2m", # 0 | |
| "relative_humidity_2m", # 1 | |
| "apparent_temperature", # 2 | |
| "precipitation", # 3 | |
| "rain", # 4 | |
| "weather_code", # 5 | |
| "wind_speed_10m", # 6 | |
| "et0_fao_evapotranspiration", # 7 (climate-grade ET reference) | |
| ], | |
| "daily": [ | |
| "weather_code", # 0 | |
| "temperature_2m_max", # 1 | |
| "temperature_2m_min", # 2 | |
| "precipitation_sum", # 3 | |
| "rain_sum", # 4 | |
| "daylight_duration", # 5 | |
| "et0_fao_evapotranspiration", # 6 (climate-grade ET reference) | |
| ], | |
| # Open-Meteo forecast free tier: up to 16 days. We were clamped to 10; | |
| # lifting to 16 gives the climate layer a fuller trend window. | |
| "forecast_days": max(1, min(int(days), 16)), | |
| # Yesterday as a weather signal informing the climate picture. | |
| "past_days": 1, | |
| "timezone": "auto", | |
| } | |
| responses = client.weather_api(url, params=params) | |
| response = responses[0] | |
| curr = response.Current() | |
| daily = response.Daily() | |
| weather_code_int = int(curr.Variables(5).Value()) | |
| current = { | |
| "temperature_c": _safe_float(curr.Variables(0).Value(), 0.0), | |
| "apparent_temperature_c": _safe_float(curr.Variables(2).Value(), 0.0), | |
| "relative_humidity_percent": _safe_float(curr.Variables(1).Value(), 0.0), | |
| "precipitation_mm": _safe_float(curr.Variables(3).Value(), 0.0), | |
| "rain_mm": _safe_float(curr.Variables(4).Value(), 0.0), | |
| "wind_speed_kph": _safe_float(curr.Variables(6).Value(), 0.0), | |
| "weather_code": weather_code_int, | |
| "weather_code_text": _wmo_text(weather_code_int), | |
| # Reference evapotranspiration (FAO) — climate-grade ET signal that | |
| # corroborates WaPOR's satellite ET for the climate picture. | |
| "et0_fao_mm": _safe_float(curr.Variables(7).Value(), 0.0), | |
| } | |
| daily_dates = pd.date_range( | |
| start=pd.to_datetime(daily.Time(), unit="s", utc=True), | |
| end=pd.to_datetime(daily.TimeEnd(), unit="s", utc=True), | |
| freq=pd.Timedelta(seconds=daily.Interval()), | |
| inclusive="left", | |
| ) if pd is not None else [] | |
| max_temp = daily.Variables(1).ValuesAsNumpy() if daily.Variables(1) else [] | |
| min_temp = daily.Variables(2).ValuesAsNumpy() if daily.Variables(2) else [] | |
| precip_sum = daily.Variables(3).ValuesAsNumpy() if daily.Variables(3) else [] | |
| rain_sum = daily.Variables(4).ValuesAsNumpy() if daily.Variables(4) else [] | |
| # et0_fao_evapotranspiration on daily is index 6 (see contract above). | |
| et0_daily = daily.Variables(6).ValuesAsNumpy() if daily.Variables(6) else [] | |
| forecast_days = [] | |
| for index, date_value in enumerate(daily_dates): | |
| forecast_days.append( | |
| { | |
| "date": date_value.strftime("%Y-%m-%d"), | |
| "temperature_max_c": _safe_float(max_temp[index], 0.0), | |
| "temperature_min_c": _safe_float(min_temp[index], 0.0), | |
| "precipitation_mm": _safe_float(precip_sum[index], 0.0), | |
| "rain_mm": _safe_float(rain_sum[index], 0.0), | |
| "et0_fao_mm": _safe_float(et0_daily[index], 0.0) if index < len(et0_daily) else 0.0, | |
| } | |
| ) | |
| return { | |
| "location": { | |
| "latitude": latitude, | |
| "longitude": longitude, | |
| "country": "Nigeria", | |
| "source": "Open-Meteo forecast", | |
| }, | |
| "current_weather": current, | |
| "forecast": forecast_days, | |
| } | |
| def _historical_from_openmeteo(latitude: float, longitude: float, days: int = 30) -> Dict[str, Any]: | |
| client = _get_openmeteo_client() | |
| if client is None: | |
| raise RuntimeError("Open-Meteo client is unavailable") | |
| end_date = datetime.now(timezone.utc).date() | |
| start_date = end_date - timedelta(days=max(1, int(days))) | |
| url = "https://archive-api.open-meteo.com/v1/archive" | |
| params = { | |
| "latitude": latitude, | |
| "longitude": longitude, | |
| "start_date": start_date.isoformat(), | |
| "end_date": end_date.isoformat(), | |
| "daily": ["temperature_2m_max", "temperature_2m_min", "precipitation_sum"], | |
| "timezone": "auto", | |
| } | |
| responses = client.weather_api(url, params=params) | |
| response = responses[0] | |
| daily = response.Daily() | |
| if pd is None: | |
| return {"recent_rainfall_mm": 0.0, "average_max_temp_c": 0.0, "average_min_temp_c": 0.0} | |
| dates = pd.date_range( | |
| start=pd.to_datetime(daily.Time(), unit="s", utc=True), | |
| end=pd.to_datetime(daily.TimeEnd(), unit="s", utc=True), | |
| freq=pd.Timedelta(seconds=daily.Interval()), | |
| inclusive="left", | |
| ) | |
| max_temp = daily.Variables(0).ValuesAsNumpy() | |
| min_temp = daily.Variables(1).ValuesAsNumpy() | |
| precip = daily.Variables(2).ValuesAsNumpy() | |
| values = [] | |
| for idx, date_value in enumerate(dates): | |
| values.append( | |
| { | |
| "date": date_value.strftime("%Y-%m-%d"), | |
| "temperature_max_c": _safe_float(max_temp[idx], 0.0), | |
| "temperature_min_c": _safe_float(min_temp[idx], 0.0), | |
| "precipitation_mm": _safe_float(precip[idx], 0.0), | |
| } | |
| ) | |
| total_rain = sum(item["precipitation_mm"] for item in values) | |
| avg_max = sum(item["temperature_max_c"] for item in values) / max(len(values), 1) | |
| avg_min = sum(item["temperature_min_c"] for item in values) / max(len(values), 1) | |
| return { | |
| "period_days": len(values), | |
| "recent_rainfall_mm": round(total_rain, 1), | |
| "average_max_temp_c": round(avg_max, 1), | |
| "average_min_temp_c": round(avg_min, 1), | |
| "daily_history": values, | |
| } | |
| def _climate_projection_from_openmeteo( | |
| latitude: float, | |
| longitude: float, | |
| models: Optional[Sequence[str]] = None, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Long-horizon climate projection (1950-2050) from the Open-Meteo Climate API. | |
| Unlike the forecast + archive endpoints, this returns downscaled CMIP6 | |
| climate model output that lets us say things like "rainfall in your | |
| location is projected to drop 8% by 2040" — the strongest possible | |
| signal for *climate-resilient* advisory. The endpoint is a separate | |
| URL (climate-api.open-meteo.com) and requires the ``models`` parameter, | |
| so it has its own function. | |
| Failures are non-fatal: the climate-first path falls back to the | |
| forecast + archive signal when this is unavailable. | |
| """ | |
| client = _get_openmeteo_client() | |
| if client is None or pd is None: | |
| raise RuntimeError("Open-Meteo client or pandas is unavailable") | |
| # Two models give us an ensemble signal at reasonable cost. We avoid | |
| # all 7 because (a) it bloats the prompt, (b) most lack soil moisture | |
| # so the union is incomplete anyway, and (c) the climate layer needs | |
| # to stay fast on cold-start. | |
| selected_models = list(models) if models else ["EC_Earth3P_HR", "MRI_AGCM3_2_S"] | |
| url = "https://climate-api.open-meteo.com/v1/climate" | |
| params = { | |
| "latitude": latitude, | |
| "longitude": longitude, | |
| "start_date": "1950-01-01", | |
| "end_date": "2050-12-31", | |
| "models": selected_models, | |
| "daily": [ | |
| "temperature_2m_mean", | |
| "precipitation_sum", | |
| ], | |
| } | |
| responses = client.weather_api(url, params=params) | |
| if not responses: | |
| raise RuntimeError("Climate API returned no responses") | |
| by_model: Dict[str, Dict[str, Any]] = {} | |
| for response in responses: | |
| try: | |
| model_name = response.Model() or "unknown" | |
| except Exception: | |
| model_name = "unknown" | |
| daily = response.Daily() | |
| if daily is None: | |
| continue | |
| # Order matches the request: 0=temperature_2m_mean, 1=precipitation_sum. | |
| temp_arr = daily.Variables(0).ValuesAsNumpy() if daily.Variables(0) else None | |
| precip_arr = daily.Variables(1).ValuesAsNumpy() if daily.Variables(1) else None | |
| if temp_arr is None or precip_arr is None: | |
| continue | |
| dates = pd.date_range( | |
| start=pd.to_datetime(daily.Time(), unit="s", utc=True), | |
| end=pd.to_datetime(daily.TimeEnd(), unit="s", utc=True), | |
| freq=pd.Timedelta(seconds=daily.Interval()), | |
| inclusive="left", | |
| ) | |
| by_model[model_name] = { | |
| "dates": dates, | |
| "temperature": temp_arr, | |
| "precipitation": precip_arr, | |
| } | |
| if not by_model: | |
| raise RuntimeError("Climate API returned no usable model data") | |
| # Build decade buckets (1960s baseline, 2030s horizon, 2040s horizon). | |
| # We use full decades when we have the data, otherwise widen to whatever | |
| # is available. | |
| def _decade_stats(years: Sequence[int]) -> Dict[str, float]: | |
| temps: List[float] = [] | |
| precips: List[float] = [] | |
| for model_data in by_model.values(): | |
| dates = model_data["dates"] | |
| mask = (dates.year >= years[0]) & (dates.year <= years[1]) | |
| if not mask.any(): | |
| continue | |
| temps.extend(model_data["temperature"][mask].tolist()) | |
| precips.extend(model_data["precipitation"][mask].tolist()) | |
| if not temps or not precips: | |
| return {"temperature_mean_c": 0.0, "precipitation_annual_mm": 0.0, "sample_years": 0} | |
| avg_temp = sum(temps) / max(len(temps), 1) | |
| # Sum to an annual figure using the number of years actually sampled. | |
| years_in_window = max(1, years[1] - years[0] + 1) | |
| # Precip values are mm/day; multiply by 365 to get an annual mm figure. | |
| avg_precip_daily = sum(precips) / max(len(precips), 1) | |
| annual_mm = avg_precip_daily * 365.0 | |
| return { | |
| "temperature_mean_c": round(avg_temp, 2), | |
| "precipitation_annual_mm": round(annual_mm, 1), | |
| "sample_years": years_in_window, | |
| } | |
| # 1961-1990 is the standard WMO climate normal baseline; 2031-2050 is | |
| # the IPCC near-term horizon. Use those when we have them. | |
| baseline = _decade_stats((1961, 1990)) | |
| horizon_2030s = _decade_stats((2031, 2050)) | |
| horizon_2040s = _decade_stats((2040, 2050)) | |
| def _delta(b: Dict[str, float], h: Dict[str, float]) -> Dict[str, Any]: | |
| if b["sample_years"] == 0 or h["sample_years"] == 0: | |
| return { | |
| "temperature_delta_c": None, | |
| "precipitation_delta_pct": None, | |
| } | |
| temp_delta = round(h["temperature_mean_c"] - b["temperature_mean_c"], 2) | |
| rain_delta_pct = None | |
| if b["precipitation_annual_mm"] > 0: | |
| rain_delta_pct = round( | |
| (h["precipitation_annual_mm"] - b["precipitation_annual_mm"]) | |
| / b["precipitation_annual_mm"] * 100.0, | |
| 1, | |
| ) | |
| return { | |
| "temperature_delta_c": temp_delta, | |
| "precipitation_delta_pct": rain_delta_pct, | |
| } | |
| # Ensure model names are strings (API may return int IDs) | |
| model_names = [str(m) for m in by_model.keys()] | |
| return { | |
| "source": "Open-Meteo Climate (CMIP6)", | |
| "models": model_names, | |
| "baseline": {"label": "1961-1990", **baseline}, | |
| "horizon_2030s": {"label": "2031-2050", **horizon_2030s, **_delta(baseline, horizon_2030s)}, | |
| "horizon_2040s": {"label": "2040-2050", **horizon_2040s, **_delta(baseline, horizon_2040s)}, | |
| } | |
| def _fallback_weather(latitude: float, longitude: float, days: int = 7) -> Dict[str, Any]: | |
| current = { | |
| "temperature_c": 29.0, | |
| "apparent_temperature_c": 30.0, | |
| "relative_humidity_percent": 72.0, | |
| "precipitation_mm": 0.8, | |
| "rain_mm": 0.8, | |
| "wind_speed_kph": 12.0, | |
| "weather_code": 1, | |
| } | |
| forecast = [ | |
| { | |
| "date": (datetime.now(timezone.utc) + timedelta(days=i)).strftime("%Y-%m-%d"), | |
| "temperature_max_c": 31.0 + i * 0.25, | |
| "temperature_min_c": 23.0 + i * 0.15, | |
| "precipitation_mm": 8.0 if i % 2 == 0 else 3.0, | |
| "rain_mm": 8.0 if i % 2 == 0 else 3.0, | |
| } | |
| for i in range(max(1, int(days))) | |
| ] | |
| return { | |
| "location": { | |
| "latitude": latitude, | |
| "longitude": longitude, | |
| "country": "Nigeria", | |
| "source": "Fallback climate heuristic", | |
| }, | |
| "current_weather": current, | |
| "forecast": forecast, | |
| } | |
| def _assess_climate_risk( | |
| crop: Optional[str], | |
| growth_stage: Optional[str], | |
| current_weather: Dict[str, Any], | |
| forecast: List[Dict[str, Any]], | |
| history: Dict[str, Any], | |
| ) -> Tuple[str, int, str]: | |
| risk_score = 0 | |
| reasons: List[str] = [] | |
| temp = _safe_float(current_weather.get("temperature_c"), 28.0) | |
| humidity = _safe_float(current_weather.get("relative_humidity_percent"), 70.0) | |
| rain_7d = sum(_safe_float(item.get("precipitation_mm"), 0.0) for item in forecast[:7]) | |
| recent_rain = _safe_float(history.get("recent_rainfall_mm"), 0.0) | |
| et0_now = _safe_float(current_weather.get("et0_fao_mm"), 0.0) | |
| # 7-day cumulative atmospheric water demand (FAO ET₀ reference). Climate-grade. | |
| et0_7d = sum(_safe_float(item.get("et0_fao_mm"), 0.0) for item in forecast[:7]) | |
| # Rain - ET demand over the week is a proper climate water-balance signal. | |
| rain_minus_et_7d = rain_7d - et0_7d | |
| if recent_rain < 40: | |
| risk_score += 2 | |
| reasons.append("Recent rainfall remains below the moisture needed for establishment and early growth.") | |
| if temp >= 35: | |
| risk_score += 2 | |
| reasons.append("High temperature signals heat stress risk for crops under active growth.") | |
| if humidity >= 80 and crop in {"tomato", "maize"}: | |
| risk_score += 1 | |
| reasons.append("High humidity raises disease pressure risk in humid conditions.") | |
| if rain_7d < 20 and growth_stage in {"pre-planting", "early-growth", "seedling", "planting"}: | |
| risk_score += 2 | |
| reasons.append("Projected rainfall remains too low to support reliable planting or establishment.") | |
| if any(_safe_float(item.get("precipitation_mm"), 0.0) > 35 for item in forecast[:3]): | |
| risk_score += 1 | |
| reasons.append("Short intense rainfall events may raise runoff and waterlogging concerns.") | |
| # Climate water-balance signal: when cumulative atmospheric water demand | |
| # outpaces forecast rain over a full week, the climate is drying out faster | |
| # than the sky is replenishing it. This is climate, not weather. | |
| if et0_7d > 0 and rain_minus_et_7d < -25: | |
| risk_score += 2 | |
| reasons.append( | |
| f"Atmospheric water demand outpaces rainfall over the next week " | |
| f"(rain {rain_7d:.1f} mm vs FAO ET₀ {et0_7d:.1f} mm) — climate is pulling moisture out of the field." | |
| ) | |
| elif et0_now >= 6 and recent_rain < 40: | |
| risk_score += 1 | |
| reasons.append( | |
| f"High reference evapotranspiration today ({et0_now:.1f} mm) on top of low recent rainfall — climate-driven moisture loss." | |
| ) | |
| if crop in {"maize", "rice", "groundnut"} and growth_stage in {"pre-planting", "planting"}: | |
| risk_score += 1 | |
| if risk_score <= 2: | |
| level = "low" | |
| elif risk_score <= 5: | |
| level = "medium" | |
| else: | |
| level = "high" | |
| summary = join_risk_reason(reasons) | |
| return level, risk_score, summary | |
| def _recommendations_for_crop(crop: Optional[str], growth_stage: Optional[str], risk_level: str) -> List[str]: | |
| normalized_crop = _normalize_crop(crop) | |
| stage = (growth_stage or "general").strip().lower() | |
| if normalized_crop == "maize": | |
| if stage in {"pre-planting", "planting"}: | |
| return dedupe_recommendations([ | |
| "Delay planting until the soil has received enough cumulative rainfall for germination.", | |
| "Use conservation tillage or mulch to hold soil moisture and reduce evaporation.", | |
| "Consider a shorter-season or drought-tolerant variety if the onset remains late.", | |
| ]) | |
| return dedupe_recommendations([ | |
| "Monitor moisture stress during early vegetative growth.", | |
| "Apply mulch and avoid late irrigation to preserve soil moisture.", | |
| "Use field scouting for leaf curl, fungal disease, and nutrient stress.", | |
| ]) | |
| if normalized_crop == "cassava": | |
| if stage in {"pre-planting", "planting", "establishment"}: | |
| return dedupe_recommendations([ | |
| "Select healthy, disease-free stem cuttings (20–25 cm long) with 4–6 viable nodes.", | |
| "Plant on raised ridges or mounds to facilitate root expansion and prevent waterlogging.", | |
| "Ensure soil is sufficiently moist at planting for rapid root sprouting and shoot emergence.", | |
| ]) | |
| return dedupe_recommendations([ | |
| "Maintain timely weeding during the first 8–12 weeks before canopy closure to conserve nutrients and moisture.", | |
| "Scout regularly for cassava mosaic disease and whitefly vectors, rogueing severely infected stems.", | |
| "Ensure field channels are clear to prevent water stagnation around developing tubers.", | |
| ]) | |
| if normalized_crop == "yam": | |
| if stage in {"pre-planting", "planting", "establishment"}: | |
| return dedupe_recommendations([ | |
| "Plant healthy seed yam setts on well-constructed mounds or ridges rich in organic matter.", | |
| "Cap or mulch mounds with dry grass to protect setts from high soil temperatures and desiccating heat.", | |
| "Provide early vine staking to maximize sunlight intercept and reduce fungal foliar disease.", | |
| ]) | |
| return dedupe_recommendations([ | |
| "Weed carefully to avoid disturbing shallow tuber roots in the mound.", | |
| "Scout for yam anthracnose disease after humid rainfall spells.", | |
| "Maintain mound shape and repair washed-out soil after heavy rains.", | |
| ]) | |
| if normalized_crop == "rice": | |
| return dedupe_recommendations([ | |
| "Keep flooded or saturated paddy conditions stable during early establishment.", | |
| "If rainfall is intense, improve drainage to avoid standing water and root stress.", | |
| "Check bund integrity and ensure a reliable water control plan is in place.", | |
| ]) | |
| if normalized_crop == "tomato": | |
| return dedupe_recommendations([ | |
| "Protect plants from heat stress by irrigating during early morning and mulching around roots.", | |
| "Improve spacing and airflow to reduce disease pressure under humid conditions.", | |
| "Avoid excess foliage wetness and monitor for early and late blight after rainfall events.", | |
| ]) | |
| if normalized_crop in {"cowpea", "soybean"}: | |
| return dedupe_recommendations([ | |
| "Plant in well-drained loamy soil to promote nodulation and avoid waterlogged seedling rot.", | |
| "Scout fields closely during flower bud formation for thrips and maruca pod borers.", | |
| "Avoid applying excess nitrogen fertilizer to prevent excessive vegetative growth over podding.", | |
| ]) | |
| if normalized_crop == "groundnut": | |
| return dedupe_recommendations([ | |
| "Ensure loose, well-drained seedbed to facilitate easy peg penetration during pegging.", | |
| "Avoid waterlogging during flowering and pod development to minimize fungal root rots.", | |
| "Scout for groundnut rosette disease transmitted by aphids and practice timely weeding.", | |
| ]) | |
| if normalized_crop in {"sorghum", "millet"}: | |
| return dedupe_recommendations([ | |
| "Sow early with the onset of rains using certified drought-tolerant seed varieties.", | |
| "Thin seedlings 2–3 weeks after emergence to optimize moisture and nutrient availability.", | |
| "Practice shallow inter-row hoeing to break soil crusts and improve rain infiltration.", | |
| ]) | |
| if normalized_crop in {"cocoa", "plantain"}: | |
| return dedupe_recommendations([ | |
| "Maintain deep organic mulch around root zones to conserve soil moisture and buffer root temperature.", | |
| "Ensure field drainage pathways prevent standing water in root zones during high-rainfall periods.", | |
| "Inspect foliage regularly for fungal diseases (e.g. black pod, sigatoka) favored by high humidity.", | |
| ]) | |
| if normalized_crop in {"pepper", "onion"}: | |
| return dedupe_recommendations([ | |
| "Transplant healthy seedlings in the late afternoon and water immediately to minimize shock.", | |
| "Use raised beds and organic mulch to regulate soil temperature and prevent root rot.", | |
| "Monitor for fungal purple blotch or damping-off during prolonged wet or overcast weather.", | |
| ]) | |
| if risk_level == "high": | |
| return dedupe_recommendations([ | |
| "Prioritize soil moisture conservation and immediate field drainage inspections.", | |
| "Delay non-urgent chemical spraying or fertilizer applications until weather stabilizes.", | |
| "Consult a local agricultural extension officer if persistent climate stress threatens the crop.", | |
| ]) | |
| return dedupe_recommendations([ | |
| "Align planting, weeding, and fertilizer timing with the upcoming 7-day rainfall outlook.", | |
| "Maintain regular scouting for soil moisture levels, leaf stress, and early pest indicators.", | |
| "Apply organic mulching and soil-building practices to improve climate resilience across the field.", | |
| ]) | |
| def build_climate_context_for_query(query: str, days: int = 7) -> str: | |
| """Build climate advisory context for a user question, using location/crop/stage extraction.""" | |
| if not query: | |
| return "" | |
| location_name, latitude, longitude = extract_nigeria_location(query) | |
| if latitude is None or longitude is None: | |
| return "" | |
| crop, growth_stage = infer_crop_and_growth_stage(query) | |
| climate_context = build_climate_intelligence_context( | |
| latitude=latitude, | |
| longitude=longitude, | |
| crop=crop, | |
| growth_stage=growth_stage, | |
| days=days, | |
| ) | |
| return ( | |
| f"Location: {location_name or 'Nigeria'}\n" | |
| f"Crop: {climate_context.get('crop', crop or 'not specified')}\n" | |
| f"Growth stage: {climate_context.get('growth_stage', growth_stage or 'not specified')}\n" | |
| f"Climate risk: {climate_context.get('climate_risk', 'unknown')}\n" | |
| f"Summary: {climate_context.get('risk_reason', 'No major risk summary available')}\n" | |
| f"Recommended actions: {'; '.join(climate_context.get('recommendations', []))}" | |
| ) | |
| def build_climate_intelligence_context( | |
| latitude: float, | |
| longitude: float, | |
| crop: Optional[str] = None, | |
| growth_stage: Optional[str] = None, | |
| days: int = 7, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Build a structured climate-risk context for use in the farmer advisory prompt. | |
| This layer combines current weather, short-term forecast, recent historical climate, | |
| crop agronomic requirements, and a simple risk assessment before the LLM is asked to give advice. | |
| """ | |
| try: | |
| climate = _forecast_from_openmeteo(latitude, longitude, days=days) | |
| except Exception as exc: | |
| logger.warning("Open-Meteo forecast failed; using fallback climate heuristic: %s", exc) | |
| climate = _fallback_weather(latitude, longitude, days=days) | |
| try: | |
| historical = _historical_from_openmeteo(latitude, longitude, days=30) | |
| except Exception as exc: | |
| logger.warning("Open-Meteo historical climate failed; using fallback history: %s", exc) | |
| historical = { | |
| "period_days": 30, | |
| "recent_rainfall_mm": 42.0, | |
| "average_max_temp_c": 30.5, | |
| "average_min_temp_c": 23.5, | |
| "daily_history": [], | |
| } | |
| # Long-horizon climate projection from CMIP6 downscaled models. | |
| # Non-fatal: if the climate-api is unreachable or returns nothing, we | |
| # silently fall through with a None projection so the rest of the | |
| # climate-first picture (forecast + 30-day history) still ships. | |
| projection: Optional[Dict[str, Any]] = None | |
| try: | |
| projection = _climate_projection_from_openmeteo(latitude, longitude) | |
| except Exception as exc: | |
| logger.warning("Open-Meteo climate projection failed; projection skipped: %s", exc) | |
| projection = None | |
| crop_name = crop or "general" | |
| crop_key = _normalize_crop(crop_name) | |
| crop_requirements = CROP_REQUIREMENTS.get(crop_key, { | |
| "ideal": "Use locally recommended practices that match the crop and the season.", | |
| "warning": "Climate variability can affect emergence, growth rate, and yield.", | |
| }) | |
| risk_level, risk_score, risk_reason = _assess_climate_risk( | |
| crop=crop_name, | |
| growth_stage=growth_stage, | |
| current_weather=climate.get("current_weather", {}), | |
| forecast=climate.get("forecast", []), | |
| history=historical, | |
| ) | |
| recommendation_list = _recommendations_for_crop(crop_name, growth_stage, risk_level) | |
| summary = { | |
| "location": { | |
| "latitude": latitude, | |
| "longitude": longitude, | |
| "country": "Nigeria", | |
| "source": climate.get("location", {}).get("source", "Open-Meteo"), | |
| }, | |
| "crop": crop_name, | |
| "growth_stage": growth_stage or "not specified", | |
| "current_weather": climate.get("current_weather", {}), | |
| "forecast": climate.get("forecast", [])[:days], | |
| "historical_climate": { | |
| "period_days": historical.get("period_days", 30), | |
| "recent_rainfall_mm": historical.get("recent_rainfall_mm", 0.0), | |
| "average_max_temp_c": historical.get("average_max_temp_c", 0.0), | |
| "average_min_temp_c": historical.get("average_min_temp_c", 0.0), | |
| }, | |
| "climate_projection": projection, | |
| "crop_requirements": crop_requirements, | |
| "climate_risk": risk_level, | |
| "risk_score": risk_score, | |
| "risk_reason": risk_reason, | |
| "recommendations": recommendation_list, | |
| } | |
| return summary | |
| def build_climate_prompt_context( | |
| latitude: float, | |
| longitude: float, | |
| crop: Optional[str] = None, | |
| growth_stage: Optional[str] = None, | |
| days: int = 7, | |
| ) -> str: | |
| context = build_climate_intelligence_context(latitude, longitude, crop=crop, growth_stage=growth_stage, days=days) | |
| current = context["current_weather"] | |
| forecast = context["forecast"] | |
| hist = context["historical_climate"] | |
| risk_reason = context["risk_reason"] | |
| recommendations = "\n- ".join(context["recommendations"]) | |
| forecast_block = [] | |
| for item in forecast[:5]: | |
| forecast_block.append( | |
| f"{item.get('date')}: max {item.get('temperature_max_c')}°C, min {item.get('temperature_min_c')}°C, rain {item.get('precipitation_mm')} mm" | |
| ) | |
| # Weather is the sky-now signal that informs the climate picture. | |
| # The headline here is the climate: where the season is heading, | |
| # what the rain trend has been, what the heat trend is doing. | |
| current_weather_text = current.get('weather_code_text') or 'unknown sky' | |
| et0_now = current.get('et0_fao_mm') | |
| et0_now_str = f"{et0_now:.1f}" if isinstance(et0_now, (int, float)) and et0_now else "n/a" | |
| # Long-horizon climate projection (CMIP6 downscaled). When available, | |
| # this is the strongest possible signal for climate-resilient advisory | |
| # — it lets the LLM recommend shorter-season varieties, drought-tolerant | |
| # cultivars, or shifted planting windows in response to a multi-decade | |
| # trajectory rather than a single season's weather. | |
| projection = context.get("climate_projection") | |
| projection_block = "- Long-horizon climate projection unavailable." | |
| if projection: | |
| baseline = projection.get("baseline", {}) | |
| h30 = projection.get("horizon_2030s", {}) | |
| h40 = projection.get("horizon_2040s", {}) | |
| models = ", ".join(projection.get("models", [])) or "n/a" | |
| b_temp = baseline.get("temperature_mean_c") | |
| h30_temp_d = h30.get("temperature_delta_c") | |
| h30_rain_d = h30.get("precipitation_delta_pct") | |
| h40_temp_d = h40.get("temperature_delta_c") | |
| h40_rain_d = h40.get("precipitation_delta_pct") | |
| b_temp_str = f"{b_temp:.1f}°C" if isinstance(b_temp, (int, float)) else "n/a" | |
| h30_temp_str = f"{h30_temp_d:+.2f}°C" if isinstance(h30_temp_d, (int, float)) else "n/a" | |
| h30_rain_str = f"{h30_rain_d:+.1f}%" if isinstance(h30_rain_d, (int, float)) else "n/a" | |
| h40_temp_str = f"{h40_temp_d:+.2f}°C" if isinstance(h40_temp_d, (int, float)) else "n/a" | |
| h40_rain_str = f"{h40_rain_d:+.1f}%" if isinstance(h40_rain_d, (int, float)) else "n/a" | |
| projection_block = ( | |
| f"- Source: {projection.get('source', 'Open-Meteo Climate')} (CMIP6 models: {models})\n" | |
| f"- Baseline ({baseline.get('label', '1961-1990')}): mean temp {b_temp_str}\n" | |
| f"- 2031-2050 horizon: temperature {h30_temp_str}, precipitation {h30_rain_str} vs baseline\n" | |
| f"- 2040-2050 horizon: temperature {h40_temp_str}, precipitation {h40_rain_str} vs baseline" | |
| ) | |
| summary = ( | |
| "CLIMATE INTELLIGENCE CONTEXT (climate-first)\n" | |
| f"Location: Nigeria (lat={latitude}, lon={longitude})\n" | |
| f"Crop: {context['crop']}\n" | |
| f"Growth stage: {context['growth_stage']}\n" | |
| "Climate-grade signals (today, used as weather evidence inside the climate picture):\n" | |
| f"- Sky right now: {current_weather_text} (WMO code)\n" | |
| f"- Temperature: {current.get('temperature_c')}°C\n" | |
| f"- Humidity: {current.get('relative_humidity_percent')}%\n" | |
| f"- Wind: {current.get('wind_speed_kph')} kph\n" | |
| f"- Recent rain: {current.get('rain_mm')} mm\n" | |
| f"- Reference evapotranspiration (FAO ET₀, today): {et0_now_str} mm\n" | |
| "Recent climate history (the actual climate picture):\n" | |
| f"- 30-day rainfall total: {hist.get('recent_rainfall_mm')} mm\n" | |
| f"- Average daily max temp (30d): {hist.get('average_max_temp_c')}°C\n" | |
| f"- Average daily min temp (30d): {hist.get('average_min_temp_c')}°C\n" | |
| "Long-horizon climate projection (CMIP6 downscaled):\n" | |
| f"{projection_block}\n" | |
| "Short-term climate trend (next 7-16 days, weather-as-signal):\n" | |
| + ("\n".join(f"- {line}" for line in forecast_block) if forecast_block else "- Forecast unavailable") | |
| + "\n" | |
| + f"Climate risk: {context['climate_risk']}\n" | |
| + f"Risk rationale: {risk_reason}\n" | |
| + "Crop agronomic requirement:\n" | |
| + f"- {context['crop_requirements'].get('ideal', 'Use crop-specific agronomic guidance')}\n" | |
| + "Recommended climate-first actions:\n" | |
| + f"- {recommendations}\n" | |
| ) | |
| return summary | |