| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import json |
| import gradio as gr |
| from gradio_client import Client |
|
|
| |
| |
| |
|
|
| 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" |
|
|
| |
| |
| |
|
|
| _clients: dict = {} |
|
|
| def _get_client(url: str) -> Client: |
| if url not in _clients: |
| _clients[url] = Client(url) |
| return _clients[url] |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, |
| variable, |
| decomp_model, |
| decomposition_period, |
| days, |
| api_name="/analyze_weather", |
| ) |
|
|
| |
| 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)}) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, |
| variable, |
| decomp_model, |
| 7, |
| days, |
| max_cloud, |
| api_name="/analyze_and_store", |
| ) |
|
|
| |
| 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)}) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, |
| variable, |
| model, |
| days, |
| api_name="/main", |
| ) |
|
|
| |
| 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)}) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| weather_data = json.loads( |
| weather_data_analyst(location, variable, 7, days) |
| ) |
|
|
| |
| satellite_data = json.loads( |
| weather_image_analyst(location, "cloud_cover", min(int(days), 365), max_cloud) |
| ) |
|
|
| |
| forecast_data = json.loads( |
| weather_data_forecast(location, variable, forecast_model, days) |
| ) |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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): |
| |
| 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) |
| |
| 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", |
| ) |
| |
| |
| |
|
|
| 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 "" |
| |
| 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) |
|
|
| |
| |
| |
| 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") |
| 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:] |
|
|
|
|
| |
| 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)" |
| ) |
|
|
| |
| 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") |
|
|
| |
| 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 |
| ) |
|
|
| |
| 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") |
| 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) |
| |
| 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, |
| variable, |
| decomp_model, |
| 7, |
| int(days), |
| float(max_cloud), |
| 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:] |
|
|
|
|
| |
| 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)" |
| ) |
|
|
| |
| 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") |
|
|
| |
| 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 |
| ) |
|
|
| |
| 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") |
| 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) |
| |
| 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:] |
|
|
|
|
| |
| 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)" |
| ) |
|
|
| |
| 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") |
|
|
| |
| 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, |
| ], |
| ) |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| 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: |
| |
| 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) |
|
|
| |
| 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}) |
|
|
| |
| 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, |
| "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, |
| "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() |
| |
| 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.") |
| |
| 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", []) |
| |
| 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", "") |
|
|
| |
| |
| |
| |
| import re |
| stat_val = None |
| stat_date = None |
| pi = forecast_data.get("prediction_info", "") |
| |
| |
| 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] |
| |
| 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 |
|
|
| |
| 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 "" |
|
|
| |
| om_match_val = None |
| om_match_date = None |
| if stat_date and daily: |
| for d in daily: |
| |
| d_date = d.get("date", "") |
| |
| 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) |", |
| ] |
|
|
| |
| 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')} | β |", |
| "", |
| ] |
|
|
| |
| 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.", |
| "", |
| ] |
|
|
| |
| 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", "β") |
| |
| 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("") |
|
|
| |
| 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_MIN_SCENES = 5 |
| REACT_DAYS_ESCALATION = {365: 1825, 180: 730, 90: 365} |
| REACT_MODEL_FALLBACK = { |
| "AutoARIMA": "Chronos-2", |
| "AutoETS": "Chronos-2", |
| "AutoLightGBM": "AutoARIMA", |
| "AutoMLP": "AutoARIMA", |
| "Chronos-2": "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 |
|
|
| |
| 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 |
|
|
| |
| 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", "")) |
|
|
| |
| |
| if train_rmse is not None and test_rmse is not None and cv_rmse is not None: |
| threshold = cv_rmse + train_rmse |
| overshoot = test_rmse - threshold |
| triggered = overshoot > 0 |
| else: |
| threshold, overshoot, triggered = None, None, False |
|
|
| |
| 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 |
|
|
| |
| 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 |
| N_WEATHER = 8 |
| N_SAT = 12 |
| N_FC = 10 |
| TOTAL = N_REPORT + N_WEATHER + N_SAT + N_FC |
|
|
| |
| |
| EMPTY_WX = ("", None, None, None, None, None, None, "") |
| |
| EMPTY_SAT = ("", None, None, "", None, None, None, None, None, None, None, "") |
| |
| EMPTY_FC = ("", "", "", "", None, None, "", None, None, None) |
|
|
| empty_all = ("",) * N_REPORT + EMPTY_WX + EMPTY_SAT + EMPTY_FC |
|
|
| 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 ( |
| |
| "\n".join(status_log), report, pplx_fc, pplx_news, |
| |
| benchmark, w_sum, s_sum, f_sum, |
| |
| 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 = [] |
| global_loops = 0 |
|
|
| |
| 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}." |
|
|
| |
| 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)." |
|
|
| |
| 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)." |
|
|
| |
| 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}" |
|
|
| |
| pplx_forecast_md = om_forecast_result["content"] |
|
|
| |
| 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." |
|
|
| |
| 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]) |
| ) |
|
|
| |
| 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") |
|
|
|
|
| |
| 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._" |
| ) |
|
|
| |
| 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") |
|
|
| |
| |
| |
| rp_forecast_benchmark = gr.State("") |
| 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=[ |
| |
| rp_status, |
| rp_gpt_report, |
| rp_pplx_forecast, |
| rp_pplx_news, |
| rp_forecast_benchmark, |
| rp_weather_summary, |
| rp_satellite_summary, |
| rp_forecast_summary, |
| rp_react_trace, |
| |
| status_output, |
| data_output, |
| lineplot_output, |
| boxplot_output, |
| autocorr_output, |
| decomp_output, |
| stats_table_output, |
| stats_conclusion_output, |
| |
| 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, |
| |
| 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) |
|
|