# ============================================================================== # Climate Risk MCP Server # ============================================================================== # Multi-Agent Framework for Climate Risk Assessment # # Exposes FOUR MCP tools by calling three existing HF Spaces: # 1. weather_data_analyst → towardsinnovationlab/Weather_Data_Analyst # API endpoint: /analyze_weather # 2. weather_image_analyst → towardsinnovationlab/weather_image_analyst # API endpoint: /analyze_and_store # 3. weather_data_forecast → towardsinnovationlab/Weather_Data_Forecast # API endpoint: /main # 4. climate_risk_report → orchestrates tools 1-3 → risk narrative # # The Gradio Blocks apps above do NOT declare explicit api_name= on their # .click() listeners, so Gradio auto-names each endpoint after the Python # function: analyze_weather → /analyze_weather etc. # # Deploy on HF Spaces (Gradio SDK). # MCP endpoint after deployment: # https://.hf.space/gradio_api/mcp/sse # ============================================================================== import json import os import gradio as gr from gradio_client import Client # --------------------------------------------------------------------------- # API keys — resolved server-side so demo users never need to paste keys. # Priority: environment variables (= HF Space secrets) → local .env files. # The .env files are for LOCAL development only and must NEVER be uploaded # to the HF Space repo; on the Space, set OPENAI_API_KEY and # PERPLEXITY_API_KEY under Settings → Variables and secrets. # --------------------------------------------------------------------------- def _read_env_file(filename: str, var_name: str) -> str: """Parse 'VAR = value' lines from a local .env file next to app.py.""" path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename) try: with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue name, _, value = line.partition("=") if name.strip() == var_name: return value.strip().strip('"').strip("'") except OSError: pass return "" OPENAI_API_KEY = ( os.getenv("OPENAI_API_KEY") or _read_env_file("openai.env", "OPENAI_API_KEY") ) PPLX_API_KEY = ( os.getenv("PERPLEXITY_API_KEY") or _read_env_file("perplexity.env", "PERPLEXITY_API_KEY") ) # --------------------------------------------------------------------------- # Space URLs (direct .hf.space URLs avoid the HF hub auth check) # --------------------------------------------------------------------------- URL_DATA_ANALYST = os.getenv("URL_DATA_ANALYST") or "https://towardsinnovationlab-weather-data-analyst.hf.space" URL_IMAGE_ANALYST = os.getenv("URL_IMAGE_ANALYST") or "https://towardsinnovationlab-weather-image-analyst.hf.space" URL_FORECAST = os.getenv("URL_FORECAST") or "https://towardsinnovationlab-weather-data-forecast.hf.space" # --------------------------------------------------------------------------- # Lazy-cached clients # --------------------------------------------------------------------------- _clients: dict = {} def _get_client(url: str) -> Client: if url not in _clients: _clients[url] = Client(url) return _clients[url] def _predict_with_retry(url: str, *args, api_name: str, retries: int = 2): """ client.predict with retry — upstream HF Spaces sleep on the free tier and the first request after a cold start often times out or errors. On failure the cached client is dropped (its websocket/session may be stale) and the call is retried after a backoff. """ import time last_exc = None for attempt in range(retries + 1): try: client = _get_client(url) return client.predict(*args, api_name=api_name) except Exception as exc: last_exc = exc _clients.pop(url, None) # stale client → rebuild on next attempt if attempt < retries: time.sleep(5 * (attempt + 1)) # 5s, 10s — give the Space time to wake raise last_exc # --------------------------------------------------------------------------- # Location resolution & disambiguation # # 'Milan' matches places in Italy, the US and Colombia; every component of # this system (this app + the three upstream Spaces) geocodes independently # via Open-Meteo taking the TOP-1 result, so an ambiguous name must be pinned # to one place up-front and expressed as a string all of them resolve alike. # The API accepts 'City, CountryName' ('Milan, Italy') but NOT ISO codes # ('Milan, US' → 0 results), so qualifier matching is done client-side. # --------------------------------------------------------------------------- def _geo_search(query: str, count: int = 10) -> list: import requests as _req try: resp = _req.get( "https://geocoding-api.open-meteo.com/v1/search", params={"name": query, "count": count, "language": "en", "format": "json"}, timeout=15, ) resp.raise_for_status() return resp.json().get("results") or [] except Exception: return [] def _resolve_location(raw: str) -> dict: """ Resolve a user-typed location — 'Milan', 'Milan, Italy', 'Milan, US', 'Milan, Tennessee' — to a single place. Returns on success: display — 'Milan, Lombardy, Italy' (for the UI) latitude / longitude / timezone upstream_query — string verified to resolve to the SAME place when the upstream Spaces run their own top-1 geocoding note — ambiguity hint or warning ('' if none) Returns {'error': ...} when nothing matches. """ raw = (raw or "").strip().strip(".") if not raw: return {"error": "Please enter a location."} city, _, qualifier = raw.partition(",") city, qualifier = city.strip(), qualifier.strip() note = "" candidates = _geo_search(raw) # handles 'City' and 'City, CountryName' if not candidates and qualifier: # Qualifier the API can't parse (ISO code, alias, state name) → filter client-side pool = _geo_search(city) qual = qualifier.lower().rstrip(".") qual = { # common aliases → official country name (keep in sync upstream) "uk": "united kingdom", "u.k": "united kingdom", "great britain": "united kingdom", "usa": "united states", "u.s": "united states", "u.s.a": "united states", "america": "united states", "uae": "united arab emirates", "holland": "netherlands", }.get(qual, qual) candidates = [ c for c in pool if qual in ( (c.get("country") or "").lower(), (c.get("country_code") or "").lower(), (c.get("admin1") or "").lower(), ) ] if not candidates: opts = " · ".join( ", ".join(x for x in (c["name"], c.get("admin1"), c.get("country")) if x) for c in pool[:5] ) return {"error": f"No match for '{raw}'." + (f" Did you mean: {opts}?" if opts else "")} if not candidates: return {"error": f"Location '{raw}' not found."} best = candidates[0] if not qualifier: others = sorted({c.get("country") for c in candidates[1:]} - {best.get("country"), None}) if others: note = (f"Other places named '{best['name']}' exist in {', '.join(others)} — " f"add the country to select those (e.g. '{best['name']}, {others[0]}').") # The upstream Spaces' get_lat_lon uses this same qualifier-aware algorithm # (see the matching patch in each upstream app), so the raw input resolves # to the same place there — pass it through unchanged. display = ", ".join(x for x in (best["name"], best.get("admin1"), best.get("country")) if x) return { "display": display, "latitude": best["latitude"], "longitude": best["longitude"], "timezone": best.get("timezone", "auto"), "upstream_query": raw, "note": note, } # ============================================================================== # TOOL 1 — Weather Data Analyst # ============================================================================== # Space: towardsinnovationlab/Weather_Data_Analyst # Main click handler: analyze_weather → api endpoint: /analyze_weather # # Inputs (positional, in order): # 0 location_input str # 1 variable_input str "Temperature" | "Precipitation" | "Wind_Speed" # 2 decomp_model_display str "additive" | "multiplicative" # 3 decomp_period_input int # 4 days_input int 90|180|365|730|1095|1825 # # Outputs tuple: # 0 status_output str (markdown) # 1 data_output DataFrame # 2 lineplot_output Plot # 3 boxplot_output Plot # 4 autocorr_output Plot # 5 decomposition_output Plot # 6 stats_table_output DataFrame # 7 stats_conclusion_output str # ============================================================================== def weather_data_analyst( location: str, variable: str = "Temperature", decomposition_period: int = 7, days: int = 365, ) -> str: """ Retrieve historical weather data for a location and run statistical diagnostics: descriptive stats, ADF & KPSS stationarity tests, seasonal decomposition, and ACF/PACF autocorrelation analysis. Args: location: City or region name in English, e.g. 'Milan', 'Nairobi', 'London'. Ambiguous names can be qualified with a country or state, e.g. 'Milan, Italy', 'Milan, US', 'Milan, Tennessee'. variable: Weather variable to analyse. One of: 'Temperature' — daily mean temperature (°C) 'Precipitation' — daily precipitation sum (mm) 'Wind_Speed' — daily maximum wind speed (km/h) decomposition_period: Period in days for seasonal decomposition (default 7). days: Historical window length. Allowed: 90, 180, 365, 730, 1095, 1825. Returns: JSON string with keys: status — human-readable summary line stationarity_conclusion — ADF + KPSS combined verdict stats_table — list of {Parameter, Value} dicts data_preview — first 10 rows of the daily time-series """ valid_vars = ["Temperature", "Precipitation", "Wind_Speed"] if variable not in valid_vars: variable = "Temperature" valid_days = [90, 180, 365, 730, 1095, 1825] if int(days) not in valid_days: days = 365 loc = _resolve_location(location) if loc.get("error"): return json.dumps({"error": loc["error"]}) location = loc["upstream_query"] decomp_model = "multiplicative" if variable == "Wind_Speed" else "additive" try: result = _predict_with_retry( URL_DATA_ANALYST, location, # location_input variable, # variable_input decomp_model, # decomp_model_display decomposition_period, # decomp_period_input days, # days_input api_name="/analyze_weather", ) # result is a tuple of 8 values (see layout above) status_md = result[0] if result[0] else "" display_df = result[1] stats_df = result[6] stats_conc = result[7] if result[7] else "" data_preview = _df_to_records(display_df, n=10) stats_rows = _df_to_records(stats_df) return json.dumps({ "status": status_md, "stationarity_conclusion": stats_conc, "stats_table": stats_rows, "data_preview": data_preview, }, ensure_ascii=False) except Exception as exc: return json.dumps({"error": str(exc)}) # ============================================================================== # TOOL 2 — Weather Image Analyst (Sentinel-2) # ============================================================================== # Space: towardsinnovationlab/weather_image_analyst # Main click handler: analyze_and_store → api endpoint: /analyze_and_store # # Inputs (positional, in order): # 0 location_input str # 1 variable_input str "cloud_cover"|"vegetation_pct"|"water_pct"| # "bare_soil_pct"|"snow_ice_pct" # 2 decomp_model_display str # 3 decomp_period_input int # 4 days_input int # 5 max_cloud_input float # # Outputs tuple: # 0 status_output str # 1 data_output DataFrame # 2 gallery_output_tc Plot # 3 page_info_tc str # 4 cloud_output Plot # 5 land_cover_output Plot # 6 lineplot_output Plot # 7 boxplot_output Plot # 8 autocorr_output Plot # 9 decomposition_output Plot # 10 stats_table_output DataFrame # 11 stats_conclusion_output str # 12 scene_store State (raw DataFrame — may be serialised or None) # 13 location_label_state str # 14 current_page_tc int # ============================================================================== def weather_image_analyst( location: str, variable: str = "cloud_cover", days: int = 180, max_cloud: float = 80.0, ) -> str: """ Search and analyse ESA Sentinel-2 satellite scenes for a location. Extracts scene-level land-cover metadata (cloud cover, vegetation, water, bare soil, snow/ice percentages) and runs stationarity tests. Args: location: City or region name in English, e.g. 'Milan', 'Amazon', 'Greenland'. Ambiguous names can be qualified with a country or state, e.g. 'Milan, Italy', 'Milan, US', 'Milan, Tennessee'. variable: Scene metadata variable to analyse. One of: 'cloud_cover' — cloud cover % per scene 'vegetation_pct' — vegetation fraction % 'water_pct' — open-water fraction % 'bare_soil_pct' — bare soil fraction % 'snow_ice_pct' — snow & ice fraction % days: Historical window in days. Allowed: 90, 180, 365, 730, 1095, 1825. max_cloud: Maximum cloud cover % for scene filtering (0–100). Returns: JSON string with keys: status — summary with scene count and date range stationarity_conclusion — ADF + KPSS verdict on the variable stats_table — list of {Parameter, Value} dicts scene_preview — first 10 rows of the scene catalogue """ valid_vars = ["cloud_cover", "vegetation_pct", "water_pct", "bare_soil_pct", "snow_ice_pct"] if variable not in valid_vars: variable = "cloud_cover" valid_days = [90, 180, 365, 730, 1095, 1825] if int(days) not in valid_days: days = 180 loc = _resolve_location(location) if loc.get("error"): return json.dumps({"error": loc["error"]}) location = loc["upstream_query"] decomp_model = ( "multiplicative" if variable in ("water_pct", "snow_ice_pct") else "additive" ) try: result = _predict_with_retry( URL_IMAGE_ANALYST, location, # location_input variable, # variable_input decomp_model, # decomp_model_display 7, # decomp_period_input days, # days_input max_cloud, # max_cloud_input api_name="/analyze_and_store", ) # result is a tuple of 15 values (see layout above) status_md = result[0] if len(result) > 0 else "" display_df = result[1] if len(result) > 1 else None stats_df = result[10] if len(result) > 10 else None stats_conc = result[11] if len(result) > 11 else "" scene_preview = _df_to_records(display_df, n=10) stats_rows = _df_to_records(stats_df) return json.dumps({ "status": status_md, "stationarity_conclusion": stats_conc, "stats_table": stats_rows, "scene_preview": scene_preview, }, ensure_ascii=False) except Exception as exc: return json.dumps({"error": str(exc)}) # ============================================================================== # TOOL 3 — Weather Data Forecast # ============================================================================== # Space: towardsinnovationlab/Weather_Data_Forecast # Main click handler: main → api endpoint: /main # # Inputs (positional, in order): # 0 location_input str # 1 variable_input str "Temperature"|"Precipitation"|"Wind_Speed" # 2 model_input str "AutoARIMA"|"AutoETS"|"AutoLightGBM"|"AutoLSTM"|"Chronos-2" # 3 days_input int 90|180|365|730|1095|1825 # # Outputs tuple: # 0 prediction_info_output str (markdown) # 1 forecast_header_output str # 2 train_metrics_output str # 3 test_metrics_output str # 4 plot_output1 Plot # 5 plot_output2 Plot # 6 backtesting_text_output str # 7 bt_rmse_plot_output Plot # 8 bt_ap_plot_output Plot # 9 bt_res_plot_output Plot # ============================================================================== def weather_data_forecast( location: str, variable: str = "Temperature", model: str = "AutoARIMA", days: int = 365, coverage_level: int = 90, ) -> str: """ Generate a 7-day weather forecast for a location using one of five time-series models with walk-forward backtesting evaluation. Args: location: City or region name in English, e.g. 'Milan', 'Tokyo', 'New York'. Ambiguous names can be qualified with a country or state, e.g. 'Milan, Italy', 'Milan, US', 'Milan, Tennessee'. variable: Weather variable to forecast. One of: 'Temperature' — daily mean temperature (°C) 'Precipitation' — daily precipitation sum (mm) 'Wind_Speed' — daily maximum wind speed (km/h) model: Forecasting model. One of: 'AutoARIMA' — ARIMA with automatic order selection (sp=7) 'AutoETS' — exponential smoothing, auto-selected components 'AutoLightGBM' — gradient-boosted trees with lag features + hyperopt 'AutoLSTM' — LSTM recurrent network on lag windows + hyperopt 'Chronos-2' — zero-shot foundation model (amazon/chronos-2) days: Historical training window in days. Allowed: 90, 180, 365, 730, 1095, 1825. coverage_level: Target coverage (%) for the conformal prediction intervals, e.g. 80, 90 or 95 (default 90). Returns: JSON string with keys: prediction_info — last actual value vs. 7-day-ahead forecast value, including the conformal prediction interval header — model + location + window label train_metrics — training RMSE test_metrics — test-set RMSE (last 30 days) backtesting_text — walk-forward CV summary with average RMSE conformal — stationarity verdict, whether the series was detrended before fitting, and the split-conformal interval's achieved coverage / width daily_forecast — the 7 forecast days as {date, predicted, lower, upper} """ valid_vars = ["Temperature", "Precipitation", "Wind_Speed"] if variable not in valid_vars: variable = "Temperature" valid_models = ["AutoARIMA", "AutoETS", "AutoLightGBM", "AutoLSTM", "Chronos-2"] if model not in valid_models: model = "AutoARIMA" valid_days = [90, 180, 365, 730, 1095, 1825] if int(days) not in valid_days: days = 365 loc = _resolve_location(location) if loc.get("error"): return json.dumps({"error": loc["error"]}) location = loc["upstream_query"] try: result = _predict_with_retry( URL_FORECAST, location, # location_input variable, # variable_input model, # model_input (Radio) days, # days_input "", # stationarity_hint — empty = self-determine (standalone MCP call) coverage_level, # target coverage (%) for conformal intervals api_name="/main", ) # result is a tuple of 12 values (see layout above) prediction_info = result[0] if len(result) > 0 else "" header = result[1] if len(result) > 1 else "" train_metrics = result[2] if len(result) > 2 else "" test_metrics = result[3] if len(result) > 3 else "" backtesting_text = result[6] if len(result) > 6 else "" conformal_text = result[10] if len(result) > 10 else "" daily_forecast_df = result[11] if len(result) > 11 else None return json.dumps({ "prediction_info": prediction_info, "header": header, "train_metrics": train_metrics, "test_metrics": test_metrics, "backtesting_text": backtesting_text, "conformal": _extract_conformal(conformal_text), "daily_forecast": _df_to_records(_to_df(daily_forecast_df)), }, ensure_ascii=False) except Exception as exc: return json.dumps({"error": str(exc)}) # ============================================================================== # TOOL 4 — Climate Risk Report (orchestrator) # ============================================================================== def climate_risk_report( location: str, variable: str = "Temperature", forecast_model: str = "AutoARIMA", days: int = 365, max_cloud: float = 80.0, ) -> str: """ Generate a comprehensive Climate Risk Report by orchestrating all three analytical agents sequentially: - Agent 1: historical weather diagnostics (stationarity, decomposition) - Agent 2: Sentinel-2 satellite land-cover analysis - Agent 3: 7-day weather forecast with backtesting metrics Results are assembled into a single structured risk report. Args: location: City or region name in English, e.g. 'Milan', 'Jakarta'. variable: Primary weather variable for agents 1 and 3. One of: 'Temperature', 'Precipitation', 'Wind_Speed'. forecast_model: Model for agent 3. One of: 'AutoARIMA', 'AutoETS', 'AutoLightGBM', 'AutoLSTM', 'Chronos-2'. days: Historical window in days (90/180/365/730/1095/1825). max_cloud: Max cloud cover % for satellite scene search (0–100). Returns: JSON string with keys: location — resolved location name weather_analysis — full output from agent 1 satellite_analysis— full output from agent 2 forecast — full output from agent 3 risk_summary — synthesised markdown risk narrative """ valid_vars = ["Temperature", "Precipitation", "Wind_Speed"] if variable not in valid_vars: variable = "Temperature" valid_days = [90, 180, 365, 730, 1095, 1825] if int(days) not in valid_days: days = 365 # --- Agent 1 --- weather_data = json.loads( weather_data_analyst(location, variable, 7, days) ) # --- Agent 2 --- satellite_data = json.loads( weather_image_analyst(location, "cloud_cover", min(int(days), 365), max_cloud) ) # --- Agent 3 --- forecast_data = json.loads( weather_data_forecast(location, variable, forecast_model, days) ) # --- Synthesise --- risk_summary = _build_risk_summary( location, days, forecast_model, weather_data, satellite_data, forecast_data, ) return json.dumps({ "location": location, "weather_analysis": weather_data, "satellite_analysis": satellite_data, "forecast": forecast_data, "risk_summary": risk_summary, }, ensure_ascii=False, indent=2) # ============================================================================== # Helpers # ============================================================================== def _df_to_records(df, n: int = None) -> list: """Safely convert a DataFrame (or dict) returned by gradio_client to records.""" if df is None: return [] try: import pandas as pd if isinstance(df, pd.DataFrame): subset = df.head(n) if n else df return subset.to_dict(orient="records") if isinstance(df, dict): subset = pd.DataFrame(df) subset = subset.head(n) if n else subset return subset.to_dict(orient="records") except Exception: pass return [] # --------------------------------------------------------------------------- # UI-output → agent-data converters. # The ReAct agents need both the UI outputs (plots, tables) and the compact # JSON dicts the MCP tools return. Calling the tool fn AND the UI fn would hit # each upstream Space twice with identical params — instead the agents call # the UI fn once and derive the dict from its outputs with these converters. # --------------------------------------------------------------------------- def _is_ui_error(status: str) -> bool: return bool(status) and status.lstrip().startswith(("❌", "⚠️")) def _wx_outputs_to_data(wx) -> dict: """Historical tab outputs (8-tuple) → weather_data dict (see weather_data_analyst).""" status = wx[0] or "" if _is_ui_error(status): return {"error": status} return { "status": status, "stationarity_conclusion": wx[7] or "", "stats_table": _df_to_records(wx[6]), "data_preview": _df_to_records(wx[1], n=10), } def _extract_land_cover_trends(scene_df) -> dict: """ The Sentinel-2 scene table (sat[1]) carries all FIVE land-cover percentages per scene — cloud, vegetation, water, bare soil, snow/ice — regardless of which single variable the ReAct loop stationarity-tests. This extracts mean + start-of-window→end-of-window trend for the four EO risk signals, so the report can ground Drought/Flood/Heat scores in observed land-cover change instead of only the one selected variable. Rows are chronological (scene search sorts by date ascending); the first and last thirds are averaged so a single outlier scene doesn't dominate. """ cols = { "Vegetation (%)": ("vegetation_pct", "Drought (vegetation loss)"), "Bare Soil (%)": ("bare_soil_pct", "Drought (desertification)"), "Water (%)": ("water_pct", "Flood (surface water extent)"), "Snow/Ice (%)": ("snow_ice_pct", "Heat (snowmelt / glacier retreat)"), } if scene_df is None or not hasattr(scene_df, "columns") or len(scene_df) == 0: return {} trends = {} for disp_col, (key, risk_hint) in cols.items(): if disp_col not in scene_df.columns: continue s = scene_df[disp_col].dropna() if len(s) < 2: continue third = max(1, len(s) // 3) start_mean = float(s.iloc[:third].mean()) end_mean = float(s.iloc[-third:].mean()) trends[key] = { "mean": round(float(s.mean()), 1), "start_mean": round(start_mean, 1), "end_mean": round(end_mean, 1), "delta": round(end_mean - start_mean, 1), "n_scenes": int(len(s)), "risk_signal": risk_hint, } return trends def _sat_outputs_to_data(sat) -> dict: """Satellite tab outputs (12-tuple) → satellite_data dict (see weather_image_analyst).""" status = sat[0] or "" if _is_ui_error(status): return {"error": status} return { "status": status, "stationarity_conclusion": sat[11] or "", "stats_table": _df_to_records(sat[10]), "scene_preview": _df_to_records(sat[1], n=10), "land_cover_trends": _extract_land_cover_trends(sat[1]), } def _fc_outputs_to_data(fc) -> dict: """Forecast tab outputs (12-tuple) → forecast_data dict (see weather_data_forecast).""" status = fc[0] or "" if _is_ui_error(status): return {"error": status} return { "prediction_info": status, "header": fc[1] or "", "train_metrics": fc[2] or "", "test_metrics": fc[3] or "", "backtesting_text": fc[6] or "", "conformal": _extract_conformal(fc[10] if len(fc) > 10 else ""), "daily_forecast": _df_to_records(fc[11]) if len(fc) > 11 else [], } def _build_risk_summary(location, days, forecast_model, weather_data, satellite_data, forecast_data) -> str: w_status = weather_data.get("status", "N/A") w_stat = weather_data.get("stationarity_conclusion", "N/A") w_err = weather_data.get("error", "") s_status = satellite_data.get("status", "N/A") s_stat = satellite_data.get("stationarity_conclusion", "N/A") s_err = satellite_data.get("error", "") f_pred = forecast_data.get("prediction_info", "N/A") f_train = forecast_data.get("train_metrics", "N/A") f_test = forecast_data.get("test_metrics", "N/A") f_bt = forecast_data.get("backtesting_text", "N/A") f_err = forecast_data.get("error", "") lines = [ f"## 🌍 Climate Risk Report — {location}", "", f"### 1. Historical Weather Analysis", w_err if w_err else w_status, f"**Stationarity:** {w_stat}", "", f"### 2. Satellite Land-Cover Assessment (Sentinel-2)", s_err if s_err else s_status, f"**Stationarity of cloud cover:** {s_stat}", "", f"### 3. Weather Forecast ({forecast_model}, 7 days ahead)", f_err if f_err else f_pred, f"**Train metrics:** {f_train}", f"**Test metrics:** {f_test}", f"**Backtesting:** {f_bt}", "", f"### 4. Risk Synthesis", ( f"Climate risk assessment for **{location}** completed using " f"{days} days of historical data, Sentinel-2 satellite imagery, " f"and a **{forecast_model}** forecasting model. " f"See individual agent outputs above for full diagnostics." ), ] return "\n".join(lines) # ============================================================================== # Historical tab — full UI (data table + plots + statistical analysis) # # Calls the upstream Weather_Data_Analyst HF Space via gradio_client. # gradio_client serialises matplotlib figures as image file paths (not Figure # objects), so we use gr.Image (not gr.Plot) to display them — gr.Image accepts # file paths directly with no postprocess crash. # DataFrames are returned as dicts and converted with pd.DataFrame before # being passed to gr.Dataframe. # The MCP tool function weather_data_analyst() above is kept intact for agents. # ============================================================================== import pandas as _pd def _to_df(raw) -> "_pd.DataFrame | None": """ Convert whatever gradio_client returns for a DataFrame output to a real DataFrame. Gradio serialises DataFrames as {"headers": [...], "data": [[row], [row], ...]}. Older versions may use {"value": {"headers": ..., "data": ...}}. """ if raw is None: return None if isinstance(raw, _pd.DataFrame): return raw if isinstance(raw, dict): # unwrap nested "value" key if present if "value" in raw and isinstance(raw["value"], dict): raw = raw["value"] headers = raw.get("headers") data = raw.get("data") if headers is not None and data is not None: return _pd.DataFrame(data, columns=headers) # fallback: plain column-oriented dict {"ColA": [...], "ColB": [...]} try: return _pd.DataFrame(raw) except Exception: pass return None def analyze_weather_ui(location, variable, decomp_period, days): """ Calls the Weather_Data_Analyst HF Space via gradio_client and returns all outputs for the Historical tab UI: result[0] status_md → gr.Markdown result[1] data_df → gr.Dataframe (date + variable) result[2] lineplot → gr.Image (file path) result[3] boxplot → gr.Image (file path) result[4] autocorr → gr.Image (file path) result[5] decomposition → gr.Image (file path) result[6] stats_df → gr.Dataframe (ADF & KPSS) result[7] stats_conclusion → gr.Textbox """ empty = ("", None, None, None, None, None, None, "") loc = _resolve_location(location) if loc.get("error"): return (f"⚠️ {loc['error']}",) + empty[1:] location = loc["upstream_query"] loc_line = f"📍 **{loc['display']}** ({loc['latitude']:.2f}°, {loc['longitude']:.2f}°)" if loc.get("note"): loc_line += f" \n_{loc['note']}_" decomp_model = "multiplicative" if variable == "Wind_Speed" else "additive" try: result = _predict_with_retry( URL_DATA_ANALYST, location, variable, decomp_model, int(decomp_period), int(days), api_name="/analyze_weather", ) # result tuple (8 values): # 0 status_md 1 data_df 2 lineplot 3 boxplot # 4 autocorr 5 decomp 6 stats_df 7 stats_conclusion status_md = loc_line + "\n\n" + (result[0] or "") data_df = _to_df(result[1]) lineplot_path = result[2] boxplot_path = result[3] autocorr_path = result[4] decomp_path = result[5] stats_df = _to_df(result[6]) stats_conc = result[7] or "" # Debug: log what gradio_client returned for plot outputs import sys print(f"[DEBUG] plot types: {[type(result[i]).__name__ for i in [2,3,4,5]]}", file=sys.stderr) print(f"[DEBUG] lineplot_path = {repr(lineplot_path)[:200]}", file=sys.stderr) # gradio_client returns plots as: # {'type': 'matplotlib', 'plot': 'data:image/webp;base64,'} # The image is already inline base64 — decode it directly. import base64, io import numpy as _np_img from PIL import Image as _PIL def _to_numpy_img(v): if v is None: return None data_uri = None if isinstance(v, dict): data_uri = v.get("plot") # 'data:image/webp;base64,...' elif isinstance(v, str) and v.startswith("data:"): data_uri = v if data_uri and ";base64," in data_uri: b64 = data_uri.split(";base64,", 1)[1] raw = base64.b64decode(b64) img = _PIL.open(io.BytesIO(raw)).convert("RGB") return _np_img.array(img) return None return ( status_md, data_df, _to_numpy_img(lineplot_path), _to_numpy_img(boxplot_path), _to_numpy_img(autocorr_path), _to_numpy_img(decomp_path), stats_df, stats_conc, ) except Exception as exc: return (f"❌ Error calling Weather_Data_Analyst space: {exc}",) + empty[1:] # --- Historical Blocks UI --- with gr.Blocks() as iface_weather: gr.Markdown("## 🌦️ Historical Weather Analysis") gr.Markdown( "Retrieve historical weather data and run statistical diagnostics: " "descriptive stats, ADF & KPSS stationarity tests, " "seasonal decomposition, and ACF/PACF autocorrelation.\n\n" "Data source: [NASA POWER API](https://power.larc.nasa.gov)" ) # --- Inputs --- with gr.Row(): location_input = gr.Textbox( label="Location", placeholder="e.g. Milan, Italy — country/state optional", scale=2, ) variable_input = gr.Dropdown( choices=["Temperature", "Precipitation", "Wind_Speed"], value="Temperature", label="Weather Variable", scale=1, ) decomp_model_display = gr.Textbox( value="additive", label="Decomposition Model (auto)", interactive=False, scale=1, ) decomp_period_input = gr.Number( value=7, label="Decomposition Period (days)", precision=0, scale=1, ) days_input = gr.Dropdown( choices=[90, 180, 365, 730, 1095, 1825], value=365, label="Historical Window (days)", scale=1, ) analyse_btn = gr.Button("Analyse Weather", variant="primary") status_output = gr.Markdown(label="Status") # --- Output Tabs --- with gr.Tabs(): with gr.Tab("Data Table"): data_output = gr.Dataframe(label="Weather Data") with gr.Tab("Data Visualization"): with gr.Row(): lineplot_output = gr.Image(label="Time Series", type="numpy") boxplot_output = gr.Image(label="Monthly Distribution", type="numpy") autocorr_output = gr.Image(label="ACF / PACF Autocorrelation", type="numpy") decomp_output = gr.Image(label="Seasonal Decomposition", type="numpy") with gr.Tab("Statistical Analysis"): stats_table_output = gr.Dataframe(label="ADF & KPSS Tests") stats_conclusion_output = gr.Textbox( label="Stationarity Conclusion", interactive=False, lines=3 ) # auto-update decomp model label when variable changes def _set_decomp_model(var): return "multiplicative" if var == "Wind_Speed" else "additive" variable_input.change( _set_decomp_model, inputs=[variable_input], outputs=[decomp_model_display], ) analyse_btn.click( analyze_weather_ui, inputs=[location_input, variable_input, decomp_period_input, days_input], outputs=[ status_output, data_output, lineplot_output, boxplot_output, autocorr_output, decomp_output, stats_table_output, stats_conclusion_output, ], ) def analyze_satellite_ui(location, variable, days, max_cloud): """ Calls the weather_image_analyst HF Space via gradio_client and returns all outputs for the Satellite tab UI: result[0] status_md → gr.Markdown result[1] data_df → gr.Dataframe (scene catalogue) result[2] gallery_tc → gr.Image (true-colour gallery — base64) result[3] page_info_tc → gr.Markdown (scene page label) result[4] cloud_plot → gr.Image (cloud cover bar chart) result[5] land_cover_plot → gr.Image (land-cover stacked chart) result[6] lineplot → gr.Image (variable time-series) result[7] boxplot → gr.Image (monthly distribution) result[8] autocorr → gr.Image (ACF / PACF) result[9] decomposition → gr.Image (seasonal decomposition) result[10] stats_df → gr.Dataframe (ADF & KPSS table) result[11] stats_conclusion → gr.Textbox result[12] scene_store → (State — ignored in UI) result[13] location_label_state → (State — ignored in UI) result[14] current_page_tc → (State — ignored in UI) """ empty = ("", None, None, "", None, None, None, None, None, None, None, "") loc = _resolve_location(location) if loc.get("error"): return (f"⚠️ {loc['error']}",) + empty[1:] location = loc["upstream_query"] loc_line = f"📍 **{loc['display']}** ({loc['latitude']:.2f}°, {loc['longitude']:.2f}°)" if loc.get("note"): loc_line += f" \n_{loc['note']}_" decomp_model = ( "multiplicative" if variable in ("water_pct", "snow_ice_pct") else "additive" ) import base64, io, sys import numpy as _np_img from PIL import Image as _PIL def _to_numpy_img(v): if v is None: return None data_uri = None if isinstance(v, dict): data_uri = v.get("plot") # {'type':'matplotlib','plot':'data:image/webp;base64,...'} elif isinstance(v, str) and v.startswith("data:"): data_uri = v if data_uri and ";base64," in data_uri: b64 = data_uri.split(";base64,", 1)[1] raw = base64.b64decode(b64) img = _PIL.open(io.BytesIO(raw)).convert("RGB") return _np_img.array(img) # fallback: file path (older gradio_client versions) if isinstance(v, str) and not v.startswith("data:"): try: return _np_img.array(_PIL.open(v).convert("RGB")) except Exception: pass return None try: result = _predict_with_retry( URL_IMAGE_ANALYST, location, # location_input variable, # variable_input decomp_model, # decomp_model_display 7, # decomp_period_input int(days), # days_input float(max_cloud), # max_cloud_input api_name="/analyze_and_store", ) print(f"[DEBUG satellite] result length: {len(result)}", file=sys.stderr) print(f"[DEBUG satellite] plot types idx 4-9: " f"{[type(result[i]).__name__ for i in range(4, 10) if i < len(result)]}", file=sys.stderr) status_md = loc_line + "\n\n" + (result[0] if len(result) > 0 and result[0] else "") data_df = _to_df(result[1]) if len(result) > 1 else None gallery_tc = _to_numpy_img(result[2]) if len(result) > 2 else None page_info_tc = result[3] if len(result) > 3 else "" cloud_plot = _to_numpy_img(result[4]) if len(result) > 4 else None land_cover_plot = _to_numpy_img(result[5]) if len(result) > 5 else None lineplot = _to_numpy_img(result[6]) if len(result) > 6 else None boxplot = _to_numpy_img(result[7]) if len(result) > 7 else None autocorr = _to_numpy_img(result[8]) if len(result) > 8 else None decomposition = _to_numpy_img(result[9]) if len(result) > 9 else None stats_df = _to_df(result[10]) if len(result) > 10 else None stats_conc = result[11] if len(result) > 11 else "" return ( status_md, data_df, gallery_tc, page_info_tc, cloud_plot, land_cover_plot, lineplot, boxplot, autocorr, decomposition, stats_df, stats_conc, ) except Exception as exc: return (f"❌ Error calling weather_image_analyst space: {exc}",) + empty[1:] # --- Satellite Blocks UI --- with gr.Blocks() as iface_satellite: gr.Markdown("## 🛰️ Satellite Land-Cover Analysis") gr.Markdown( "Search and analyse ESA Sentinel-2 scenes for a location. " "Extracts scene-level land-cover metadata (cloud cover, vegetation, " "water, bare soil, snow/ice) and runs stationarity diagnostics.\n\n" "Data source: [ESA Sentinel-2 via Copernicus](https://scihub.copernicus.eu)" ) # --- Inputs --- with gr.Row(): sat_location_input = gr.Textbox( label="Location", placeholder="e.g. Milan, Italy — country/state optional", scale=2, ) sat_variable_input = gr.Dropdown( choices=["cloud_cover", "vegetation_pct", "water_pct", "bare_soil_pct", "snow_ice_pct"], value="cloud_cover", label="Satellite Variable", scale=1, ) sat_decomp_model_display = gr.Textbox( value="additive", label="Decomposition Model (auto)", interactive=False, scale=1, ) sat_days_input = gr.Dropdown( choices=[90, 180, 365, 730, 1095, 1825], value=180, label="Historical Window (days)", scale=1, ) sat_max_cloud_input = gr.Slider( 0, 100, value=80, step=5, label="Max Cloud Cover (%)", scale=1, ) sat_analyse_btn = gr.Button("Analyse Satellite Data", variant="primary") sat_status_output = gr.Markdown(label="Status") # --- Output Tabs --- with gr.Tabs(): with gr.Tab("Scene Catalogue"): sat_data_output = gr.Dataframe(label="Scene Metadata Table") with gr.Tab("Scene Gallery"): sat_gallery_output = gr.Image(label="True-Colour Preview", type="numpy") sat_page_info_output = gr.Markdown(label="Scene Info") with gr.Tab("Land Cover Overview"): with gr.Row(): sat_cloud_output = gr.Image(label="Cloud Cover Distribution", type="numpy") sat_land_cover_output = gr.Image(label="Land Cover Breakdown", type="numpy") with gr.Tab("Variable Analysis"): with gr.Row(): sat_lineplot_output = gr.Image(label="Time Series", type="numpy") sat_boxplot_output = gr.Image(label="Monthly Distribution", type="numpy") sat_autocorr_output = gr.Image(label="ACF / PACF Autocorrelation", type="numpy") sat_decomp_output = gr.Image(label="Seasonal Decomposition", type="numpy") with gr.Tab("Statistical Analysis"): sat_stats_table_output = gr.Dataframe(label="ADF & KPSS Tests") sat_stats_conclusion_output = gr.Textbox( label="Stationarity Conclusion", interactive=False, lines=3 ) # auto-update decomp model label when variable changes def _set_sat_decomp_model(var): return "multiplicative" if var in ("water_pct", "snow_ice_pct") else "additive" sat_variable_input.change( _set_sat_decomp_model, inputs=[sat_variable_input], outputs=[sat_decomp_model_display], ) sat_analyse_btn.click( analyze_satellite_ui, inputs=[sat_location_input, sat_variable_input, sat_days_input, sat_max_cloud_input], outputs=[ sat_status_output, sat_data_output, sat_gallery_output, sat_page_info_output, sat_cloud_output, sat_land_cover_output, sat_lineplot_output, sat_boxplot_output, sat_autocorr_output, sat_decomp_output, sat_stats_table_output, sat_stats_conclusion_output, ], ) def analyze_forecast_ui(location, variable, model, days, stationarity_hint="", coverage_level=90): """ Calls the Weather_Data_Forecast HF Space via gradio_client and returns all outputs for the Forecast tab UI: result[0] prediction_info → gr.Markdown result[1] header → gr.Markdown result[2] train_metrics → gr.Markdown result[3] test_metrics → gr.Markdown result[4] plot1 → gr.Image (full history + forecast, conformal-shaded) result[5] plot2 → gr.Image (test window + forecast, conformal-shaded) result[6] backtesting_text → gr.Markdown (includes folded-in conformal + stationarity) result[7] bt_rmse_plot → gr.Image (CV RMSE per fold) result[8] bt_ap_plot → gr.Image (actual vs predicted) result[9] bt_res_plot → gr.Image (residuals) result[10] conformal_text → (hidden — parsed by _extract_conformal, not shown standalone) result[11] daily_forecast_df → gr.Dataframe (7-day forecast with 90% conformal interval) stationarity_hint: 'true'/'false'/'' — passed straight through to the Space. '' means the Space determines stationarity for itself; a real value comes from the WeatherAgent's own verdict (single source of truth), see _react_forecast_agent / generate_risk_report_ui. """ empty = ("", "", "", "", None, None, "", None, None, None, "", None) loc = _resolve_location(location) if loc.get("error"): return (f"⚠️ {loc['error']}",) + empty[1:] location = loc["upstream_query"] loc_line = f"📍 **{loc['display']}** ({loc['latitude']:.2f}°, {loc['longitude']:.2f}°)" if loc.get("note"): loc_line += f" \n_{loc['note']}_" import base64, io, sys import numpy as _np_img from PIL import Image as _PIL def _to_numpy_img(v): if v is None: return None data_uri = None if isinstance(v, dict): data_uri = v.get("plot") # 'data:image/webp;base64,...' elif isinstance(v, str) and v.startswith("data:"): data_uri = v if data_uri and ";base64," in data_uri: b64 = data_uri.split(";base64,", 1)[1] raw = base64.b64decode(b64) img = _PIL.open(io.BytesIO(raw)).convert("RGB") return _np_img.array(img) # also handle file paths returned by older gradio versions if isinstance(v, str) and not v.startswith("data:"): try: return _np_img.array(_PIL.open(v).convert("RGB")) except Exception: pass return None try: result = _predict_with_retry( URL_FORECAST, location, variable, model, int(days), stationarity_hint, coverage_level, api_name="/main", ) print(f"[DEBUG forecast] result length: {len(result)}", file=sys.stderr) print(f"[DEBUG forecast] plot4 type: {type(result[4]).__name__ if len(result) > 4 else 'N/A'}", file=sys.stderr) prediction_info = loc_line + "\n\n" + (result[0] if len(result) > 0 and result[0] else "") header = result[1] if len(result) > 1 else "" train_metrics = result[2] if len(result) > 2 else "" test_metrics = result[3] if len(result) > 3 else "" plot1 = _to_numpy_img(result[4]) if len(result) > 4 else None plot2 = _to_numpy_img(result[5]) if len(result) > 5 else None backtesting_text = result[6] if len(result) > 6 else "" bt_rmse_plot = _to_numpy_img(result[7]) if len(result) > 7 else None bt_ap_plot = _to_numpy_img(result[8]) if len(result) > 8 else None bt_res_plot = _to_numpy_img(result[9]) if len(result) > 9 else None conformal_text = result[10] if len(result) > 10 and result[10] else "" daily_forecast_df = _to_df(result[11]) if len(result) > 11 else None return ( prediction_info, header, train_metrics, test_metrics, plot1, plot2, backtesting_text, bt_rmse_plot, bt_ap_plot, bt_res_plot, conformal_text, daily_forecast_df, ) except Exception as exc: return (f"❌ Error calling Weather_Data_Forecast space: {exc}",) + empty[1:] # --- Forecast Blocks UI --- with gr.Blocks() as iface_forecast: gr.Markdown("## 📈 Weather Data Forecast") gr.Markdown( "Generate a 7-day weather forecast using AutoARIMA, AutoETS, AutoLightGBM, " "AutoLSTM or Chronos-2. Includes walk-forward backtesting evaluation.\n\n" "Data source: [NASA POWER API](https://power.larc.nasa.gov)" ) # --- Inputs --- with gr.Row(): fc_location_input = gr.Textbox( label="Location", placeholder="e.g. Milan, Italy — country/state optional", scale=2, ) fc_variable_input = gr.Dropdown( choices=["Temperature", "Precipitation", "Wind_Speed"], value="Temperature", label="Weather Variable", scale=1, ) fc_model_input = gr.Radio( choices=["AutoARIMA", "AutoETS", "AutoLightGBM", "AutoLSTM", "Chronos-2"], value="AutoARIMA", label="Forecast Model", ) fc_days_input = gr.Dropdown( choices=[90, 180, 365, 730, 1095, 1825], value=365, label="Historical Window (days)", scale=1, ) fc_coverage_input = gr.Dropdown( choices=[80, 90, 95], value=90, label="Prediction Interval Coverage (%)", scale=1, ) # Hidden — standalone tab lets the upstream Space self-determine stationarity fc_stationarity_hint = gr.Textbox(value="", visible=False) fc_forecast_btn = gr.Button("Run Forecast", variant="primary") # --- Outputs --- with gr.Tabs(): with gr.Tab("Forecast Summary"): fc_header_output = gr.Markdown(label="Model & Location") fc_prediction_info_output = gr.Markdown(label="Prediction Info") with gr.Row(): fc_train_metrics_output = gr.Markdown(label="Train Metrics") fc_test_metrics_output = gr.Markdown(label="Test Metrics") gr.Markdown("#### 7-Day Forecast (Conformal Prediction Interval at the selected coverage)") fc_daily_forecast_output = gr.Dataframe(label="Daily Predictions") with gr.Tab("Forecast Plots"): fc_plot1_output = gr.Image(label="Full History + 7-day Forecast (shaded = conformal intervals)", type="numpy") fc_plot2_output = gr.Image(label="Test Window + 7-day Forecast (shaded = conformal intervals)", type="numpy") with gr.Tab("Backtesting, Stationarity & Conformal Prediction"): fc_backtesting_text_output = gr.Markdown(label="Backtesting Summary") fc_bt_rmse_output = gr.Image(label="CV RMSE per Fold", type="numpy") fc_bt_ap_output = gr.Image(label="Actual vs Predicted (Out-of-Fold)", type="numpy") fc_bt_res_output = gr.Image(label="Residuals Over Time", type="numpy") # Kept bound (Climate Risk Report parses this text) but not shown its own tab. fc_conformal_output = gr.Markdown(visible=False) fc_forecast_btn.click( analyze_forecast_ui, inputs=[fc_location_input, fc_variable_input, fc_model_input, fc_days_input, fc_stationarity_hint, fc_coverage_input], outputs=[ fc_prediction_info_output, fc_header_output, fc_train_metrics_output, fc_test_metrics_output, fc_plot1_output, fc_plot2_output, fc_backtesting_text_output, fc_bt_rmse_output, fc_bt_ap_output, fc_bt_res_output, fc_conformal_output, fc_daily_forecast_output, ], ) # ============================================================================== # Risk Report UI helpers # ============================================================================== def _call_open_meteo_forecast(location: str, variable: str, lat: float = None, lon: float = None, resolved_name: str = None) -> dict: """ Fetch a free 7-day weather forecast from Open-Meteo API (no key required). Uses pre-resolved coordinates when given (lat/lon/resolved_name from _resolve_location); otherwise geocodes the location string itself. Returns dict with keys: content — human-readable markdown narrative daily — list of {date, min, max, mean} dicts unit — unit string error — error string if failed (else None) """ import requests as _req # Map our variable names to Open-Meteo daily parameters VARIABLE_MAP = { "Temperature": { "params": "temperature_2m_max,temperature_2m_min,temperature_2m_mean", "keys": ("temperature_2m_max", "temperature_2m_min", "temperature_2m_mean"), "unit": "°C", }, "Precipitation": { "params": "precipitation_sum", "keys": (None, None, "precipitation_sum"), "unit": "mm", }, "Wind_Speed": { "params": "wind_speed_10m_max,wind_gusts_10m_max", "keys": (None, "wind_gusts_10m_max", "wind_speed_10m_max"), "unit": "km/h", }, } cfg = VARIABLE_MAP.get(variable, VARIABLE_MAP["Temperature"]) try: # Step 1: Geocode (skipped when the caller passes resolved coordinates) if lat is None or lon is None: geo_resp = _req.get( "https://geocoding-api.open-meteo.com/v1/search", params={"name": location, "count": 1, "language": "en", "format": "json"}, timeout=15, ) geo_resp.raise_for_status() geo_data = geo_resp.json() if not geo_data.get("results"): return {"content": f"Location '{location}' not found.", "daily": [], "unit": cfg["unit"], "error": "geocoding failed"} place = geo_data["results"][0] lat, lon = place["latitude"], place["longitude"] resolved_name = place.get("name", location) resolved_name = resolved_name or location # Step 2: Fetch 7-day forecast fc_resp = _req.get( "https://api.open-meteo.com/v1/forecast", params={ "latitude": lat, "longitude": lon, "daily": cfg["params"], "timezone": "auto", "forecast_days": 7, }, timeout=15, ) fc_resp.raise_for_status() fc_data = fc_resp.json() daily_raw = fc_data.get("daily", {}) dates = daily_raw.get("time", []) key_max, key_min, key_mean = cfg["keys"] daily = [] for i, date in enumerate(dates): mx = daily_raw.get(key_max, [None]*8)[i] if key_max else None mn = daily_raw.get(key_min, [None]*8)[i] if key_min else None mean = daily_raw.get(key_mean, [None]*8)[i] if key_mean else None if mean is None and mx is not None and mn is not None: mean = round((mx + mn) / 2, 2) daily.append({"date": date, "min": mn, "max": mx, "mean": mean}) # Build narrative lines = [f"### 🌤️ Open-Meteo 7-Day Forecast — {variable} for {resolved_name}\n"] for d in daily: parts = [f"**{d['date']}**"] if d["mean"] is not None: parts.append(f"avg {d['mean']} {cfg['unit']}") if d["min"] is not None: parts.append(f"min {d['min']} {cfg['unit']}") if d["max"] is not None: parts.append(f"max {d['max']} {cfg['unit']}") lines.append("- " + " | ".join(parts)) content = "\n".join(lines) content += f"\n\n_Source: [Open-Meteo](https://open-meteo.com/) — free, no API key required._" return {"content": content, "daily": daily, "unit": cfg["unit"], "error": None} except Exception as exc: return {"content": f"Open-Meteo forecast failed: {exc}", "daily": [], "unit": "?", "error": str(exc)} def _call_perplexity(prompt: str, pplx_api_key: str, recency: str = "week") -> dict: """ Call Perplexity sonar-pro with web search grounding. Returns dict with keys: content, citations (list of URLs). """ import requests as _req headers = { "Authorization": f"Bearer {pplx_api_key}", "Content-Type": "application/json", } payload = { "model": "sonar", "messages": [ { "role": "system", "content": ( "You are a meteorological intelligence assistant. " "Be precise, cite sources, and focus on factual current data." ), }, {"role": "user", "content": prompt}, ], "max_tokens": 1024, "temperature": 0.2, "search_recency_filter": recency, # "week" for recent events "return_citations": True, } resp = _req.post( "https://api.perplexity.ai/chat/completions", headers=headers, json=payload, timeout=60, ) resp.raise_for_status() data = resp.json() content = data["choices"][0]["message"]["content"] citations = data.get("citations", []) return {"content": content, "citations": citations} def _call_gpt56(prompt: str, openai_api_key: str, system: str = "") -> str: """ Call GPT-5.6 Sol via OpenAI Responses API to synthesise the risk narrative. """ import requests as _req headers = { "Authorization": f"Bearer {openai_api_key}", "Content-Type": "application/json", } messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) payload = { "model": "gpt-5.6-sol", "input": messages, # Responses API uses "input" not "messages" "max_output_tokens": 2048, } resp = _req.post( "https://api.openai.com/v1/responses", headers=headers, json=payload, timeout=120, ) resp.raise_for_status() data = resp.json() # Responses API: output is a list of message objects for block in data.get("output", []): if block.get("type") == "message": for part in block.get("content", []): if part.get("type") == "output_text": return part.get("text", "") return data.get("output_text", str(data)) def _openmeteo_to_nums(om_result: dict) -> dict: """ Convert Open-Meteo forecast result to the pplx_nums dict format used by _build_benchmark_md and the synthesis prompt. """ daily = om_result.get("daily", []) unit = om_result.get("unit", "?") means = [d["mean"] for d in daily if d.get("mean") is not None] mins = [d["min"] for d in daily if d.get("min") is not None] maxs = [d["max"] for d in daily if d.get("max") is not None] return { "daily": daily, "7day_mean": round(sum(means)/len(means), 2) if means else None, "7day_min": round(min(mins), 2) if mins else None, "7day_max": round(max(maxs), 2) if maxs else None, "unit": unit, "notes": om_result.get("error") or "Open-Meteo structured data — no extraction needed.", } def _build_benchmark_md(forecast_model: str, forecast_data: dict, pplx_nums: dict, variable: str) -> str: """ Build a clean markdown benchmark table comparing the statistical model 7-day-ahead single value against the Open-Meteo live benchmark. """ unit = pplx_nums.get("unit", "") # Extract the statistical model's forecast value AND date from prediction_info # prediction_info looks like: # "At DD-MM-YYYY the average Temperature is X.XX" # "At 24-05-2026 the average forecast is 20.38" import re stat_val = None stat_date = None pi = forecast_data.get("prediction_info", "") # prediction_info has TWO dates: last-actual-value date then forecast-target date. # Always take the LAST match -- that is the 7-day-ahead forecast target. all_dates = re.findall(r"At\s+\**(\d{2}-\d{2}-\d{4}|\d{4}-\d{2}-\d{2})\**", pi) if all_dates: stat_date = all_dates[-1] # Try to grab the value vm = re.search(r"forecast is \**([0-9.]+)", pi) if not vm: vm = re.search(r"is \**([0-9.]+)", pi) if vm: try: stat_val = float(vm.group(1)) except ValueError: pass # Determine the Open-Meteo date range daily = pplx_nums.get("daily", []) om_date_range = "" if daily: first = daily[0].get("date", "") last = daily[-1].get("date", "") om_date_range = f"{first} → {last}" if first and last else "" # Find the Open-Meteo entry closest to stat_date for a point comparison om_match_val = None om_match_date = None if stat_date and daily: for d in daily: # normalise both dates to YYYY-MM-DD for comparison d_date = d.get("date", "") # convert DD-MM-YYYY → YYYY-MM-DD if needed sd_norm = stat_date if re.match(r"\d{2}-\d{2}-\d{4}", stat_date): parts = stat_date.split("-") sd_norm = f"{parts[2]}-{parts[1]}-{parts[0]}" if d_date == sd_norm and d.get("mean") is not None: om_match_val = d["mean"] om_match_date = d_date break lines = [ f"## 📊 Forecast Benchmark — {variable}", "", "> **Benchmark = Open-Meteo 7-day live forecast** (ground truth).", f"> The statistical model ({forecast_model}) is evaluated *against* it.", "", "| | Live Forecast (Benchmark) | Statistical Model |", "|---|---|---|", f"| **Source** | Open-Meteo API (real-time) | {forecast_model} (NASA POWER history) |", f"| **Period / Date** | {om_date_range if om_date_range else 'Next 7 days'} | **{stat_date if stat_date else 'see below'}** (7-day-ahead target) |", ] # Model is a single-point forecast — do NOT compare it to the 7-day avg lines += [ f"| **7-day mean ({unit})** | {pplx_nums.get('7day_mean', 'N/A')} | *(single-point forecast — see same-day comparison below)* |", f"| **7-day min ({unit})** | {pplx_nums.get('7day_min', 'N/A')} | — |", f"| **7-day max ({unit})** | {pplx_nums.get('7day_max', 'N/A')} | — |", "", ] # Point-vs-point delta ONLY (same day, computed by Python — never by LLM) pt_delta = None pt_dir = None if stat_val is not None and om_match_val is not None: pt_delta = round(stat_val - om_match_val, 2) pt_dir = "higher" if pt_delta > 0 else "lower" if pt_delta < 0 else "identical" lines += [ f"### 📍 Same-Day Comparison — {om_match_date}", "", f"| | {forecast_model} | Open-Meteo |", "|---|---|---|", f"| **Date** | {stat_date} | {om_match_date} |", f"| **Value ({unit})** | **{stat_val}** | **{om_match_val}** |", f"| **Δ ({unit})** | **{pt_delta:+.2f}** | *(reference)* |", "", f"→ {forecast_model} is **{pt_dir}** by **{abs(pt_delta):.2f} {unit}** on {om_match_date}.", "_This is a like-for-like point comparison. The model predicts a single day; Open-Meteo provides that same day's forecast._", "", ] elif stat_val is not None: lines += [ f"⚠️ Model target date **{stat_date}** is outside the Open-Meteo 7-day window ({om_date_range}). No same-day comparison possible.", "", ] # Daily table if daily: lines += [ "### Daily Breakdown (Open-Meteo)", "", f"| Date | Min ({unit}) | Max ({unit}) | Mean ({unit}) | Model target? |", "|---|---|---|---|---|", ] for d in daily: d_date = d.get("date", "—") # normalise stat_date for comparison sd_norm = stat_date or "" if re.match(r"\d{2}-\d{2}-\d{4}", sd_norm): parts = sd_norm.split("-") sd_norm = f"{parts[2]}-{parts[1]}-{parts[0]}" is_target = "**← model**" if d_date == sd_norm else "" lines.append( f"| {d_date} " f"| {d.get('min') if d.get('min') is not None else '—'} " f"| {d.get('max') if d.get('max') is not None else '—'} " f"| {d.get('mean') if d.get('mean') is not None else '—'} " f"| {is_target} |" ) lines.append("") # Statistical model details lines += [ "### Statistical Model Details", "", pi if pi else "N/A", "", forecast_data.get("train_metrics", ""), forecast_data.get("test_metrics", ""), forecast_data.get("backtesting_text", ""), "", ] if pplx_nums.get("notes"): lines += [f"_Note: {pplx_nums['notes']}_", ""] return "\n".join(lines), stat_date, stat_val, om_match_val, pt_delta, pt_dir def _build_forecast_comparison_table(pplx_nums: dict, forecast_data: dict, variable: str, model_label: str, coverage_level: float = 90) -> str: """ Merge the free Open-Meteo benchmark with the statistical model's own day-by-day predictions (+ conformal interval at the user's coverage) into ONE table — this replaces the old standalone 'Live Forecast (Open-Meteo)' tab (which only ever showed the benchmark alone) with an actual comparison. """ unit = pplx_nums.get("unit", "") om_by_date = {d["date"]: d for d in pplx_nums.get("daily", [])} model_by_date = {r["Date"]: r for r in forecast_data.get("daily_forecast", [])} all_dates = sorted(set(om_by_date) | set(model_by_date)) if not all_dates: return "_No 7-day forecast data available._" cov = f"{coverage_level:.0f}%" lines = [ f"### 📊 7-Day Forecast — {variable} ({unit})", "", f"Open-Meteo is a free live forecast used as an independent benchmark; " f"**{model_label}** is this app's statistical model with its {cov} conformal interval.", "", f"| Date | Open-Meteo (benchmark) | Model Prediction | {cov} Interval |", "|---|---|---|---|", ] for date in all_dates: om = om_by_date.get(date) om_str = f"{om['mean']:.1f}" if om and om.get("mean") is not None else "—" m = model_by_date.get(date) if m and m.get("Predicted") is not None: pred_str = f"{m['Predicted']:.2f}" lo, hi = m.get("Lower"), m.get("Upper") interval_str = f"[{lo:.2f}, {hi:.2f}]" if lo is not None and hi is not None else "—" else: pred_str, interval_str = "—", "—" lines.append(f"| {date} | {om_str} | {pred_str} | {interval_str} |") lines.append("") lines.append(f"_Source: [Open-Meteo](https://open-meteo.com/) (free, no key) vs. this app's {model_label} forecast._") return "\n".join(lines) # ============================================================================== # ReAct ENGINE (Option B — Python controls the loop, LLM provides judgment) # ============================================================================== # # Architecture: # - Python detects trigger conditions deterministically: cross-source data # agreement (WeatherAgent), scene count (SatelliteAgent), split-conformal # coverage + interval width (ForecastAgent). # - SatelliteAgent still asks a small GPT-5.6 Sol call to decide accept vs. retry # on its deterministic trigger; WeatherAgent and ForecastAgent act on their # triggers directly (no LLM in the retry decision — the retry target is # unambiguous for both: escalate a web search, or switch to Chronos-2). # - Hard cap: MAX_LOOPS_PER_AGENT=2 (3 agents → at most 6 upstream calls). # - Every Thought/Action/Observation is appended to react_log (list of dicts) # which is rendered in the UI after the report. # # Trigger thresholds (deterministic — never changed by LLM): REACT_MIN_SCENES = 5 # satellite scenes < 5 → retry with a looser cloud filter REACT_MAX_CLOUD_STEP = 15 # percentage points added to max_cloud per retry, capped at 100 # NOTE: retrying with a different `variable` was tried here previously, but # search_sentinel2_scenes() does not take `variable` as a parameter — the # scene search (and therefore scene count) is identical no matter which # variable is selected. max_cloud is the only parameter that changes it. REACT_XSOURCE_THRESH = { # variable → (min Pearson r, max |mean bias|), NASA POWER vs ERA5 "Temperature": (0.90, 2.0), # °C "Precipitation": (0.60, 3.0), # mm/day — point-vs-reanalysis precipitation is noisy "Wind_Speed": (0.75, 6.0), # km/h } # Forecast trigger: the user selects the target coverage level for the # per-step conformal prediction intervals (Weather_Data_Forecast calibrates # them Nixtla-style via walk-forward folds; Coverage (%) / Average Width are # then measured on the 30-day test window exactly as in the # stock_market_analysis_and_forecast Space). Trigger fires when EITHER # achieved Coverage (%) < the user's selected coverage level, OR # width ratio (Average Width / 2×train std) > REACT_WIDTH_RATIO_TRIGGER # On trigger, the model switches straight to Chronos-2 — the most robust # fallback of the five — unless Chronos-2 was already the first choice, in # which case there is nowhere further to fall back and the result stands. REACT_WIDTH_RATIO_TRIGGER = 0.80 # "average width > 80%" of the 2×train-std data scale # Primary forecast quality check: interval (Winkler) score of the selected # model vs a seasonal-naive benchmark, both conformally calibrated on the # same test window upstream. ratio = IS_model / IS_naive (lower IS = better): # ratio < REACT_IS_RATIO_WARN → model beats/matches the benchmark → accept # ratio >= REACT_IS_RATIO_WARN → RETAIN the model but warn: "The selected # model does not improve uncertainty quality relative to the # seasonal-naïve benchmark" — a warning, NOT an automatic model switch. # The older coverage/width hard trigger (switch to Chronos-2) only applies # as a fallback when the benchmark ratio is unavailable (e.g. the deployed # Weather_Data_Forecast Space doesn't report interval scores yet). REACT_IS_RATIO_WARN = 1.05 MAX_LOOPS_PER_AGENT = 2 def _react_step(react_log: list, agent: str, thought: str, action: str, observation: str): """Append one Thought → Action → Observation triple to the log.""" react_log.append({ "agent": agent, "thought": thought, "action": action, "observation": observation, }) def _extract_conformal(conformal_text: str) -> dict: """ Parse Weather_Data_Forecast's conformal/stationarity markdown block into a dict the ForecastAgent's trigger and the report synthesis can read. Fields stay None when the block reports calibration was impossible. """ import re out = { "achieved_coverage": None, "target_coverage": None, "width_ratio": None, "average_width": None, "detrended": None, "stationarity": "", "interval_score": None, "naive_interval_score": None, "is_ratio": None, } if not conformal_text: return out m = re.search(r"Interval Score \(model\):\s*([0-9.]+)", conformal_text, re.IGNORECASE) if m: out["interval_score"] = float(m.group(1)) m = re.search(r"Interval Score \(seasonal-naive benchmark\):\s*([0-9.]+)", conformal_text, re.IGNORECASE) if m: out["naive_interval_score"] = float(m.group(1)) m = re.search(r"Interval score ratio \(model / seasonal-naive\):\s*([0-9.]+)", conformal_text, re.IGNORECASE) if m: out["is_ratio"] = float(m.group(1)) elif out["interval_score"] is not None and out["naive_interval_score"]: out["is_ratio"] = out["interval_score"] / out["naive_interval_score"] m = (re.search(r"Coverage \(%\):\s*([0-9.]+)", conformal_text) or re.search(r"Achieved coverage[^:]*:\s*([0-9.]+)%", conformal_text, re.IGNORECASE)) if m: out["achieved_coverage"] = float(m.group(1)) / 100.0 m = re.search(r"\(([0-9.]+)%\s*target coverage", conformal_text, re.IGNORECASE) if m: out["target_coverage"] = float(m.group(1)) / 100.0 m = re.search(r"Width ratio[^:]*:\s*([0-9.]+)", conformal_text, re.IGNORECASE) if m: out["width_ratio"] = float(m.group(1)) m = re.search(r"Average Width:\s*([0-9.]+)", conformal_text, re.IGNORECASE) if m: out["average_width"] = float(m.group(1)) m = re.search(r"Detrended before fitting:\s*(Yes|No)", conformal_text, re.IGNORECASE) if m: out["detrended"] = (m.group(1).lower() == "yes") m = re.search(r"\*\*Stationarity:\*\*\s*(.+)", conformal_text) if m: out["stationarity"] = m.group(1).strip() return out def _extract_scene_count(status_str: str) -> int | None: """ Parse scene count from a satellite status string such as '**144** Sentinel-2 scenes (2025-07-10 -> 2026-07-10)'. A naive digit-before-'scene' pattern matches the '2' in 'Sentinel-2' before ever reaching the real bolded count (no whitespace follows '**144**' directly) — the lookahead below guards against that. """ import re if not status_str: return None for m in re.finditer(r"(\d+)(?!-2\b)\s*\**\s*(?:Sentinel-2\s+)?scenes?", status_str, re.IGNORECASE): try: return int(m.group(1)) except ValueError: continue return None def _is_nonstationary(stationarity_conclusion: str) -> bool: """Return True if the stationarity conclusion indicates non-stationarity.""" if not stationarity_conclusion: return False s = stationarity_conclusion.lower() return ("non-stationary" in s or "nonstationary" in s or "unit root" in s) def _react_reason(situation: str, options: list[str]) -> str: """ Tiny GPT-5.6 Sol call — given a situation and a list of options, return the best option label and a one-sentence justification. Used ONLY for retry-strategy decisions, not for scoring or report text. """ prompt = ( f"You are a climate data pipeline orchestrator.\n" f"Situation: {situation}\n" f"Available options: {options}\n" f"Reply with ONLY: | \n" f"Example: Chronos-2 | Zero-shot model is more robust when training data is non-stationary." ) try: raw = _call_gpt56(prompt, OPENAI_API_KEY, system="You are a terse pipeline decision engine. One line only.") return raw.strip() except Exception as exc: return f"{options[0]} | Fallback — GPT reasoning unavailable: {exc}" def _fetch_openmeteo_archive(lat: float, lon: float, variable: str, days: int): """ Fetch the same daily series the upstream analyst builds from NASA POWER, but from the independent Open-Meteo ERA5 archive (free, no key). Returns DataFrame(ds, y) or None on failure. Wind uses hourly data aggregated to a daily mean so it is comparable with NASA POWER's WS10M daily mean (the archive's daily wind field is a max). """ import requests as _req import pandas as _pd from datetime import datetime, timedelta end = datetime.now() - timedelta(days=1) # same window as upstream start = end - timedelta(days=int(days)) base = { "latitude": lat, "longitude": lon, "start_date": start.strftime("%Y-%m-%d"), "end_date": end.strftime("%Y-%m-%d"), "timezone": "auto", } try: if variable == "Wind_Speed": r = _req.get("https://archive-api.open-meteo.com/v1/archive", params={**base, "hourly": "wind_speed_10m"}, timeout=30) r.raise_for_status() h = r.json().get("hourly", {}) df = _pd.DataFrame({"ds": _pd.to_datetime(h.get("time", [])), "y": h.get("wind_speed_10m", [])}).dropna() df = df.set_index("ds").resample("D")["y"].mean().reset_index() else: key = "temperature_2m_mean" if variable == "Temperature" else "precipitation_sum" r = _req.get("https://archive-api.open-meteo.com/v1/archive", params={**base, "daily": key}, timeout=30) r.raise_for_status() d = r.json().get("daily", {}) df = _pd.DataFrame({"ds": _pd.to_datetime(d.get("time", [])), "y": d.get(key, [])}).dropna() if df.empty: return None df["ds"] = _pd.to_datetime(df["ds"]).dt.normalize() return df except Exception: return None def _cross_source_agreement(nasa_df, om_df, variable): """ Compare the upstream NASA POWER series with the Open-Meteo ERA5 series. Returns (pearson_r, mean_bias nasa−era5, overlap_days); (None, None, n) when comparison is impossible (missing data or < 30 overlapping days). """ import pandas as _pd if nasa_df is None or om_df is None or len(om_df) == 0 or len(nasa_df) == 0: return None, None, 0 try: a = nasa_df.copy() date_col = a.columns[0] var_col = variable if variable in a.columns else a.columns[1] a[date_col] = _pd.to_datetime(a[date_col]).dt.normalize() s_nasa = a.set_index(date_col)[var_col].astype(float) s_era5 = om_df.set_index("ds")["y"].astype(float) j = _pd.concat([s_nasa, s_era5], axis=1, join="inner").dropna() if len(j) < 30: return None, None, len(j) corr = float(j.iloc[:, 0].corr(j.iloc[:, 1])) bias = float((j.iloc[:, 0] - j.iloc[:, 1]).mean()) return corr, bias, len(j) except Exception: return None, None, 0 def _react_weather_agent(location, variable, days, react_log, loc=None): """ ReAct loop for Weather Data Analyst. Step 1: upstream analysis (NASA POWER) + independent verification of the same series against the Open-Meteo ERA5 archive. Trigger: sources diverge (low correlation / high bias) → step 2 retrieves context from the internet (Perplexity: microclimate, terrain, known data issues) and marks data confidence LOW for the report. Returns: (weather_data dict, wx_outputs tuple, verdict label) """ thought = ( f"Running historical weather analysis for {location} / {variable} with " f"{int(days)} days window, then cross-checking NASA POWER against the " f"independent Open-Meteo ERA5 archive." ) action = f"weather_data_analyst(location={location}, variable={variable}, days={int(days)})" # Single upstream call — the agent dict is derived from the UI outputs wx_outputs = analyze_weather_ui(location, variable, 7, int(days)) weather_data = _wx_outputs_to_data(wx_outputs) if weather_data.get("error"): _react_step(react_log, "WeatherAgent", thought, action, f"Error: {weather_data['error']}. Stopping.") return weather_data, wx_outputs, "failed" stat_conc = weather_data.get("stationarity_conclusion", "") stat_note = "" # Deterministic trigger: cross-source agreement corr = bias = None n_overlap = 0 if loc and loc.get("latitude") is not None: era5_df = _fetch_openmeteo_archive(loc["latitude"], loc["longitude"], variable, int(days)) corr, bias, n_overlap = _cross_source_agreement(wx_outputs[1], era5_df, variable) corr_min, bias_max = REACT_XSOURCE_THRESH.get(variable, (0.90, 2.0)) unit = {"Temperature": "°C", "Precipitation": "mm", "Wind_Speed": "km/h"}.get(variable, "") if corr is None: weather_data["cross_source"] = { "reference": "Open-Meteo ERA5 archive", "verdict": "unverified", "detail": f"comparison unavailable ({n_overlap} overlapping days)", } _react_step(react_log, "WeatherAgent", thought, action, f"Analysis OK. Cross-source check unavailable " f"({n_overlap} overlapping days) — proceeding with NASA POWER only." + stat_note) return weather_data, wx_outputs, "cross-check unavailable" agree = (corr >= corr_min) and (abs(bias) <= bias_max) weather_data["cross_source"] = { "reference": "Open-Meteo ERA5 archive", "overlap_days": n_overlap, "correlation": round(corr, 3), "mean_bias": round(bias, 2), "unit": unit, "verdict": "consistent" if agree else "divergent", } if agree: _react_step(react_log, "WeatherAgent", thought, action, f"Cross-source check PASSED: r={corr:.3f} (min {corr_min}), " f"bias={bias:+.2f} {unit} (max {bias_max}) over {n_overlap} days — " f"NASA POWER independently confirmed by ERA5." + stat_note) return weather_data, wx_outputs, f"cross-source verified (r={corr:.2f})" # Divergence → second ReAct step: retrieve explanatory context from the web _react_step(react_log, "WeatherAgent", thought, action, f"Cross-source check FAILED: r={corr:.3f} (min {corr_min}), " f"bias={bias:+.2f} {unit} (max {bias_max}) over {n_overlap} days. " f"Investigating via web search." + stat_note) thought2 = (f"NASA POWER and ERA5 disagree for {location} / {variable}. Possible causes: " f"complex terrain, coastal effects, urban heat island, or data-quality issues.") action2 = f"perplexity_search(microclimate & data-quality context for {location})" if PPLX_API_KEY: try: px = _call_perplexity( f"Are there known weather-data quality issues, microclimates, or geographic " f"factors (mountains, coastline, urban heat island) near {location} that could " f"make reanalysis datasets such as NASA POWER and ERA5 disagree on {variable}? " f"Answer in 2-3 sentences.", PPLX_API_KEY, recency="month", ) context = px.get("content", "").strip()[:400] or "no relevant context found." except Exception as exc: context = f"web lookup failed: {exc}" else: context = "web lookup skipped (no Perplexity key configured)." weather_data["cross_source"]["investigation"] = context weather_data["cross_source"]["confidence"] = "low" _react_step(react_log, "WeatherAgent", thought2, action2, f"Context: {context} → Data confidence marked LOW for the report.") return weather_data, wx_outputs, f"sources diverge (r={corr:.2f}) — confidence LOW" def _react_satellite_agent(location, sat_variable, days, max_cloud, react_log): """ ReAct loop for Weather Image Analyst. Every call already returns ALL FIVE land-cover percentages per scene (cloud, vegetation, water, bare soil, snow/ice), regardless of which single `sat_variable` is chosen for the stationarity test — extracted in `_sat_outputs_to_data` into `land_cover_trends` for report risk-scoring. Trigger: < REACT_MIN_SCENES found → retry with a LOOSER max_cloud filter. (`sat_variable` was tried here previously, but search_sentinel2_scenes() doesn't take a variable argument, so switching it never changed the scene count — max_cloud is the parameter that actually does.) Returns: (satellite_data dict, sat_outputs tuple, final_max_cloud used) """ loop_count = 0 current_cloud = float(max_cloud) sat_days = min(int(days), 365) while loop_count < MAX_LOOPS_PER_AGENT: loop_count += 1 thought = ( f"Running satellite analysis for {location} / {sat_variable} " f"with {sat_days} days, max_cloud={current_cloud:.0f}% (loop {loop_count}/{MAX_LOOPS_PER_AGENT})." ) action = (f"weather_image_analyst(location={location}, variable={sat_variable}, " f"days={sat_days}, max_cloud={current_cloud:.0f})") sat_outputs = analyze_satellite_ui(location, sat_variable, sat_days, current_cloud) satellite_data = _sat_outputs_to_data(sat_outputs) error = satellite_data.get("error", "") status_str = satellite_data.get("status", "") scene_count = _extract_scene_count(status_str) if error: observation = f"Error: {error}. Stopping." _react_step(react_log, "SatelliteAgent", thought, action, observation) break sufficient = (scene_count is None) or (scene_count >= REACT_MIN_SCENES) if sufficient: scene_str = f"{scene_count} scenes" if scene_count is not None else "scenes found" observation = f"{scene_str} at max_cloud={current_cloud:.0f}% — sufficient. Proceeding." _react_step(react_log, "SatelliteAgent", thought, action, observation) break # Too few scenes — max_cloud is the only lever that changes scene count next_cloud = min(100.0, current_cloud + REACT_MAX_CLOUD_STEP) if next_cloud <= current_cloud or loop_count >= MAX_LOOPS_PER_AGENT: observation = ( f"Only {scene_count} scenes at max_cloud={current_cloud:.0f}%. " f"{'Already at 100% cloud tolerance' if next_cloud <= current_cloud else 'Loop cap reached'}. " f"Accepting sparse data — noting limitation in report." ) _react_step(react_log, "SatelliteAgent", thought, action, observation) break reason_raw = _react_reason( f"Only {scene_count} Sentinel-2 scenes found for {location} in {sat_days} days " f"at max_cloud={current_cloud:.0f}%.", [f"retry with max_cloud={next_cloud:.0f}%", f"accept {scene_count} scenes"], ) chosen, justification = (reason_raw.split("|", 1) + [""])[0].strip(), (reason_raw.split("|", 1) + ["", ""])[1].strip() observation = ( f"Only {scene_count} scenes at max_cloud={current_cloud:.0f}%. " f"Decision: {chosen}. Reason: {justification}" ) _react_step(react_log, "SatelliteAgent", thought, action, observation) if "accept" in chosen.lower(): break current_cloud = next_cloud return satellite_data, sat_outputs, current_cloud def _react_forecast_agent(location, variable, forecast_model, days, react_log, stationarity_hint="", coverage_level=90): """ ReAct loop for Weather Data Forecast. Every model (AutoARIMA/AutoETS/AutoLightGBM/AutoLSTM/Chronos-2) detrends by fitting a linear trend before fitting when the series is non-stationary, then calibrates a split-conformal 90%-target prediction interval on its own 30-day test window. That replaces the old RMSE-threshold trigger entirely. `stationarity_hint` ('true'/'false'/'') is the WeatherAgent's own ADF+KPSS verdict on this location/variable, passed through so there is ONE source of truth for stationarity instead of two independent tests that could disagree — Weather_Data_Forecast uses it directly instead of re-testing when it's non-empty (see generate_risk_report_ui, which runs WeatherAgent first specifically so this value is ready in time). `coverage_level` is the user-selected target coverage (%) for the conformal intervals — it is BOTH the calibration target upstream and the acceptance threshold here. Primary check (deterministic): interval score of the selected model vs the seasonal-naive benchmark (both computed upstream on the same test window with the same conformal calibration). ratio = IS_model / IS_naive < REACT_IS_RATIO_WARN → accept ratio ≥ REACT_IS_RATIO_WARN → RETAIN the model with an explicit warning ("does not improve uncertainty quality relative to the seasonal-naïve benchmark") — a caution flag, not an automatic switch. Legacy fallback (only when the benchmark ratio is unavailable, e.g. an older deployed upstream Space): Coverage < coverage_level OR width ratio > REACT_WIDTH_RATIO_TRIGGER → switch straight to Chronos-2 unless it was already the first choice. Returns: (forecast_data dict, fc_outputs tuple, final_model used) """ loop_count = 0 current_model = forecast_model last_data, last_outputs = None, None cov_target = float(coverage_level) / 100.0 switch_history: list = [] # e.g. ["AutoARIMA → Chronos-2 (Coverage 63.0% < 90% target)"] def _record(label, detail): """Stamp the agent's final decision onto forecast_data for the Evidence Quality tab (label: accepted | retained_with_caution | switched_accepted | fallback_exhausted | unverified | failed).""" if isinstance(forecast_data, dict): forecast_data["react_decision"] = { "label": label, "detail": detail, "switch_history": list(switch_history), } while loop_count < MAX_LOOPS_PER_AGENT: loop_count += 1 thought = ( f"Running {current_model} forecast for {location} / {variable} " f"with {days} days history at {coverage_level:.0f}% target coverage " f"(loop {loop_count}/{MAX_LOOPS_PER_AGENT})." ) action = f"weather_data_forecast(location={location}, model={current_model}, days={days})" # Single upstream call — the agent dict is derived from the UI outputs fc_outputs = analyze_forecast_ui(location, variable, current_model, int(days), stationarity_hint, coverage_level) forecast_data = _fc_outputs_to_data(fc_outputs) last_data, last_outputs = forecast_data, fc_outputs error = forecast_data.get("error", "") if error: observation = f"Error: {error}. Stopping." _react_step(react_log, "ForecastAgent", thought, action, observation) _record("failed", error) break conf = forecast_data.get("conformal", {}) coverage = conf.get("achieved_coverage") width = conf.get("width_ratio") is_ratio = conf.get("is_ratio") stat_str = conf.get("stationarity", "") if coverage is None or width is None: observation = ( f"{current_model}: conformal calibration unavailable " f"(too little training data for calibration folds). " f"Accepting result as-is. Stationarity: {stat_str}" ) _react_step(react_log, "ForecastAgent", thought, action, observation) _record("unverified", "conformal calibration unavailable — " "interval reliability could not be checked") break cov_pct = coverage * 100 # ── Primary check: interval score vs the seasonal-naive benchmark ──── # (both conformally calibrated on the same test window upstream). # ratio < REACT_IS_RATIO_WARN → the model's uncertainty estimates beat # or match repeating last week — accept. ratio ≥ threshold → retain # the model but WARN; the benchmark miss is a caveat, not grounds to # discard the user's model choice. if is_ratio is not None: if is_ratio < REACT_IS_RATIO_WARN: observation = ( f"{current_model}: interval score ratio vs seasonal-naive " f"= {is_ratio:.3f} < {REACT_IS_RATIO_WARN:.2f} — the model improves " f"uncertainty quality over the benchmark. Coverage={cov_pct:.1f}% " f"(target {coverage_level:.0f}%), width ratio={width:.2f}. " f"Stationarity: {stat_str}. Proceeding." ) _react_step(react_log, "ForecastAgent", thought, action, observation) _record("switched_accepted" if switch_history else "accepted", f"Interval score ratio {is_ratio:.3f} < {REACT_IS_RATIO_WARN:.2f} " f"vs seasonal-naive benchmark") else: observation = ( f"{current_model}: interval score ratio vs seasonal-naive " f"= {is_ratio:.3f} ≥ {REACT_IS_RATIO_WARN:.2f}. The selected model does " f"not improve uncertainty quality relative to the seasonal-naïve " f"benchmark. Retaining the model — results should be interpreted " f"with caution." ) _react_step(react_log, "ForecastAgent", thought, action, observation) _record("retained_with_caution", f"Interval score ratio {is_ratio:.3f} ≥ {REACT_IS_RATIO_WARN:.2f} " f"vs seasonal-naive benchmark") break # ── Fallback (benchmark unavailable): legacy coverage/width trigger ── cov_fail = coverage < cov_target width_fail = width > REACT_WIDTH_RATIO_TRIGGER triggered = cov_fail or width_fail if not triggered: observation = ( f"{current_model}: benchmark interval score unavailable — legacy check: " f"Coverage={cov_pct:.1f}% (target {coverage_level:.0f}%), " f"width ratio={width:.2f} (max {REACT_WIDTH_RATIO_TRIGGER:.2f}) — both " f"acceptable. Stationarity: {stat_str}. Proceeding." ) _react_step(react_log, "ForecastAgent", thought, action, observation) _record("switched_accepted" if switch_history else "accepted", f"Coverage {cov_pct:.1f}% ≥ {coverage_level:.0f}% target, " f"width ratio {width:.2f} ≤ {REACT_WIDTH_RATIO_TRIGGER:.2f} " f"(benchmark ratio unavailable)") break reason = " and ".join( ([f"Coverage {cov_pct:.1f}% < {coverage_level:.0f}% target"] if cov_fail else []) + ([f"width ratio {width:.2f} > {REACT_WIDTH_RATIO_TRIGGER:.2f}"] if width_fail else []) ) # Hard trigger — switch to Chronos-2 unless it's already the model in use. if current_model == "Chronos-2" or loop_count >= MAX_LOOPS_PER_AGENT: if current_model == "Chronos-2" and forecast_model == "Chronos-2": stop_reason = "Chronos-2 was already the first choice — no further fallback." elif current_model == "Chronos-2": stop_reason = "The Chronos-2 fallback also failed the check — no further fallback." else: stop_reason = "Loop cap reached." observation = ( f"{current_model}: {reason} — interval unreliable. {stop_reason} " f"Using this result — flag reduced forecast confidence in the report." ) _react_step(react_log, "ForecastAgent", thought, action, observation) _record("fallback_exhausted", f"{reason}. {stop_reason}") break observation = ( f"{current_model}: {reason} — interval unreliable. " f"Switching to Chronos-2 (zero-shot, most robust fallback)." ) _react_step(react_log, "ForecastAgent", thought, action, observation) switch_history.append(f"{current_model} → Chronos-2 ({reason})") current_model = "Chronos-2" return last_data, last_outputs, current_model def format_react_log(react_log: list) -> str: """Render the ReAct log as clean markdown for the UI accordion.""" if not react_log: return "_No ReAct steps recorded (all agents converged on first attempt)._" lines = ["## 🔁 ReAct Trace — Agent Reasoning Log", ""] for i, step in enumerate(react_log, 1): agent = step["agent"] lines += [ f"### Step {i} — {agent}", f"**💭 Thought:** {step['thought']}", f"**⚡ Action:** `{step['action']}`", f"**👁 Observation:** {step['observation']}", "", ] lines += [ "---", f"_Total ReAct steps: {len(react_log)} | " f"Agents: {', '.join(sorted(set(s['agent'] for s in react_log)))}_", ] return "\n".join(lines) # ============================================================================== # Evidence Quality & Agent Decisions — four compact cards assembled from the # validation checks the agents already run (cross-source weather verification, # conformal forecast calibration, satellite scene quality), plus an overall # confidence verdict. Built deterministically — no LLM involved. # ============================================================================== _QUALITY_DOT = {"High": "🟢", "Medium": "🟡", "Low": "🔴", "Unverified": "⚪"} def _build_quality_md(weather_data, satellite_data, forecast_data, forecast_model, final_model, coverage_level, req_max_cloud, final_max_cloud, sat_scene_df=None) -> str: """Markdown for the ✅ Evidence Quality & Agent Decisions tab.""" # ── Card 1: historical weather reliability ──────────────────────────────── cross = (weather_data or {}).get("cross_source", {}) or {} verdict = cross.get("verdict", "unverified") if verdict == "consistent": hist_rating, hist_line = "High", "Sources consistent — NASA POWER independently confirmed by ERA5." elif verdict == "divergent": hist_rating, hist_line = "Low", "Sources diverge — treat historical values with caution." else: hist_rating, hist_line = "Unverified", "Cross-source comparison unavailable." unit = cross.get("unit", "") hist_rows = [ ("Reference", cross.get("reference", "Open-Meteo ERA5 archive")), ("Correlation", f"{cross['correlation']:.3f}" if cross.get("correlation") is not None else "n/a"), ("Mean bias", f"{cross['mean_bias']:+.2f} {unit}" if cross.get("mean_bias") is not None else "n/a"), ("Overlap days", cross.get("overlap_days", "n/a")), ("Verdict", hist_line), ] if cross.get("investigation"): hist_rows.append(("Investigation", cross["investigation"][:200])) # ── Card 2: forecast reliability ────────────────────────────────────────── conf = (forecast_data or {}).get("conformal", {}) or {} dec = (forecast_data or {}).get("react_decision", {}) or {} label = dec.get("label", "unverified") cov = conf.get("achieved_coverage") fc_decision_text = { "accepted": "Model retained — its uncertainty estimates beat the seasonal-naïve benchmark.", "switched_accepted": "Switched to a fallback model that passed the quality check.", "retained_with_caution": ("⚠️ **Model retained with caution** — the selected model does " "not improve uncertainty quality relative to the seasonal-naïve " "benchmark. Results should be interpreted with caution."), "fallback_exhausted": "❌ Interval unreliable and no further fallback available — reduced confidence.", "unverified": "Conformal calibration unavailable — interval reliability not checked.", "failed": "❌ Forecast agent failed.", }.get(label, "n/a") fc_rating = { "accepted": "High", "switched_accepted": "Medium", "retained_with_caution": "Medium", "fallback_exhausted": "Low", "unverified": "Unverified", "failed": "Low", }.get(label, "Unverified") model_str = final_model + (f" (switched from {forecast_model})" if final_model != forecast_model else "") is_ratio = conf.get("is_ratio") fc_rows = [ ("Selected model", model_str), ("Target coverage", f"{float(coverage_level):.0f}%"), ("Achieved coverage", f"{cov*100:.1f}%" if cov is not None else "n/a"), ("Interval width ratio", f"{conf['width_ratio']:.2f} (max {REACT_WIDTH_RATIO_TRIGGER:.2f})" if conf.get("width_ratio") is not None else "n/a"), ("Interval score vs seasonal-naïve", f"{(is_ratio - 1) * 100:+.1f}% (ratio {is_ratio:.3f}, warn at ≥ {REACT_IS_RATIO_WARN:.2f})" if is_ratio is not None else "n/a — benchmark not reported by upstream"), ("Detrended before fitting", {True: "Yes", False: "No"}.get(conf.get("detrended"), "n/a")), ("Decision", fc_decision_text), ] for sw in dec.get("switch_history", []): fc_rows.append(("Agent switch", sw)) # ── Card 3: satellite reliability ───────────────────────────────────────── n_scenes = _extract_scene_count((satellite_data or {}).get("status", "")) mean_cloud, n_valid = None, None if sat_scene_df is not None and hasattr(sat_scene_df, "columns") and len(sat_scene_df): cloud_col = next((c for c in sat_scene_df.columns if "cloud" in c.lower()), None) if cloud_col is not None: cl = _pd.to_numeric(sat_scene_df[cloud_col], errors="coerce").dropna() mean_cloud = float(cl.mean()) if len(cl) else None lc_cols = [c for c in sat_scene_df.columns if any(k in c for k in ("Vegetation", "Water", "Bare", "Snow"))] if lc_cols: n_valid = int(sat_scene_df[lc_cols].notna().any(axis=1).sum()) retried = float(final_max_cloud) != float(req_max_cloud) if n_scenes is None: sat_rating = "Unverified" elif n_scenes >= 2 * REACT_MIN_SCENES and (mean_cloud is None or mean_cloud <= 40): sat_rating = "High" elif n_scenes >= REACT_MIN_SCENES: sat_rating = "Medium" else: sat_rating = "Low" sat_rows = [ ("Retrieved scenes", n_scenes if n_scenes is not None else "n/a"), ("Scenes with land-cover data", n_valid if n_valid is not None else "n/a"), ("Average cloud cover", f"{mean_cloud:.0f}%" if mean_cloud is not None else "n/a"), ("Scene-filter retry", f"Yes — max_cloud raised {float(req_max_cloud):.0f}% → " f"{float(final_max_cloud):.0f}%" if retried else "No"), ] # ── Card 4: overall confidence ──────────────────────────────────────────── score_map = {"High": 3.0, "Medium": 2.0, "Low": 1.0, "Unverified": 1.5} avg = (score_map[hist_rating] + score_map[fc_rating] + score_map[sat_rating]) / 3 overall = "High" if avg >= 2.5 else ("Medium" if avg >= 1.75 else "Low") def _card(title, rows): out = [f"### {title}", "", "| | |", "|---|---|"] out += [f"| {k} | {v} |" for k, v in rows] return "\n".join(out) return "\n\n".join([ "## ✅ Evidence Quality & Confidence", "All checks below are deterministic outputs of the agents' own " "validation steps — none of these numbers come from an LLM.", _card(f"{_QUALITY_DOT[hist_rating]} Historical weather reliability — {hist_rating}", hist_rows), _card(f"{_QUALITY_DOT[fc_rating]} Forecast reliability — {fc_rating}", fc_rows), _card(f"{_QUALITY_DOT[sat_rating]} Satellite reliability — {sat_rating}", sat_rows), _card(f"{_QUALITY_DOT[overall]} Overall confidence — {overall}", [ ("Historical evidence", f"{_QUALITY_DOT[hist_rating]} {hist_rating}"), ("Forecast evidence", f"{_QUALITY_DOT[fc_rating]} {fc_rating}"), ("Satellite evidence", f"{_QUALITY_DOT[sat_rating]} {sat_rating}"), ("External alerts", "⚪ Unverified (contextual news, not validated evidence)"), ("**Overall assessment confidence**", f"**{_QUALITY_DOT[overall]} {overall}**"), ]), ]) # ============================================================================== # What-if scenario analysis — deterministic re-interpretation of the report's # hazard scores under a user-chosen asset type / hazard priority / risk # tolerance. Uses Risk_h = w_H·H + w_E·E + w_V·V on a 0–10 scale. The weights # and exposure/vulnerability lookups are ILLUSTRATIVE and configurable, not # scientifically universal — stated in the UI. # ============================================================================== WHATIF_WEIGHTS = (0.5, 0.3, 0.2) # (hazard, exposure, vulnerability) — must sum to 1 # asset type → hazard → (Exposure, Vulnerability) on 0–10 (illustrative defaults) WHATIF_ASSET_EV = { "Agricultural land": {"Drought": (8.0, 6.0), "Flood": (6.0, 5.0), "Heat": (7.0, 5.0), "Storm": (5.0, 4.0)}, "Urban infrastructure": {"Drought": (3.0, 3.0), "Flood": (7.0, 6.0), "Heat": (6.0, 5.0), "Storm": (6.0, 5.0)}, "Coastal residential": {"Drought": (2.0, 3.0), "Flood": (8.0, 7.0), "Heat": (5.0, 5.0), "Storm": (8.0, 6.0)}, "Industrial facility": {"Drought": (4.0, 3.0), "Flood": (6.0, 5.0), "Heat": (5.0, 4.0), "Storm": (6.0, 5.0)}, } WHATIF_TOLERANCE_THRESH = {"Low": 4.0, "Moderate": 5.5, "High": 7.0} def _parse_risk_scores(gpt_report: str) -> dict: """ Pull the 'Score: X/10' values for Heat/Flood/Drought/Storm out of the GPT report's Risk Scores section. The synthesis prompt mandates the 'Score: X/10' format, so this stays robust to prose variations. """ import re scores = {} if not gpt_report: return scores for hazard in ("Heat", "Flood", "Drought", "Storm"): m = re.search( rf"{hazard}[^\n]*?(?:\n[^\n]*?)?Score[:\s*]*([0-9]+(?:\.[0-9]+)?)\s*/\s*10", gpt_report, re.IGNORECASE, ) if m: try: scores[hazard] = min(10.0, max(0.0, float(m.group(1)))) except ValueError: continue return scores def whatif_ui(hazard_scores, asset_type, hazard, tolerance): """Button handler for the What-if Scenario Analysis section.""" if not isinstance(hazard_scores, dict) or not hazard_scores: return ("⚠️ Generate a risk report first — the what-if analysis reuses " "the hazard scores from the report's Risk Scores section.") if hazard not in hazard_scores: avail = ", ".join(sorted(hazard_scores)) or "none" return (f"⚠️ No parsed score for **{hazard}** in the current report " f"(available: {avail}).") wH, wE, wV = WHATIF_WEIGHTS H = float(hazard_scores[hazard]) thresh = WHATIF_TOLERANCE_THRESH.get(tolerance, 5.5) def _risk(asset): E, V = WHATIF_ASSET_EV[asset][hazard] return round(wH * H + wE * E + wV * V, 1), E, V risk_cur, E_cur, V_cur = _risk(asset_type) verdict_cur = ("⚠️ exceeds" if risk_cur >= thresh else "✅ within") + \ f" the {tolerance.lower()}-tolerance threshold ({thresh:.1f}/10)" lines = [ f"### Current scenario — {asset_type}", "", f"| | |", f"|---|---|", f"| {hazard} hazard (from report) | {H:.1f}/10 |", f"| Exposure ({asset_type}) | {E_cur:.1f}/10 |", f"| Vulnerability ({asset_type}) | {V_cur:.1f}/10 |", f"| **Overall {hazard.lower()} risk** | **{risk_cur:.1f}/10** — {verdict_cur} |", "", f"### Alternative scenarios ({hazard} hazard held at {H:.1f}/10)", "", "| Asset type | Exposure | Vulnerability | Overall risk |", "|---|---|---|---|", ] for asset in WHATIF_ASSET_EV: r, E, V = _risk(asset) mark = " ← current" if asset == asset_type else "" flag = "⚠️ " if r >= thresh else "" lines.append(f"| {asset}{mark} | {E:.1f} | {V:.1f} | {flag}{r:.1f}/10 |") lines += [ "", f"Risk = {wH}·Hazard + {wE}·Exposure + {wV}·Vulnerability on a 0–10 " "scale. The hazard score comes from the generated report; exposure and " "vulnerability are illustrative, configurable asset-type defaults — the " "weighting is explicitly a modelling choice, not a scientific universal.", ] return "\n".join(lines) def generate_risk_report_ui( location, variable, forecast_model, days, max_cloud, sat_variable, coverage_level=90, ): """ Full risk report pipeline — also populates Historical, Satellite, Forecast tabs. The three ReAct agents run IN PARALLEL (they hit three independent upstream Spaces); the UI updates live as each agent finishes. API keys are resolved server-side (Space secrets / local .env files). Outputs (43 total): [0] rp_status [1] rp_gpt_report [2] rp_pplx_forecast [3] rp_pplx_news [4] rp_quality_md (✅ Evidence Quality & Agent Decisions tab) [5] rp_forecast_benchmark (State) [6] rp_weather_summary (State) [7] rp_satellite_summary (State) [8] rp_forecast_summary (State) [9] rp_hazard_scores (State — parsed Risk Scores for what-if) [10] rp_react_trace -- Historical tab (8 outputs) -- [8] status_output [9] data_output [10] lineplot_output [11] boxplot_output [12] autocorr_output [13] decomp_output [14] stats_table_output [15] stats_conclusion_output -- Satellite tab (12 outputs) -- [16] sat_status_output [17] sat_data_output [18] sat_gallery_output [19] sat_page_info_output [20] sat_cloud_output [21] sat_land_cover_output [22] sat_lineplot_output [23] sat_boxplot_output [24] sat_autocorr_output [25] sat_decomp_output [26] sat_stats_table_output [27] sat_stats_conclusion_output -- Forecast tab (12 outputs) -- [28] fc_prediction_info_output [29] fc_header_output [30] fc_train_metrics_output [31] fc_test_metrics_output [32] fc_plot1_output [33] fc_plot2_output [34] fc_backtesting_text_output [35] fc_bt_rmse_output [36] fc_bt_ap_output [37] fc_bt_res_output [38] fc_conformal_output [39] fc_daily_forecast_output """ import sys N_REPORT = 11 # 5 visible + 5 hidden gr.State + 1 ReAct trace N_WEATHER = 8 # historical tab outputs N_SAT = 12 # satellite tab outputs N_FC = 12 # forecast tab outputs TOTAL = N_REPORT + N_WEATHER + N_SAT + N_FC # 43 # Per-slot empty values — Dataframe and Image must be None, text can be "" # Weather (8): Markdown, Dataframe, Image, Image, Image, Image, Dataframe, Textbox EMPTY_WX = ("", None, None, None, None, None, None, "") # Satellite (12): Markdown, Dataframe, Image, Markdown, Image, Image, Image, Image, Image, Image, Dataframe, Textbox EMPTY_SAT = ("", None, None, "", None, None, None, None, None, None, None, "") # Forecast (12): Markdown, Markdown, Markdown, Markdown, Image, Image, Markdown, Image, Image, Image, Markdown, Dataframe EMPTY_FC = ("", "", "", "", None, None, "", None, None, None, "", None) empty_all = ("",) * N_REPORT + EMPTY_WX + EMPTY_SAT + EMPTY_FC # 11+8+12+12=43 def _emit(status_log, report="", benchmark="", pplx_fc="", pplx_news="", quality="", hazards=None, w_sum="", s_sum="", f_sum="", react_trace="", wx=None, sat=None, fc=None): """Build the full tuple yield (5 visible + 5 State + 1 ReAct + wx + sat + fc).""" wx = wx if wx is not None else EMPTY_WX sat = sat if sat is not None else EMPTY_SAT fc = fc if fc is not None else EMPTY_FC return ( # 5 visible report slots "\n".join(status_log), report, pplx_fc, pplx_news, quality, # 5 hidden State slots benchmark, w_sum, s_sum, f_sum, (hazards if hazards is not None else {}), # ReAct trace (visible accordion) react_trace, *wx, *sat, *fc, ) if not location or not location.strip(): yield ("⚠️ Please enter a location.",) + empty_all[1:] return if not OPENAI_API_KEY: yield ("⚠️ Server configuration error: OPENAI_API_KEY is not set. " "On HF Spaces add it under Settings → Variables and secrets.",) + empty_all[1:] return loc = _resolve_location(location) if loc.get("error"): yield (f"⚠️ {loc['error']}",) + empty_all[1:] return location = loc["upstream_query"] # what every agent receives loc_line = (f"📍 Location: **{loc['display']}** " f"({loc['latitude']:.2f}°, {loc['longitude']:.2f}°)") if loc.get("note"): loc_line += f" \n_{loc['note']}_" import time as _time from concurrent.futures import ThreadPoolExecutor, as_completed IDX_WX, IDX_SAT, IDX_FC = 1, 2, 3 # status_log slots (0 = location line) status_log = [ loc_line, "🔄 [1/6] Historical weather analysis (ReAct)…", "🔄 [2/6] Satellite land-cover analysis (ReAct)…", "🔄 [3/6] Statistical forecast (ReAct)…", ] yield _emit(status_log) t0 = _time.time() agent_elapsed: dict = {} # slot → seconds from common start to completion # Per-agent ReAct logs (merged in fixed order for display) — each agent # appends only to its own list, so no locking is needed across threads. wx_log, sat_log, fc_log = [], [], [] react_log = [] wx_outputs, sat_outputs, fc_outputs = None, None, None weather_data, satellite_data, forecast_data = {}, {}, {} final_model = forecast_model final_max_cloud = float(max_cloud) # ── Step 1: WeatherAgent runs first, alone ──────────────────────────────── # This ordering guarantees ForecastAgent receives WeatherAgent's own # ADF+KPSS stationarity verdict as the single source of truth (passed # below as `stationarity_hint`) instead of Weather_Data_Forecast # re-testing independently — two tests on closely related series can # disagree, and the report should carry one answer, not two. weather_data, wx_outputs, wx_verdict = _react_weather_agent( location, variable, days, wx_log, loc ) agent_elapsed["wx"] = _time.time() - t0 if weather_data.get("error"): status_log[IDX_WX] = f"❌ [1/6] Historical weather failed: {weather_data['error']}" stationarity_hint = "" # unknown — let Weather_Data_Forecast self-determine else: status_log[IDX_WX] = f"✅ [1/6] Historical weather — {wx_verdict} ({agent_elapsed['wx']:.0f}s)." stationarity_hint = "true" if _is_nonstationary( weather_data.get("stationarity_conclusion", "")) else "false" react_log = wx_log + sat_log + fc_log yield _emit(status_log, react_trace=format_react_log(react_log), wx=wx_outputs) # ── Step 2: Satellite + Forecast in parallel (independent of each other; # Forecast carries Weather's stationarity_hint from step 1) ────── t1 = _time.time() with ThreadPoolExecutor(max_workers=2) as pool: fut_map = { pool.submit(_react_satellite_agent, location, sat_variable, days, max_cloud, sat_log): "sat", pool.submit(_react_forecast_agent, location, variable, forecast_model, days, fc_log, stationarity_hint, coverage_level): "fc", } for fut in as_completed(fut_map): slot = fut_map[fut] agent_elapsed[slot] = _time.time() - t1 secs = f"{agent_elapsed[slot]:.0f}s" try: result, err = fut.result(), "" except Exception as exc: result, err = None, str(exc) if slot == "sat": if result: satellite_data, sat_outputs, final_max_cloud = result sat_label = (f"retried → max_cloud raised to {final_max_cloud:.0f}%" if final_max_cloud != float(max_cloud) else f"no retry needed (max_cloud={final_max_cloud:.0f}%)") status_log[IDX_SAT] = f"✅ [2/6] Satellite — {sat_label} ({secs})." else: satellite_data = {"error": f"SatelliteAgent crashed: {err}"} status_log[IDX_SAT] = f"❌ [2/6] Satellite failed: {err}" else: if result: forecast_data, fc_outputs, final_model = result conf = forecast_data.get("conformal", {}) if forecast_data else {} cov = conf.get("achieved_coverage") cov_str = f", coverage={cov*100:.0f}%" if cov is not None else "" fc_label = (f"retried → {forecast_model}→{final_model}{cov_str}" if final_model != forecast_model else f"no retry needed (model={final_model}{cov_str})") status_log[IDX_FC] = f"✅ [3/6] Forecast — {fc_label} ({secs})." else: forecast_data = {"error": f"ForecastAgent crashed: {err}"} status_log[IDX_FC] = f"❌ [3/6] Forecast failed: {err}" # Live update: refresh status, tabs, and ReAct trace as agents land react_log = wx_log + sat_log + fc_log yield _emit(status_log, react_trace=format_react_log(react_log), wx=wx_outputs, sat=sat_outputs, fc=fc_outputs) # Phase 2 (Satellite + Forecast) ran concurrently; phase 1 (Weather) did not. # Report both: total wall time vs. what fully sequential would have cost. wall_total = _time.time() - t0 work_total = sum(agent_elapsed.values()) phase2_wall = _time.time() - t1 phase2_work = agent_elapsed.get("sat", 0) + agent_elapsed.get("fc", 0) if phase2_work > phase2_wall: status_log.append( f"⏱ Weather ran first ({agent_elapsed.get('wx', 0):.0f}s); Satellite+Forecast then " f"ran in parallel ({phase2_work:.0f}s of work in {phase2_wall:.0f}s) — " f"total {wall_total:.0f}s vs {work_total:.0f}s fully sequential." ) # ── Evidence Quality & Agent Decisions (deterministic, no LLM) ─────────── quality_md = _build_quality_md( weather_data, satellite_data, forecast_data, forecast_model, final_model, coverage_level, max_cloud, final_max_cloud, sat_scene_df=(sat_outputs[1] if sat_outputs and len(sat_outputs) > 1 else None), ) # ── Step 2: Open-Meteo 7-day forecast + Perplexity storm news ──────────── react_trace_md = format_react_log(react_log) status_log.append("🔄 [4/6] Fetching Open-Meteo live forecast & Perplexity storm news…") yield _emit(status_log, quality=quality_md, react_trace=react_trace_md, wx=wx_outputs, sat=sat_outputs, fc=fc_outputs) om_forecast_result = _call_open_meteo_forecast( location, variable, lat=loc["latitude"], lon=loc["longitude"], resolved_name=loc["display"], ) pplx_news_result = {"content": "N/A", "citations": []} _REFUSAL_SIGNALS = [ "i don't have live web access", "i can't reliably answer", "i cannot access", "i don't have access", "no search results", "i'm unable to", "i am unable to", "i'm sorry, but i can't", ] if not PPLX_API_KEY: pplx_news_result = { "content": ( f"ℹ️ **Storm & extreme weather news lookup skipped** — no Perplexity " f"API key configured on the server. The risk report is based on " f"historical data, satellite analysis, and forecasts only." ), "citations": [], } status_log[-1] = "✅ [4/6] Open-Meteo forecast retrieved (storm news skipped — no key)." else: try: pplx_news_result = _call_perplexity( f"Search for any severe weather events, storms, floods, heatwaves, or extreme " f"weather incidents that have occurred or are forecast near {location} in the " f"past 7 days or coming 7 days. Include official warnings or alerts if any. " f"If nothing significant is found, just say: no major weather alerts currently " f"reported for this area.", PPLX_API_KEY, recency="week", ) news_text = pplx_news_result.get("content", "") if any(s in news_text.lower() for s in _REFUSAL_SIGNALS): pplx_news_result = { "content": ( f"✅ **No major weather alerts found for {location}.**\n\n" f"No significant storms, floods, heatwaves, or extreme weather events " f"are currently reported for this area in the past or coming 7 days." ), "citations": [], } status_log[-1] = "✅ [4/6] Open-Meteo forecast & Perplexity storm news retrieved." except Exception as exc: pplx_news_result = { "content": ( f"✅ **No major weather alerts confirmed for {location}.**\n\n" f"Storm news lookup was unavailable. No extreme weather events are " f"currently on record for this area." ), "citations": [], } status_log[-1] = f"⚠️ [4/6] Open-Meteo OK; storm news unavailable: {exc}" # ── Step 3: Build numerics from Open-Meteo (no GPT extraction needed) ──── status_log.append("🔄 [5/6] Building benchmark from Open-Meteo data…") yield _emit(status_log, quality=quality_md, react_trace=react_trace_md, wx=wx_outputs, sat=sat_outputs, fc=fc_outputs) pplx_nums = _openmeteo_to_nums(om_forecast_result) benchmark_md, stat_forecast_date, stat_forecast_val, om_same_day_val, pt_delta, pt_dir = _build_benchmark_md(forecast_model, forecast_data, pplx_nums, variable) # Replaces the old standalone Open-Meteo-only tab: one table with both # the free live benchmark AND this app's own model predictions + interval. pplx_forecast_md = _build_forecast_comparison_table(pplx_nums, forecast_data, variable, final_model, coverage_level) status_log[-1] = "✅ [5/6] Benchmark ready." # Format Perplexity news output with citations pplx_news_md = pplx_news_result["content"] if pplx_news_result["citations"]: pplx_news_md += "\n\n**Sources:** " + " · ".join( f"[{i+1}]({u})" for i, u in enumerate(pplx_news_result["citations"][:8]) ) # ── Step 4: GPT-5.6 Sol synthesis ───────────────────────────────────────────── status_log.append("🔄 [6/6] GPT-5.6 Sol synthesising climate risk report…") yield _emit(status_log, benchmark=benchmark_md, pplx_fc=pplx_forecast_md, pplx_news=pplx_news_md, quality=quality_md, react_trace=react_trace_md, wx=wx_outputs, sat=sat_outputs, fc=fc_outputs) synthesis_prompt = f""" You are a senior climate risk analyst. Below is all available data for **{loc['display']}**. Produce a comprehensive, professional climate risk report structured exactly as described. --- ## DATA INPUTS ### A) Historical Weather Analysis (NASA POWER, last {days} days) Status: {weather_data.get('status', 'N/A')} Stationarity: {weather_data.get('stationarity_conclusion', 'N/A')} Stats: {json.dumps(weather_data.get('stats_table', [])[:5])} Cross-source check (NASA POWER vs Open-Meteo ERA5): {json.dumps(weather_data.get('cross_source', {}))} Error: {weather_data.get('error', 'none')} ### B) Satellite Land-Cover (Sentinel-2, ReAct-tested variable: {sat_variable}) Status: {satellite_data.get('status', 'N/A')} Stationarity ({sat_variable}): {satellite_data.get('stationarity_conclusion', 'N/A')} Stats: {json.dumps(satellite_data.get('stats_table', [])[:5])} Land-cover trends (ALL scenes, ALL variables — start-of-window vs end-of-window means, each tagged with the risk category it signals): {json.dumps(satellite_data.get('land_cover_trends', {}), indent=2)} Error: {satellite_data.get('error', 'none')} ### C) Statistical Forecast ({final_model}{f' — ReAct switched from {forecast_model}' if final_model != forecast_model else ''}) Target date : {stat_forecast_date} Forecast val : {stat_forecast_val} {pplx_nums.get('unit')} Train RMSE : {forecast_data.get('train_metrics', 'N/A')} Test RMSE : {forecast_data.get('test_metrics', 'N/A')} Backtesting : {forecast_data.get('backtesting_text', 'N/A')} Stationarity & detrending: {forecast_data.get('conformal', {}).get('stationarity', 'N/A')} Conformal interval (90% target): achieved coverage {forecast_data.get('conformal', {}).get('achieved_coverage')}, width ratio {forecast_data.get('conformal', {}).get('width_ratio')} Interval score vs seasonal-naive benchmark (ratio, <1.05 = model better): {forecast_data.get('conformal', {}).get('is_ratio', 'N/A')} ### D) Open-Meteo Live Forecast — {variable} (BENCHMARK) Window : {pplx_nums['daily'][0]['date'] if pplx_nums.get('daily') else 'N/A'} → {pplx_nums['daily'][-1]['date'] if pplx_nums.get('daily') else 'N/A'} 7-day mean : {pplx_nums.get('7day_mean')} {pplx_nums.get('unit')} 7-day min : {pplx_nums.get('7day_min')} {pplx_nums.get('unit')} 7-day max : {pplx_nums.get('7day_max')} {pplx_nums.get('unit')} Daily data : {json.dumps(pplx_nums.get('daily', []))} ### D2) PRE-COMPUTED SAME-DAY COMPARISON (use these numbers exactly — do NOT recalculate) Model target date : {stat_forecast_date} Model value : {stat_forecast_val} {pplx_nums.get('unit')} Open-Meteo value (same day) : {om_same_day_val} {pplx_nums.get('unit')} Δ (model − Open-Meteo) : {f"{pt_delta:+.2f} {pplx_nums.get('unit')}" if pt_delta is not None else "N/A — target date outside Open-Meteo window"} Direction : {pt_dir if pt_dir else "N/A — no same-day match"} ### E) Perplexity Sonar Pro — Recent & Upcoming Storm/Extreme Weather News {pplx_news_result['content'][:1200]} --- ## REQUIRED REPORT STRUCTURE 1. **Executive Summary** (3–5 sentences): Integrate all data sources. State: "{forecast_model} forecasts {stat_forecast_val} {pplx_nums.get('unit')} for {stat_forecast_date}. Open-Meteo forecasts {om_same_day_val} {pplx_nums.get('unit')} for the same date. The pre-computed difference is {f"{pt_delta:+.2f}" if pt_delta is not None else "N/A"} {pplx_nums.get('unit')} ({pt_dir if pt_dir else "N/A"})." USE THESE EXACT NUMBERS. Do NOT compare the model point to the 7-day average. Do NOT recalculate. 2. **Risk Scores** — for Heat, Flood, Drought, Storm each provide: - Score: X/10 | Confidence: low/medium/high - Rationale: 2–3 data-grounded sentences Ground each score in section B's land-cover trends where the risk_signal tag matches: Drought ← vegetation_pct / bare_soil_pct delta (declining vegetation or rising bare soil over the window = higher score). Flood ← water_pct delta (rising surface water = higher score) and section E storm/flood news. Heat ← snow_ice_pct delta (declining snow/ice = earlier snowmelt signal) plus the Temperature stationarity/forecast data. Storm ← section E news primarily. If a trend's n_scenes is small (<5), lower that score's confidence and say why. If land-cover trends are empty, say satellite data was unavailable for that score. 3. **Forecast Benchmark Comparison**: Report ONLY the same-day point comparison from section D2. State the pre-computed Δ = {f"{pt_delta:+.2f}" if pt_delta is not None else "N/A"} {pplx_nums.get('unit')}. Explain likely causes (model training window, nowcast vs statistical extrapolation, etc.). Do NOT mention the 7-day average as a comparison point for the model. 4. **7-Day Outlook**: Day-by-day or block summary using Open-Meteo daily data as primary benchmark, {final_model} as context. Flag elevated-risk days. 5. **Recent & Upcoming Extreme Events**: From the news data — severity, relevance to risk scores. 6. **Data Quality & Confidence Notes**: Gaps, uncertainty, confidence drivers. Ground this section in TWO independent checks: - Historical data: the cross-source check from section A — if 'consistent', state that the historical data is independently verified (give r and bias); if 'divergent', lower confidence and cite the investigation context. - Forecast: section C's stationarity/detrending and conformal interval — if the series was non-stationary, say it was detrended before fitting; state the achieved coverage vs the 90% target, and if a model switch happened (see the section C header), say so and explain briefly why (the original model's forecast interval failed to reliably cover the historical test window, so the more robust zero-shot model was used instead). Return clean markdown. Bold section headers. """ gpt_report = "" try: gpt_report = _call_gpt56( synthesis_prompt, OPENAI_API_KEY, system=( "You are a senior climate risk analyst producing structured reports " "for institutional clients. Be precise, evidence-based, and concise. " "Always ground claims in the provided data." ), ) status_log[-1] = "✅ [6/6] GPT-5.6 Sol synthesis complete. Report ready." except Exception as exc: status_log[-1] = f"⚠️ [6/6] GPT-5.6 Sol error: {exc}" gpt_report = f"GPT-5.6 Sol synthesis failed: {exc}" yield _emit( status_log, report=gpt_report, benchmark=benchmark_md, pplx_fc=pplx_forecast_md, pplx_news=pplx_news_md, quality=quality_md, hazards=_parse_risk_scores(gpt_report), w_sum=weather_data.get("status", weather_data.get("error", "")), s_sum=satellite_data.get("status", satellite_data.get("error", "")), f_sum=forecast_data.get("prediction_info", forecast_data.get("error", "")), react_trace=format_react_log(react_log), wx=wx_outputs, sat=sat_outputs, fc=fc_outputs, ) def _today_str() -> str: from datetime import date return date.today().strftime("%d %B %Y") # ============================================================================== # EO Evidence & Change Detection # # Computes the actual satellite evidence behind the risk scores: a baseline # vs recent Sentinel-2 scene pair from the SAME MGRS tile (so pixel grids # align), the selected spectral index (NDVI/NDWI/NDSI/BSI) for both, and a # change map ΔI = I_recent − I_baseline with summary statistics. # # STAC search / COG band reads / index formulas are kept in sync with the # weather_image_analyst Space (same Earth Search v1 endpoint, same 256-px # overview reads, same normalised-difference math) — this tab reads the # rasters directly instead of round-tripping images through gradio_client, # because the upstream Space has no baseline-vs-recent comparison endpoint. # ============================================================================== EO_STAC_SEARCH_URL = "https://earth-search.aws.element84.com/v1/search" EO_BAND_READ_SIZE = 256 EO_CHANGE_PIXEL_THRESH = 0.10 # |Δindex| above this counts as a "changed" pixel EO_INDEX_CFG = { # index → (cmap, (vmin, vmax), label, needed bands) "NDVI": ("RdYlGn", (-0.2, 0.8), "NDVI — Vegetation Health", ("B04", "B08")), "NDWI": ("RdYlBu", (-0.5, 0.5), "NDWI — Water Bodies", ("B03", "B08")), "NDSI": ("Blues", (-0.5, 0.8), "NDSI — Snow & Ice", ("B03", "B11")), "BSI": ("YlOrBr", (-0.5, 0.5), "BSI — Bare Soil", ("B02", "B04", "B08", "B11")), } # index → (direction of concern, interpretation, associated hazard) # ΔI beyond ±EO_DELTA_SIGNIFICANT in the "concern" direction raises the flag. EO_DELTA_SIGNIFICANT = 0.05 EO_CHANGE_RULES = { "NDVI": ("down", "declining vegetation condition", "drought", "improving vegetation condition"), "NDWI": ("up", "expanding surface water", "flood", "shrinking surface water (possible drought signal)"), "NDSI": ("down", "declining snow/ice cover", "heat (earlier snowmelt)", "expanding snow/ice cover"), "BSI": ("up", "expanding bare soil", "drought (desertification)", "declining bare soil"), } def _eo_search_scenes(lat: float, lon: float, days: int, max_cloud: float): """Slim STAC search over the analysis window; returns DataFrame or empty.""" import requests as _req from datetime import datetime, timedelta end = datetime.now() - timedelta(days=1) start = end - timedelta(days=int(days)) delta = 0.15 payload = { "collections": ["sentinel-2-l2a"], "bbox": [round(lon - delta, 6), round(lat - delta, 6), round(lon + delta, 6), round(lat + delta, 6)], "datetime": f"{start:%Y-%m-%d}T00:00:00Z/{end:%Y-%m-%d}T23:59:59Z", "query": {"eo:cloud_cover": {"lte": float(max_cloud)}}, "limit": 100, "sortby": [{"field": "properties.datetime", "direction": "asc"}], } items, page = [], 1 while len(items) < 400: payload["page"] = page try: r = _req.post(EO_STAC_SEARCH_URL, json=payload, timeout=30) if r.status_code != 200: break data = r.json() feats = data.get("features", []) if not feats: break items.extend(feats) if not any(l.get("rel") == "next" for l in data.get("links", [])): break page += 1 except Exception: break records = [] for item in items: props, assets = item.get("properties", {}), item.get("assets", {}) def _href(*keys): for k in keys: href = assets.get(k, {}).get("href") if href: return href return None try: dt = _pd.to_datetime(props.get("datetime", "")).tz_localize(None) except Exception: continue sid = item.get("id", "") records.append({ "ds": dt, "id": sid, # MGRS tile code, e.g. '32TNR' in 'S2B_32TNR_20260705_0_L2A' — # scenes must share it for their pixel grids to align. "tile": sid.split("_")[1] if sid.count("_") >= 2 else "", "cloud_cover": props.get("eo:cloud_cover"), "thumbnail_url": _href("thumbnail"), "B02_url": _href("blue", "B02"), "B03_url": _href("green", "B03"), "B04_url": _href("red", "B04"), "B08_url": _href("nir", "nir08", "B08"), "B11_url": _href("swir16", "B11"), }) df = _pd.DataFrame(records) return df.sort_values("ds").reset_index(drop=True) if not df.empty else df def _eo_read_band(url: str): """Read one band from a remote COG at overview resolution → [0,1] array.""" import numpy as _np try: import rasterio from rasterio.enums import Resampling except ImportError: return None, "rasterio not installed on this Space" if not url: return None, "band URL missing" try: gdal_path = f"/vsicurl/{url}" if url.startswith("http") else url with rasterio.open(gdal_path) as src: data = src.read(1, out_shape=(EO_BAND_READ_SIZE, EO_BAND_READ_SIZE), resampling=Resampling.average).astype(_np.float32) return _np.where(data == 0, _np.nan, data / 10000.0), "" except Exception as e: return None, str(e) def _eo_compute_index(index_name: str, row) -> tuple: """Download the needed bands for one scene row, compute the index array.""" import numpy as _np needed = EO_INDEX_CFG[index_name][3] bands = {} for b in needed: arr, err = _eo_read_band(row.get(f"{b}_url")) if arr is None: return None, f"band {b}: {err}" bands[b] = arr with _np.errstate(divide="ignore", invalid="ignore"): if index_name == "NDVI": idx = (bands["B08"] - bands["B04"]) / (bands["B08"] + bands["B04"]) elif index_name == "NDWI": idx = (bands["B03"] - bands["B08"]) / (bands["B03"] + bands["B08"]) elif index_name == "NDSI": idx = (bands["B03"] - bands["B11"]) / (bands["B03"] + bands["B11"]) else: # BSI num = (bands["B11"] + bands["B04"]) - (bands["B08"] + bands["B02"]) den = (bands["B11"] + bands["B04"]) + (bands["B08"] + bands["B02"]) idx = num / den return _np.where(_np.isfinite(idx), idx, _np.nan), "" def _eo_load_thumbnail(url: str): """True-colour scene preview → numpy RGB array (or None).""" import io as _io import numpy as _np import requests as _req from PIL import Image as _PIL if not url: return None try: r = _req.get(url, timeout=20) if r.status_code == 200: return _np.array(_PIL.open(_io.BytesIO(r.content)).convert("RGB")) except Exception: pass return None def _eo_fig_to_np(fig): """Render a matplotlib figure to a numpy RGB array and close it.""" import numpy as _np fig.canvas.draw() arr = _np.asarray(fig.canvas.buffer_rgba())[..., :3].copy() import matplotlib.pyplot as _plt _plt.close(fig) return arr def _eo_render_index_map(index_arr, index_name: str, date_str: str, cloud_pct) -> "object": import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as _plt cmap, (vmin, vmax), label, _ = EO_INDEX_CFG[index_name] cloud_str = f"{cloud_pct:.0f}%" if cloud_pct is not None else "?" fig, ax = _plt.subplots(figsize=(5, 5)) im = ax.imshow(index_arr, cmap=cmap, vmin=vmin, vmax=vmax, interpolation="bilinear") fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label=index_name) ax.set_title(f"{label}\n{date_str} ☁ {cloud_str}", fontsize=9) ax.axis("off") fig.tight_layout() return _eo_fig_to_np(fig) def _eo_render_change_map(delta_arr, index_name: str, base_date: str, recent_date: str) -> "object": import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as _plt fig, ax = _plt.subplots(figsize=(5.5, 5)) im = ax.imshow(delta_arr, cmap="RdBu_r", vmin=-0.4, vmax=0.4, interpolation="bilinear") fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label=f"Δ{index_name}") ax.set_title(f"{index_name} change map\n{base_date} → {recent_date}", fontsize=9) ax.axis("off") fig.tight_layout() return _eo_fig_to_np(fig) def _eo_pick_scene_pair(df, strategy: str): """ Pick (baseline_row, recent_row) from the SAME MGRS tile so the two 256-px overview grids cover the same footprint and ΔI is meaningful. 'First vs Recent': lowest-cloud scene in the first third of the window vs lowest-cloud scene in the last third (maximises temporal span). 'Clearest pair': the two lowest-cloud scenes overall, in date order. """ tile = df["tile"].value_counts().idxmax() tdf = df[df["tile"] == tile].reset_index(drop=True) if len(tdf) < 2: return None, None, tile if strategy.startswith("Clearest"): best = tdf.nsmallest(2, "cloud_cover").sort_values("ds") return best.iloc[0], best.iloc[1], tile third = max(1, len(tdf) // 3) base = tdf.iloc[:third].nsmallest(1, "cloud_cover").iloc[0] recent = tdf.iloc[-third:].nsmallest(1, "cloud_cover").iloc[0] if base["id"] == recent["id"]: # tiny catalogues can collapse to one scene base, recent = tdf.iloc[0], tdf.iloc[-1] return base, recent, tile def run_eo_evidence_ui(location, days, max_cloud, index_name, strategy): """ Button handler for the EO Evidence & Change Detection tab. Returns (status_md, baseline_tc, recent_tc, baseline_idx, recent_idx, change_map, stats_md). """ import numpy as _np empty = ("", None, None, None, None, None, "") if not location or not location.strip(): return ("⚠️ Enter a location first (same field as the risk report).",) + empty[1:] loc = _resolve_location(location) if loc.get("error"): return (f"⚠️ {loc['error']}",) + empty[1:] index_name = (index_name or "NDVI").upper() if index_name not in EO_INDEX_CFG: index_name = "NDVI" try: df = _eo_search_scenes(loc["latitude"], loc["longitude"], int(days), float(max_cloud)) if df.empty or len(df) < 2: return (f"⚠️ Fewer than 2 Sentinel-2 scenes found for " f"**{loc['display']}** in the last {int(days)} days at " f"max cloud {float(max_cloud):.0f}% — raise the cloud limit " f"or widen the window.",) + empty[1:] base, recent, tile = _eo_pick_scene_pair(df, strategy or "First vs Recent") if base is None: return (f"⚠️ No Sentinel-2 tile has 2+ scenes for this window — " f"cannot build an aligned baseline/recent pair.",) + empty[1:] base_date, recent_date = f"{base['ds']:%Y-%m-%d}", f"{recent['ds']:%Y-%m-%d}" # ── True-colour previews + index rasters for both scenes ───────────── tc_base = _eo_load_thumbnail(base.get("thumbnail_url")) tc_recent = _eo_load_thumbnail(recent.get("thumbnail_url")) idx_base_arr, err_b = _eo_compute_index(index_name, base) if idx_base_arr is None: return (f"❌ Baseline scene band read failed: {err_b}",) + empty[1:] idx_recent_arr, err_r = _eo_compute_index(index_name, recent) if idx_recent_arr is None: return (f"❌ Recent scene band read failed: {err_r}",) + empty[1:] img_idx_base = _eo_render_index_map(idx_base_arr, index_name, base_date, base.get("cloud_cover")) img_idx_recent = _eo_render_index_map(idx_recent_arr, index_name, recent_date, recent.get("cloud_cover")) delta_arr = idx_recent_arr - idx_base_arr img_change = _eo_render_change_map(delta_arr, index_name, base_date, recent_date) # ── ΔI statistics ──────────────────────────────────────────────────── mean_base = float(_np.nanmean(idx_base_arr)) mean_recent = float(_np.nanmean(idx_recent_arr)) delta_mean = mean_recent - mean_base finite = _np.isfinite(delta_arr) valid_frac = float(finite.mean()) changed_pct = (float((_np.abs(delta_arr[finite]) > EO_CHANGE_PIXEL_THRESH).mean()) * 100 if finite.any() else float("nan")) pair_clouds = [c for c in (base.get("cloud_cover"), recent.get("cloud_cover")) if c is not None] pair_cloud = max(pair_clouds) if pair_clouds else None mean_cloud = float(df["cloud_cover"].mean()) if df["cloud_cover"].notna().any() else None # Scene quality / evidence confidence from cloud + valid-pixel rate if pair_cloud is not None and pair_cloud <= 20 and valid_frac >= 0.8: quality = "High" elif pair_cloud is not None and pair_cloud <= 50 and valid_frac >= 0.6: quality = "Medium" else: quality = "Low" concern_dir, concern_txt, hazard, opposite_txt = EO_CHANGE_RULES[index_name] signed_concern = -delta_mean if concern_dir == "down" else delta_mean if signed_concern >= EO_DELTA_SIGNIFICANT: interpretation, assoc_hazard = concern_txt, hazard elif signed_concern <= -EO_DELTA_SIGNIFICANT: interpretation, assoc_hazard = opposite_txt, "none flagged" else: interpretation, assoc_hazard = "no significant change", "none flagged" status_md = ( f"📍 **{loc['display']}** ({loc['latitude']:.2f}°, {loc['longitude']:.2f}°) — " f"tile **{tile}**, {len(df)} scenes in window, comparing " f"**{base_date}** (☁ {base.get('cloud_cover', float('nan')):.0f}%) vs " f"**{recent_date}** (☁ {recent.get('cloud_cover', float('nan')):.0f}%)." ) stats_md = "\n".join([ f"### Satellite Evidence — {EO_INDEX_CFG[index_name][2]}", "", f"| | |", f"|---|---|", f"| Mean baseline ({base_date}) | {mean_base:.3f} |", f"| Mean recent ({recent_date}) | {mean_recent:.3f} |", f"| **Δ{index_name} (recent − baseline)** | **{delta_mean:+.3f}** |", f"| Changed pixels (\\|Δ\\| > {EO_CHANGE_PIXEL_THRESH}) | {changed_pct:.1f}% |", f"| Valid pixels in change map | {valid_frac*100:.0f}% |", f"| Scenes in window / tile {tile} | {len(df)} / {int((df['tile'] == tile).sum())} |", f"| Mean cloud cover (window) | {f'{mean_cloud:.0f}%' if mean_cloud is not None else 'n/a'} |", f"| Scene quality | {quality} |", "", f"**Evidence interpretation:** {interpretation}", f"**Associated hazard:** {assoc_hazard}", f"**Evidence confidence:** {quality.lower()}", "", "ΔI = Ī_recent − Ī_baseline over the full tile footprint at " "256-px overview resolution. Cloud pixels are not masked — check " "the true-colour previews and cloud percentages when reading the " "change map.", ]) return (status_md, tc_base, tc_recent, img_idx_base, img_idx_recent, img_change, stats_md) except Exception as exc: return (f"❌ EO evidence failed: {exc}",) + empty[1:] # --- Risk Report Blocks UI --- with gr.Blocks() as iface_report: gr.Markdown("## 🌍 Climate Risk Report") gr.Markdown( "Runs all three analytical agents with ReAct self-correction, fetches a " "**7-day live forecast from Open-Meteo** as the benchmark, then uses " "**GPT-5.6 Sol** to synthesise a structured climate risk report. The tabs below " "show the **risk assessment**, the **Earth-observation evidence** behind it " "(Sentinel-2 change detection), the **forecast with uncertainty**, and the " "**quality checks & agent decisions** that determine confidence.\n\n" "_No API keys required — just pick a location and press the button._" ) # --- Inputs --- with gr.Row(): rp_location = gr.Textbox( label="Location", placeholder="e.g. Milan, Italy — country/state optional", scale=2, ) rp_variable = gr.Dropdown( choices=["Temperature", "Precipitation", "Wind_Speed"], value="Temperature", label="Primary Variable", scale=1, ) rp_model = gr.Radio( choices=["AutoARIMA", "AutoETS", "AutoLightGBM", "AutoLSTM", "Chronos-2"], value="AutoARIMA", label="Forecast Model", ) with gr.Row(): rp_days = gr.Dropdown( choices=[90, 180, 365, 730, 1095, 1825], value=365, label="Historical Window (days)", scale=1, ) rp_max_cloud = gr.Slider( 0, 100, value=80, step=5, label="Max Cloud Cover (%)", scale=1, ) rp_sat_variable = gr.Dropdown( choices=["cloud_cover", "vegetation_pct", "water_pct", "bare_soil_pct", "snow_ice_pct"], value="cloud_cover", label="Satellite Variable", scale=1, ) rp_coverage = gr.Dropdown( choices=[80, 90, 95], value=90, label="Prediction Interval Coverage (%)", scale=1, ) rp_run_btn = gr.Button("🚀 Generate Risk Report", variant="primary") rp_status = gr.Markdown(label="Progress") # --- Output Tabs --- # Removed: Model vs Live Benchmark (single-point vs daily doesn't compare fairly) # Removed: Agent Data Summary (redundant) rp_forecast_benchmark = gr.State("") # kept as hidden state for pipeline compat rp_weather_summary = gr.State("") rp_satellite_summary = gr.State("") rp_forecast_summary = gr.State("") rp_hazard_scores = gr.State({}) # parsed Risk Scores → what-if analysis with gr.Tabs(): with gr.Tab("📋 Risk Assessment"): rp_gpt_report = gr.Markdown(label="Full Risk Report") # News is contextual enrichment, not primary evidence — kept as a # collapsible section instead of a principal tab. with gr.Accordion("🌐 External Alerts & Context (Perplexity)", open=False): gr.Markdown("Official warnings, recent extreme events, sources " "and publication dates. Unverified contextual news — see " "the Evidence Quality tab for how it is weighted.") rp_pplx_news = gr.Markdown("_Run a report to fetch external alerts._") with gr.Accordion("🧭 What-if Scenario Analysis", open=False): gr.Markdown("Re-interpret the report's hazard scores for a " "different asset type and risk tolerance. Deterministic — " "no LLM call, instant.") with gr.Row(): wf_asset = gr.Dropdown( choices=list(WHATIF_ASSET_EV.keys()), value="Agricultural land", label="Asset type", ) wf_hazard = gr.Dropdown( choices=["Drought", "Flood", "Heat", "Storm"], value="Drought", label="Hazard priority", ) wf_tolerance = gr.Dropdown( choices=["Low", "Moderate", "High"], value="Moderate", label="Risk tolerance", ) wf_btn = gr.Button("Recalculate scenario") wf_out = gr.Markdown("_Generate a risk report first, then recalculate._") with gr.Tab("🛰️ EO Evidence & Change Detection"): gr.Markdown( "**Satellite evidence behind the risk scores** — baseline vs recent " "Sentinel-2 scene (same MGRS tile, aligned pixel grids), the selected " "spectral index for both dates, and the change map " "ΔI = Ī_recent − Ī_baseline. Uses the Location / Historical Window / " "Max Cloud settings above; runs on demand, independent of the report." ) with gr.Row(): eo_index = gr.Dropdown( choices=["NDVI", "NDWI", "NDSI", "BSI"], value="NDVI", label="Index", ) eo_compare = gr.Dropdown( choices=["First vs Recent", "Clearest pair"], value="First vs Recent", label="Comparison", ) eo_btn = gr.Button("🛰️ Analyze change", variant="primary") eo_status = gr.Markdown() with gr.Row(): eo_tc_base = gr.Image(label="Baseline true-colour", interactive=False) eo_tc_recent = gr.Image(label="Recent true-colour", interactive=False) with gr.Row(): eo_idx_base = gr.Image(label="Baseline index", interactive=False) eo_idx_recent = gr.Image(label="Recent index", interactive=False) with gr.Row(): eo_change = gr.Image(label="Change map (recent − baseline)", interactive=False) eo_stats = gr.Markdown("_Pick an index and press Analyze change._") with gr.Tab("📊 Forecast & Uncertainty"): gr.Markdown("Open-Meteo live benchmark vs this app's model " "prediction with its conformal prediction interval. Full " "diagnostics (backtesting, stationarity, interval " "calibration) are in the 📈 Forecast tab.") rp_pplx_forecast = gr.Markdown(label="Open-Meteo Benchmark vs. Model Prediction") with gr.Tab("✅ Evidence Quality & Agent Decisions"): rp_quality_md = gr.Markdown("_Run a report to see the evidence quality checks._") with gr.Accordion("🔁 ReAct Trace — Agent Reasoning Log", open=False): gr.Markdown("Each agent's Thought → Action → Observation loop, " "including retry decisions surfaced in the cards above.") rp_react_trace = gr.Markdown("_Run an analysis to see the ReAct trace._") wf_btn.click( whatif_ui, inputs=[rp_hazard_scores, wf_asset, wf_hazard, wf_tolerance], outputs=[wf_out], ) eo_btn.click( run_eo_evidence_ui, inputs=[rp_location, rp_days, rp_max_cloud, eo_index, eo_compare], outputs=[eo_status, eo_tc_base, eo_tc_recent, eo_idx_base, eo_idx_recent, eo_change, eo_stats], ) with gr.Accordion("🔌 Connect your own agent (MCP)", open=False): gr.Markdown( "This Space is also a **Model Context Protocol (MCP) server** — any MCP " "client (Claude Desktop, Claude Code, custom agents) can call the same " "four tools that power this UI: `weather_data_analyst`, " "`weather_image_analyst`, `weather_data_forecast`, `climate_risk_report`.\n\n" "**Endpoint:**\n" "```\n" "https://towardsinnovationlab-climate-risk-monitoring.hf.space/gradio_api/mcp/sse\n" "```\n" "**Claude Desktop config** (`claude_desktop_config.json`):\n" "```json\n" "{\n" ' "mcpServers": {\n' ' "climate-risk": {\n' ' "command": "npx",\n' ' "args": ["mcp-remote",\n' ' "https://towardsinnovationlab-climate-risk-monitoring.hf.space/gradio_api/mcp/sse",\n' ' "--transport", "sse-only"]\n' " }\n" " }\n" "}\n" "```" ) rp_run_btn.click( generate_risk_report_ui, inputs=[ rp_location, rp_variable, rp_model, rp_days, rp_max_cloud, rp_sat_variable, rp_coverage, ], outputs=[ # ── Risk Report tabs (5 visible + 5 hidden State + ReAct) ── rp_status, rp_gpt_report, rp_pplx_forecast, rp_pplx_news, rp_quality_md, # ✅ Evidence Quality & Agent Decisions tab rp_forecast_benchmark, # gr.State — hidden, kept for pipeline compat rp_weather_summary, # gr.State rp_satellite_summary, # gr.State rp_forecast_summary, # gr.State rp_hazard_scores, # gr.State — parsed Risk Scores for what-if rp_react_trace, # ReAct accordion # ── Historical tab (8) ── status_output, data_output, lineplot_output, boxplot_output, autocorr_output, decomp_output, stats_table_output, stats_conclusion_output, # ── Satellite tab (12) ── sat_status_output, sat_data_output, sat_gallery_output, sat_page_info_output, sat_cloud_output, sat_land_cover_output, sat_lineplot_output, sat_boxplot_output, sat_autocorr_output, sat_decomp_output, sat_stats_table_output, sat_stats_conclusion_output, # ── Forecast tab (11) ── fc_prediction_info_output, fc_header_output, fc_train_metrics_output, fc_test_metrics_output, fc_plot1_output, fc_plot2_output, fc_backtesting_text_output, fc_bt_rmse_output, fc_bt_ap_output, fc_bt_res_output, fc_conformal_output, fc_daily_forecast_output, ], ) demo = gr.TabbedInterface( [iface_weather, iface_satellite, iface_forecast, iface_report], ["🌦️ Historical", "🛰️ Satellite", "📈 Forecast", "🌍 Risk Report"], title="🌍 Climate Risk MCP Server", ) if __name__ == "__main__": demo.launch(mcp_server=True)