towardsinnovationlab's picture
Upload app.py
fa6e701 verified
Raw
History Blame Contribute Delete
94.5 kB
# ==============================================================================
# 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://<your-space>.hf.space/gradio_api/mcp/sse
# ==============================================================================
import json
import gradio as gr
from gradio_client import Client
# ---------------------------------------------------------------------------
# Space URLs (direct .hf.space URLs avoid the HF hub auth check)
# ---------------------------------------------------------------------------
URL_DATA_ANALYST = "https://towardsinnovationlab-weather-data-analyst.hf.space"
URL_IMAGE_ANALYST = "https://towardsinnovationlab-weather-image-analyst.hf.space"
URL_FORECAST = "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]
# ==============================================================================
# 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'.
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
decomp_model = "multiplicative" if variable == "Wind_Speed" else "additive"
try:
client = _get_client(URL_DATA_ANALYST)
result = client.predict(
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'.
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
decomp_model = (
"multiplicative" if variable in ("water_pct", "snow_ice_pct")
else "additive"
)
try:
client = _get_client(URL_IMAGE_ANALYST)
result = client.predict(
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"|"AutoMLP"|"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,
) -> 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'.
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
'AutoMLP' β€” multilayer perceptron with lag features + hyperopt
'Chronos-2' β€” zero-shot foundation model (amazon/chronos-2)
days: Historical training window in days. Allowed: 90, 180, 365, 730, 1095, 1825.
Returns:
JSON string with keys:
prediction_info β€” last actual value vs. 7-day-ahead forecast value
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
"""
valid_vars = ["Temperature", "Precipitation", "Wind_Speed"]
if variable not in valid_vars:
variable = "Temperature"
valid_models = ["AutoARIMA", "AutoETS", "AutoLightGBM", "AutoMLP", "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
try:
client = _get_client(URL_FORECAST)
result = client.predict(
location, # location_input
variable, # variable_input
model, # model_input (Radio)
days, # days_input
api_name="/main",
)
# result is a tuple of 10 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 ""
return json.dumps({
"prediction_info": prediction_info,
"header": header,
"train_metrics": train_metrics,
"test_metrics": test_metrics,
"backtesting_text": backtesting_text,
}, 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', 'AutoMLP', '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 []
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, "")
if not location or not location.strip():
return ("⚠️ Please enter a location.",) + empty[1:]
decomp_model = "multiplicative" if variable == "Wind_Speed" else "additive"
try:
client = _get_client(URL_DATA_ANALYST)
result = client.predict(
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 = 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,<b64>'}
# 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, Tokyo, Nairobi",
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, "")
if not location or not location.strip():
return ("⚠️ Please enter a location.",) + empty[1:]
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:
client = _get_client(URL_IMAGE_ANALYST)
result = client.predict(
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 = result[0] if len(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, Amazon, Greenland",
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):
"""
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)
result[5] plot2 β†’ gr.Image (test window + forecast)
result[6] backtesting_text β†’ gr.Markdown
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)
"""
empty = ("", "", "", "", None, None, "", None, None, None)
if not location or not location.strip():
return ("⚠️ Please enter a location.",) + empty[1:]
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:
client = _get_client(URL_FORECAST)
result = client.predict(
location,
variable,
model,
int(days),
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 = 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 ""
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
return (
prediction_info,
header,
train_metrics,
test_metrics,
plot1,
plot2,
backtesting_text,
bt_rmse_plot,
bt_ap_plot,
bt_res_plot,
)
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, "
"AutoMLP 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, New York, Sydney",
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", "AutoMLP", "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_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")
with gr.Tab("Forecast Plots"):
fc_plot1_output = gr.Image(label="Full History + 7-day Forecast", type="numpy")
fc_plot2_output = gr.Image(label="Test Window + 7-day Forecast", type="numpy")
with gr.Tab("Backtesting"):
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")
fc_forecast_btn.click(
analyze_forecast_ui,
inputs=[fc_location_input, fc_variable_input, fc_model_input, fc_days_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,
],
)
# ==============================================================================
# Risk Report UI helpers
# ==============================================================================
def _call_open_meteo_forecast(location: str, variable: str) -> dict:
"""
Fetch a free 7-day weather forecast from Open-Meteo API (no key required).
Geocodes the location via Open-Meteo's geocoding API, then fetches daily
forecast for the requested variable.
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
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)
# 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_gpt55(prompt: str, openai_api_key: str, system: str = "") -> str:
"""
Call GPT-5.5 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.5-2026-04-23",
"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.",
}
"""
Use GPT-5.5 as a structured extractor: parse Perplexity's 7-day forecast narrative
and return daily numeric values for the requested variable.
Output: {
"daily": [{"date": str, "min": float|None, "max": float|None, "mean": float|None}, ...],
"7day_mean": float|None,
"7day_min": float|None,
"7day_max": float|None,
"unit": str,
"notes": str
}
If a day only has min+max (no explicit mean), compute mean = (min+max)/2.
If a day has only one value, treat it as mean.
"""
extraction_prompt = f"""
Extract daily numeric values for **{variable}** from this 7-day weather forecast text.
FORECAST TEXT:
{pplx_content}
Return ONLY valid JSON (no markdown fences, no explanation) in this exact schema:
{{
"unit": "<unit string, e.g. Β°C or mm or km/h>",
"daily": [
{{"date": "<date label e.g. Mon 23 May>", "min": <number or null>, "max": <number or null>, "mean": <number or null>}},
...
],
"notes": "<brief note on extraction quality or missing data>"
}}
Rules:
- Extract values ONLY for {variable}. For Temperature use Β°C, Precipitation use mm, Wind_Speed use km/h.
- If a day shows a range like "16–22Β°C", set min=16, max=22, mean=null (it will be computed).
- If a day shows a single value like "19Β°C", set min=null, max=null, mean=19.
- If no data exists for a day, set all three to null.
- Include all 7 days even if some have nulls.
- Do NOT include any text outside the JSON object.
"""
try:
raw = _call_gpt55(extraction_prompt, openai_api_key, system="You are a precise data extraction tool. Output only valid JSON.")
# strip any accidental markdown fences
raw = raw.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
parsed = json.loads(raw)
except Exception as exc:
return {"daily": [], "7day_mean": None, "7day_min": None, "7day_max": None,
"unit": "?", "notes": f"Extraction failed: {exc}"}
daily = parsed.get("daily", [])
# Fill computed means where only min/max exist
for d in daily:
if d.get("mean") is None and d.get("min") is not None and d.get("max") is not None:
d["mean"] = round((d["min"] + d["max"]) / 2, 2)
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": parsed.get("unit", "?"),
"notes": parsed.get("notes", ""),
}
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
# ==============================================================================
# ReAct ENGINE (Option B β€” Python controls the loop, LLM provides judgment)
# ==============================================================================
#
# Architecture:
# - Python detects trigger conditions deterministically (RMSE ratio, scene
# count, stationarity verdict).
# - A small GPT-5.5 call decides the retry strategy (which model/days/variable
# to try next) and writes a one-line reasoning note.
# - Hard caps: MAX_LOOPS_PER_AGENT=2, GLOBAL_LOOP_CAP=8.
# - 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):
# Trigger: test_RMSE > cv_mean_RMSE + 1 Γ— train_RMSE
# Rationale: cv_mean is the expected out-of-sample error; 1Γ—train_RMSE is one noise-floor
# of tolerance. Exceeding this means the model degrades beyond what data variance explains.
REACT_MIN_SCENES = 5 # satellite scenes < 5 β†’ retry variable
REACT_DAYS_ESCALATION = {365: 1825, 180: 730, 90: 365} # days β†’ next window
REACT_MODEL_FALLBACK = { # model β†’ fallback model for retry
"AutoARIMA": "Chronos-2",
"AutoETS": "Chronos-2",
"AutoLightGBM": "AutoARIMA",
"AutoMLP": "AutoARIMA",
"Chronos-2": "AutoARIMA", # Chronos-2 fails β†’ fall back to AutoARIMA
}
REACT_SAT_VAR_FALLBACK = {
"cloud_cover": "vegetation_pct",
"vegetation_pct": "water_pct",
"water_pct": "bare_soil_pct",
"bare_soil_pct": "snow_ice_pct",
"snow_ice_pct": "cloud_cover",
}
MAX_LOOPS_PER_AGENT = 2
GLOBAL_LOOP_CAP = 8
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_rmse(metrics_str: str) -> float | None:
"""Parse 'RMSE: X.XXXX' from a metrics markdown string. Returns float or None."""
import re
if not metrics_str:
return None
m = re.search(r"RMSE[:\s*]+([0-9]+\.?[0-9]*)", metrics_str, re.IGNORECASE)
if m:
try:
return float(m.group(1))
except ValueError:
pass
return None
def _extract_cv_rmse(backtesting_text: str) -> float | None:
"""Parse 'Average CV RMSE across N fold(s): X.XXXX' from backtesting_text."""
import re
if not backtesting_text:
return None
m = re.search(r"Average CV RMSE[^:]*:\s*([0-9]+\.?[0-9]*)", backtesting_text, re.IGNORECASE)
if m:
try:
return float(m.group(1))
except ValueError:
pass
return None
def _extract_scene_count(status_str: str) -> int | None:
"""Parse scene count from satellite status string like 'Found 3 scenes'."""
import re
if not status_str:
return None
m = re.search(r"(\d+)\s+scene", status_str, re.IGNORECASE)
if m:
try:
return int(m.group(1))
except ValueError:
pass
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(openai_api_key: str, situation: str, options: list[str]) -> str:
"""
Tiny GPT-5.5 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: <chosen_option> | <one sentence justification>\n"
f"Example: Chronos-2 | Zero-shot model is more robust when training data is non-stationary."
)
try:
raw = _call_gpt55(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 _react_weather_agent(location, variable, days, openai_api_key, react_log):
"""
ReAct loop for Weather Data Analyst.
Trigger: non-stationarity β†’ retry with larger window.
Returns: (weather_data dict, wx_outputs tuple, final_days used)
"""
loop_count = 0
current_days = int(days)
while loop_count < MAX_LOOPS_PER_AGENT:
loop_count += 1
thought = (
f"Running historical weather analysis for {location} / {variable} "
f"with {current_days} days window (loop {loop_count}/{MAX_LOOPS_PER_AGENT})."
)
action = f"weather_data_analyst(location={location}, variable={variable}, days={current_days})"
weather_data = json.loads(weather_data_analyst(location, variable, 7, current_days))
wx_outputs = analyze_weather_ui(location, variable, 7, current_days)
stat_conc = weather_data.get("stationarity_conclusion", "")
error = weather_data.get("error", "")
if error:
observation = f"Error: {error}. Stopping."
_react_step(react_log, "WeatherAgent", thought, action, observation)
break
if not _is_nonstationary(stat_conc):
observation = (
f"Stationary signal confirmed at {current_days} days. "
f"Stationarity: {stat_conc[:120]}. Proceeding."
)
_react_step(react_log, "WeatherAgent", thought, action, observation)
break
# Non-stationary β€” decide whether to retry
next_days = REACT_DAYS_ESCALATION.get(current_days)
if not next_days or loop_count >= MAX_LOOPS_PER_AGENT:
observation = (
f"Non-stationary at {current_days} days. "
f"{'No larger window available' if not next_days else 'Loop cap reached'}. "
f"Accepting result β€” flagging genuine non-stationarity in report."
)
_react_step(react_log, "WeatherAgent", thought, action, observation)
break
reason_raw = _react_reason(
openai_api_key,
f"Historical data for {location}/{variable} is non-stationary at {current_days} days.",
[f"retry with {next_days} days", "accept non-stationary result"],
)
chosen, justification = (reason_raw.split("|", 1) + [""])[0].strip(), (reason_raw.split("|", 1) + ["", ""])[1].strip()
observation = (
f"Non-stationary at {current_days} days. "
f"Decision: {chosen}. Reason: {justification}"
)
_react_step(react_log, "WeatherAgent", thought, action, observation)
if "accept" in chosen.lower():
break
current_days = next_days
return weather_data, wx_outputs, current_days
def _react_satellite_agent(location, sat_variable, days, max_cloud, openai_api_key, react_log):
"""
ReAct loop for Weather Image Analyst.
Trigger: < REACT_MIN_SCENES found β†’ retry with fallback variable.
Returns: (satellite_data dict, sat_outputs tuple, final_variable used)
"""
loop_count = 0
current_var = sat_variable
sat_days = min(int(days), 365)
while loop_count < MAX_LOOPS_PER_AGENT:
loop_count += 1
thought = (
f"Running satellite analysis for {location} / {current_var} "
f"with {sat_days} days, max_cloud={max_cloud}% (loop {loop_count}/{MAX_LOOPS_PER_AGENT})."
)
action = f"weather_image_analyst(location={location}, variable={current_var}, days={sat_days})"
satellite_data = json.loads(
weather_image_analyst(location, current_var, sat_days, float(max_cloud))
)
sat_outputs = analyze_satellite_ui(location, current_var, sat_days, float(max_cloud))
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} for {current_var} β€” sufficient. Proceeding."
_react_step(react_log, "SatelliteAgent", thought, action, observation)
break
# Too few scenes
next_var = REACT_SAT_VAR_FALLBACK.get(current_var)
if not next_var or loop_count >= MAX_LOOPS_PER_AGENT:
observation = (
f"Only {scene_count} scenes for {current_var}. "
f"{'No fallback variable available' if not next_var 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(
openai_api_key,
f"Only {scene_count} Sentinel-2 scenes found for {location}/{current_var} in {sat_days} days.",
[f"retry with {next_var}", f"accept {scene_count} scenes for {current_var}"],
)
chosen, justification = (reason_raw.split("|", 1) + [""])[0].strip(), (reason_raw.split("|", 1) + ["", ""])[1].strip()
observation = (
f"Only {scene_count} scenes for {current_var}. "
f"Decision: {chosen}. Reason: {justification}"
)
_react_step(react_log, "SatelliteAgent", thought, action, observation)
if "accept" in chosen.lower():
break
current_var = next_var
return satellite_data, sat_outputs, current_var
def _react_forecast_agent(location, variable, forecast_model, days, openai_api_key, react_log):
"""
ReAct loop for Weather Data Forecast.
Trigger: test_RMSE > cv_mean_RMSE + 1 Γ— train_RMSE β†’ retry model.
Returns: (forecast_data dict, fc_outputs tuple, final_model used)
"""
loop_count = 0
current_model = forecast_model
best_data = None
best_outputs = None
best_ratio = float("inf")
while loop_count < MAX_LOOPS_PER_AGENT:
loop_count += 1
thought = (
f"Running {current_model} forecast for {location} / {variable} "
f"with {days} days history (loop {loop_count}/{MAX_LOOPS_PER_AGENT})."
)
action = f"weather_data_forecast(location={location}, model={current_model}, days={days})"
forecast_data = json.loads(weather_data_forecast(location, variable, current_model, int(days)))
fc_outputs = analyze_forecast_ui(location, variable, current_model, int(days))
error = forecast_data.get("error", "")
if error:
observation = f"Error: {error}. Stopping."
_react_step(react_log, "ForecastAgent", thought, action, observation)
if best_data is None:
best_data, best_outputs = forecast_data, fc_outputs
break
train_rmse = _extract_rmse(forecast_data.get("train_metrics", ""))
test_rmse = _extract_rmse(forecast_data.get("test_metrics", ""))
cv_rmse = _extract_cv_rmse(forecast_data.get("backtesting_text", ""))
# Trigger: test_RMSE > cv_mean + 1 Γ— train_RMSE
# If any value is missing, fall back to a safe non-trigger (None = unknown)
if train_rmse is not None and test_rmse is not None and cv_rmse is not None:
threshold = cv_rmse + train_rmse # cv_mean + 1 Γ— noise floor
overshoot = test_rmse - threshold # positive = bad
triggered = overshoot > 0
else:
threshold, overshoot, triggered = None, None, False # cannot evaluate β†’ accept
# Track best result (lowest test_RMSE seen, or first if unknown)
curr_test = test_rmse if test_rmse is not None else float("inf")
if curr_test < best_ratio:
best_ratio = curr_test
best_data = forecast_data
best_outputs = fc_outputs
if not triggered:
if threshold is not None:
observation = (
f"{current_model}: train={train_rmse:.4f}, test={test_rmse:.4f}, "
f"cv_mean={cv_rmse:.4f} | threshold=cv+1Γ—train={threshold:.4f} | "
f"test ≀ threshold β€” acceptable. Proceeding."
)
else:
observation = (
f"{current_model}: RMSE values unavailable β€” accepting result as-is."
)
_react_step(react_log, "ForecastAgent", thought, action, observation)
break
# Triggered β€” decide whether to retry
next_model = REACT_MODEL_FALLBACK.get(current_model)
if not next_model or next_model == current_model or loop_count >= MAX_LOOPS_PER_AGENT:
observation = (
f"{current_model}: test={test_rmse:.4f} > threshold={threshold:.4f} "
f"(cv_mean={cv_rmse:.4f} + train={train_rmse:.4f}) β€” overshoot={overshoot:.4f}. "
f"{'No fallback available' if not next_model else 'Loop cap reached'}. "
f"Using best result so far."
)
_react_step(react_log, "ForecastAgent", thought, action, observation)
break
reason_raw = _react_reason(
openai_api_key,
f"{current_model} for {location}/{variable}: test_RMSE={test_rmse:.4f} exceeds "
f"cv_mean + 1Γ—train = {threshold:.4f} by {overshoot:.4f}. "
f"This means the model degrades beyond its own noise floor on recent data.",
[f"retry with {next_model}", f"accept {current_model} (test={test_rmse:.4f})"],
)
chosen = (reason_raw.split("|", 1) + [""])[0].strip()
justification = (reason_raw.split("|", 1) + ["", ""])[1].strip()
observation = (
f"{current_model}: test={test_rmse:.4f} > threshold={threshold:.4f} β€” overshoot={overshoot:.4f}. "
f"Decision: {chosen}. Reason: {justification}"
)
_react_step(react_log, "ForecastAgent", thought, action, observation)
if "accept" in chosen.lower():
break
current_model = next_model
return best_data, best_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)
def generate_risk_report_ui(
location, variable, forecast_model, days, max_cloud, sat_variable,
openai_api_key, pplx_api_key
):
"""
Full risk report pipeline β€” also populates Historical, Satellite, Forecast tabs.
Yields progress on every step so the UI updates live.
Outputs (34 total):
[0] rp_status
[1] rp_gpt_report
[2] rp_forecast_benchmark
[3] rp_pplx_forecast
[4] rp_pplx_news
[5] rp_weather_summary
[6] rp_satellite_summary
[7] rp_forecast_summary
-- 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 (10 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
"""
import sys
N_REPORT = 9 # 5 visible + 3 hidden gr.State + 1 ReAct trace
N_WEATHER = 8 # historical tab outputs
N_SAT = 12 # satellite tab outputs
N_FC = 10 # forecast tab outputs
TOTAL = N_REPORT + N_WEATHER + N_SAT + N_FC # 38
# 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 (10): Markdown, Markdown, Markdown, Markdown, Image, Image, Markdown, Image, Image, Image
EMPTY_FC = ("", "", "", "", None, None, "", None, None, None)
empty_all = ("",) * N_REPORT + EMPTY_WX + EMPTY_SAT + EMPTY_FC # 8+8+12+10=38
def _emit(status_log, report="", benchmark="", pplx_fc="", pplx_news="",
w_sum="", s_sum="", f_sum="", react_trace="",
wx=None, sat=None, fc=None):
"""Build the full tuple yield (5 visible + 3 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,
# 3 hidden State slots
benchmark, w_sum, s_sum, f_sum,
# 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 or not openai_api_key.strip():
yield ("⚠️ Please provide your OpenAI API key.",) + empty_all[1:]
return
if not pplx_api_key or not pplx_api_key.strip():
yield ("⚠️ Please provide your Perplexity API key.",) + empty_all[1:]
return
status_log = []
react_log = [] # all Thought/Action/Observation steps
global_loops = 0 # hard cap across all agents
# ── Step 1a: Historical weather β€” ReAct loop ──────────────────────────────
status_log.append("πŸ”„ [1/6] Historical weather analysis (ReAct)…")
yield _emit(status_log)
weather_data, wx_outputs, final_days = _react_weather_agent(
location, variable, days, openai_api_key, react_log
)
wx_steps = len([s for s in react_log if s["agent"] == "WeatherAgent"])
global_loops += wx_steps
wx_label = f"retried β†’ window escalated to {final_days}d" if final_days != int(days) else f"no retry needed (window={final_days}d)"
status_log[-1] = f"βœ… [1/6] Historical weather β€” {wx_label}."
# ── Step 1b: Satellite analysis β€” ReAct loop ─────────────────────────────
status_log.append("πŸ”„ [2/6] Satellite land-cover analysis (ReAct)…")
yield _emit(status_log, wx=wx_outputs)
if global_loops < GLOBAL_LOOP_CAP:
satellite_data, sat_outputs, final_sat_var = _react_satellite_agent(
location, sat_variable, days, max_cloud, openai_api_key, react_log
)
sat_steps = len([s for s in react_log if s["agent"] == "SatelliteAgent"])
global_loops += sat_steps
sat_label = f"retried β†’ switched to {final_sat_var}" if final_sat_var != sat_variable else f"no retry needed (var={final_sat_var})"
status_log[-1] = f"βœ… [2/6] Satellite β€” {sat_label}."
else:
satellite_data = json.loads(weather_image_analyst(location, sat_variable, min(int(days), 365), float(max_cloud)))
sat_outputs = analyze_satellite_ui(location, sat_variable, min(int(days), 365), float(max_cloud))
final_sat_var = sat_variable
status_log[-1] = "βœ… [2/6] Satellite complete (global cap reached β€” direct call)."
# ── Step 1c: Statistical forecast β€” ReAct loop ───────────────────────────
status_log.append("πŸ”„ [3/6] Running statistical forecast (ReAct)…")
yield _emit(status_log, wx=wx_outputs, sat=sat_outputs)
if global_loops < GLOBAL_LOOP_CAP:
forecast_data, fc_outputs, final_model = _react_forecast_agent(
location, variable, forecast_model, days, openai_api_key, react_log
)
fc_steps = len([s for s in react_log if s["agent"] == "ForecastAgent"])
global_loops += fc_steps
import re as _re2
fc_obs = [s["observation"] for s in react_log if s["agent"] == "ForecastAgent"]
last_fc_obs = fc_obs[-1] if fc_obs else ""
t_m = _re2.search(r"test=([0-9.]+)", last_fc_obs)
th_m = _re2.search(r"threshold=([0-9.]+)", last_fc_obs)
rmse_str = f", test={t_m.group(1)} thr={th_m.group(1)}" if (t_m and th_m) else ""
fc_label = f"retried β†’ {forecast_model}β†’{final_model}{rmse_str}" if final_model != forecast_model else f"no retry needed (model={final_model}{rmse_str})"
status_log[-1] = f"βœ… [3/6] Forecast β€” {fc_label}."
else:
forecast_data = json.loads(weather_data_forecast(location, variable, forecast_model, int(days)))
fc_outputs = analyze_forecast_ui(location, variable, forecast_model, int(days))
final_model = forecast_model
status_log[-1] = "βœ… [3/6] Forecast complete (global cap reached β€” direct call)."
# ── Step 2: Open-Meteo 7-day forecast + Perplexity storm news ────────────
status_log.append("πŸ”„ [4/6] Fetching Open-Meteo live forecast & Perplexity storm news…")
yield _emit(status_log, wx=wx_outputs, sat=sat_outputs, fc=fc_outputs)
om_forecast_result = _call_open_meteo_forecast(location, variable)
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",
]
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}"
# Format forecast markdown
pplx_forecast_md = om_forecast_result["content"]
# ── 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, 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)
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.5 synthesis ─────────────────────────────────────────────
status_log.append("πŸ”„ [6/6] GPT-5.5 synthesising climate risk report…")
yield _emit(status_log, benchmark=benchmark_md,
pplx_fc=pplx_forecast_md, pplx_news=pplx_news_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 **{location}**.
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])}
Error: {weather_data.get('error', 'none')}
### B) Satellite Land-Cover (Sentinel-2, variable: {sat_variable})
Status: {satellite_data.get('status', 'N/A')}
Stationarity: {satellite_data.get('stationarity_conclusion', 'N/A')}
Stats: {json.dumps(satellite_data.get('stats_table', [])[:5])}
Error: {satellite_data.get('error', 'none')}
### C) Statistical Forecast ({forecast_model})
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')}
### 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
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, {forecast_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.
Return clean markdown. Bold section headers.
"""
gpt_report = ""
try:
gpt_report = _call_gpt55(
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.5 synthesis complete. Report ready."
except Exception as exc:
status_log[-1] = f"⚠️ [6/6] GPT-5.5 error: {exc}"
gpt_report = f"GPT-5.5 synthesis failed: {exc}"
yield _emit(
status_log,
report=gpt_report,
benchmark=benchmark_md,
pplx_fc=pplx_forecast_md,
pplx_news=pplx_news_md,
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")
# --- Risk Report Blocks UI ---
with gr.Blocks() as iface_report:
gr.Markdown("## 🌍 Climate Risk Report")
gr.Markdown(
"Orchestrates all three analytical agents, fetches a **7-day live forecast from Open-Meteo** "
"(free, no key needed) as the benchmark, queries **Perplexity Sonar Pro** "
"for recent/upcoming storm & extreme weather news, then uses **GPT-5.5** "
"to synthesise a structured climate risk report with benchmark comparison.\n\n"
"_API keys are used only for this request and never stored._"
)
# --- Inputs ---
with gr.Row():
rp_location = gr.Textbox(
label="Location",
placeholder="e.g. Milan, Jakarta, Cape Town",
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", "AutoMLP", "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_openai_key = gr.Textbox(
label="OpenAI API Key",
placeholder="sk-...",
type="password",
scale=2,
)
rp_pplx_key = gr.Textbox(
label="Perplexity API Key",
placeholder="pplx-...",
type="password",
scale=2,
)
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("")
with gr.Tabs():
with gr.Tab("πŸ“‹ Risk Report (GPT-5.5)"):
rp_gpt_report = gr.Markdown(label="Full Risk Report")
with gr.Tab("πŸ“‘ Live Forecast (Open-Meteo)"):
rp_pplx_forecast = gr.Markdown(label="7-Day Live Forecast")
with gr.Tab("⚑ Storm & Extreme Events (Perplexity)"):
rp_pplx_news = gr.Markdown(label="Recent & Upcoming Extreme Weather")
with gr.Accordion("πŸ” ReAct Trace β€” Agent Reasoning Log", open=False):
gr.Markdown("<sub>Shows each agent's Thought β†’ Action β†’ Observation loop. "
"Only populated when an agent triggered a retry. "
"Expand after running an analysis.</sub>")
rp_react_trace = gr.Markdown("_Run an analysis to see the ReAct trace._")
rp_run_btn.click(
generate_risk_report_ui,
inputs=[
rp_location, rp_variable, rp_model, rp_days, rp_max_cloud, rp_sat_variable,
rp_openai_key, rp_pplx_key,
],
outputs=[
# ── Risk Report tab (5 visible + 1 hidden State) ──
rp_status,
rp_gpt_report,
rp_pplx_forecast,
rp_pplx_news,
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_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 (10) ──
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,
],
)
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)