|
|
|
|
|
|
| import gradio as gr
|
| import requests
|
| import pandas as pd
|
| import numpy as np
|
| import warnings
|
| from datetime import datetime, timedelta
|
| from statsmodels.tsa.stattools import adfuller, kpss
|
| from statsmodels.tsa.seasonal import seasonal_decompose
|
| from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
|
| import matplotlib.pyplot as plt
|
| import matplotlib.dates as mdates
|
| import seaborn as sns
|
|
|
| warnings.filterwarnings("ignore")
|
|
|
|
|
|
|
|
|
|
|
| def get_lat_lon(location):
|
| """
|
| Geocode 'City', 'City, Country', 'City, US' or 'City, Tennessee' via the
|
| Open-Meteo geocoding API. Comma qualifiers are matched client-side against
|
| country name, ISO country code and admin1 (state/region), because the API
|
| itself cannot parse ISO codes or state names. Bare names keep returning
|
| the API's top-ranked match (backwards compatible).
|
| NOTE: keep in sync with the identical logic in the Climate_Risk_Monitoring
|
| Space (_resolve_location) and the other two upstream Spaces.
|
| """
|
| try:
|
| url = "https://geocoding-api.open-meteo.com/v1/search"
|
|
|
| def search(query, count=10):
|
| resp = requests.get(
|
| url,
|
| params={"name": query, "count": count, "language": "en", "format": "json"},
|
| timeout=10,
|
| )
|
| if resp.status_code == 200:
|
| return resp.json().get("results") or []
|
| return []
|
|
|
| raw = (location or "").strip().strip(".")
|
| city, _, qualifier = raw.partition(",")
|
| city, qualifier = city.strip(), qualifier.strip()
|
|
|
| candidates = search(raw)
|
| if not candidates and qualifier:
|
| qual = qualifier.lower().rstrip(".")
|
| qual = {
|
| "uk": "united kingdom", "u.k": "united kingdom",
|
| "great britain": "united kingdom",
|
| "usa": "united states", "u.s": "united states",
|
| "u.s.a": "united states", "america": "united states",
|
| "uae": "united arab emirates", "holland": "netherlands",
|
| }.get(qual, qual)
|
| candidates = [c for c in search(city) if qual in (
|
| (c.get("country") or "").lower(),
|
| (c.get("country_code") or "").lower(),
|
| (c.get("admin1") or "").lower(),
|
| )]
|
| if candidates:
|
| result = candidates[0]
|
| return {
|
| "location": result["name"],
|
| "country": result.get("country", "Unknown"),
|
| "latitude": result["latitude"],
|
| "longitude": result["longitude"],
|
| "timezone": result.get("timezone", "UTC")
|
| }
|
| return {"error": f"Location '{location}' not found"}
|
| except Exception as e:
|
| return {"error": str(e)}
|
|
|
| def fetch_historical_weather(latitude: float, longitude: float, days: int = 365) -> tuple:
|
| """
|
| Fetch daily historical weather from NASA POWER API.
|
| Source: https://power.larc.nasa.gov — NASA servers, no API key, global,
|
| data from 1981 to present. Completely independent of open-meteo.
|
| Returns (DataFrame, error_string).
|
| DataFrame columns: ds, Temperature, Precipitation, Wind_Speed
|
| NASA POWER parameters used:
|
| T2M — temperature at 2m (°C)
|
| PRECTOTCORR — corrected precipitation (mm/day)
|
| WS10M — wind speed at 10m (m/s) → converted to km/h
|
| """
|
| try:
|
| end_date = datetime.now() - timedelta(days=1)
|
| start_date = end_date - timedelta(days=days)
|
|
|
| r = requests.get(
|
| "https://power.larc.nasa.gov/api/temporal/daily/point",
|
| params={
|
| "parameters": "T2M,PRECTOTCORR,WS10M",
|
| "community": "RE",
|
| "longitude": round(longitude, 4),
|
| "latitude": round(latitude, 4),
|
| "start": start_date.strftime("%Y%m%d"),
|
| "end": end_date.strftime("%Y%m%d"),
|
| "format": "JSON",
|
| },
|
| timeout=60,
|
| )
|
|
|
| if r.status_code != 200:
|
| return pd.DataFrame(), f"NASA POWER HTTP {r.status_code}: {r.text[:200]}"
|
|
|
| data = r.json()
|
| params = data.get("properties", {}).get("parameter", {})
|
|
|
| if not params:
|
| return pd.DataFrame(), "NASA POWER response missing 'parameter' data"
|
|
|
| t2m = params.get("T2M", {})
|
| prec = params.get("PRECTOTCORR", {})
|
| ws10m = params.get("WS10M", {})
|
|
|
| if not t2m:
|
| return pd.DataFrame(), "NASA POWER returned no temperature data"
|
|
|
| dates = sorted(t2m.keys())
|
| df = pd.DataFrame({
|
| "ds": pd.to_datetime(dates, format="%Y%m%d"),
|
| "Temperature": [t2m.get(d, np.nan) for d in dates],
|
| "Precipitation": [prec.get(d, np.nan) for d in dates],
|
|
|
| "Wind_Speed": [ws10m.get(d, np.nan) * 3.6 for d in dates],
|
| })
|
|
|
|
|
| df = df.replace(-999, np.nan).dropna()
|
|
|
| if len(df) < 30:
|
| return pd.DataFrame(), f"Only {len(df)} days of data — need at least 30"
|
|
|
| return df, None
|
|
|
| except Exception as e:
|
| return pd.DataFrame(), str(e)
|
|
|
|
|
|
|
|
|
|
|
| def perform_statistical_tests(df, column):
|
| ts = df[column].dropna()
|
| with warnings.catch_warnings():
|
| warnings.simplefilter("ignore")
|
| adf_result = adfuller(ts)
|
| adf_stationary = bool(adf_result[1] < 0.05)
|
| kpss_result = kpss(ts, regression='c')
|
| kpss_stationary = bool(kpss_result[1] > 0.05)
|
|
|
| rows = [
|
| ["Mean", f"{ts.mean():.4f}"],
|
| ["Std Dev", f"{ts.std():.4f}"],
|
| ["Min", f"{ts.min():.4f}"],
|
| ["Max", f"{ts.max():.4f}"],
|
| ["ADF Test Statistic", f"{adf_result[0]:.4f}"],
|
| ["ADF p-value", f"{adf_result[1]:.4f}"],
|
| ["ADF Stationary", "Yes" if adf_stationary else "No"],
|
| ["KPSS Test Statistic", f"{kpss_result[0]:.4f}"],
|
| ["KPSS p-value", f"{kpss_result[1]:.4f}"],
|
| ["KPSS Stationary", "Yes" if kpss_stationary else "No"],
|
| ]
|
| result_df = pd.DataFrame(rows, columns=["Parameter", "Value"])
|
|
|
| if adf_stationary and kpss_stationary:
|
| conclusion = f"{column} is stationary (both ADF and KPSS agree)."
|
| elif adf_stationary and not kpss_stationary:
|
| conclusion = f"{column} is trend-stationary (ADF stationary, KPSS non-stationary)."
|
| elif not adf_stationary and kpss_stationary:
|
| conclusion = f"{column} is difference-stationary (ADF non-stationary, KPSS stationary)."
|
| else:
|
| conclusion = f"{column} is non-stationary (both ADF and KPSS agree)."
|
|
|
| return result_df, conclusion
|
|
|
|
|
|
|
|
|
|
|
|
|
| def plot_lineplot(df, column, location_name):
|
| fig, ax = plt.subplots(figsize=(12, 4))
|
| ax.plot(df['ds'], df[column], label=column, color='tab:blue')
|
| ax.set_title(f"{column} Over Time - {location_name}", fontsize=12)
|
| ax.set_xlabel('Date')
|
| ax.set_ylabel(column)
|
| ax.grid(True, linestyle='--', alpha=0.7)
|
| plt.xticks(rotation=45)
|
| plt.tight_layout()
|
| return fig
|
|
|
|
|
| def plot_boxplot(df, column, location_name):
|
| data_reset = df.reset_index(drop=True).copy()
|
| data_reset['Month'] = data_reset['ds'].dt.strftime('%B')
|
| months_order = [
|
| 'January', 'February', 'March', 'April', 'May', 'June',
|
| 'July', 'August', 'September', 'October', 'November', 'December'
|
| ]
|
|
|
| present_months = [m for m in months_order if m in data_reset['Month'].values]
|
| fig, ax = plt.subplots(figsize=(12, 4))
|
| sns.boxplot(
|
| x='Month', y=column, data=data_reset,
|
| order=present_months, palette='Set3', ax=ax
|
| )
|
| ax.set_title(f"{column} Distribution per Month - {location_name}", fontsize=12)
|
| ax.set_xlabel('Month', fontsize=10)
|
| ax.set_ylabel(column, fontsize=10)
|
| ax.tick_params(axis='x', rotation=45, labelsize=10)
|
| ax.tick_params(axis='y', labelsize=10)
|
| ax.grid(True, linestyle='--', alpha=0.7)
|
| plt.tight_layout()
|
| return fig
|
|
|
|
|
| def plot_autocorrelation(df, column):
|
| fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(12, 4))
|
| plot_acf(df[column].dropna(), lags=21, ax=axs[0], color="fuchsia")
|
| axs[0].set_title(f"Autocorrelation - {column}")
|
| plot_pacf(df[column].dropna(), lags=21, ax=axs[1], color="lime")
|
| axs[1].set_title(f"Partial Autocorrelation - {column}")
|
| plt.tight_layout()
|
| return fig
|
|
|
|
|
| def plot_decomposition(df, column, model, period):
|
| try:
|
| result = seasonal_decompose(df[column].dropna(), model=model, period=period)
|
| fig = result.plot()
|
| fig.suptitle(f"Seasonal Decomposition of {column}", fontsize=13, y=1.02)
|
| plt.tight_layout()
|
| return fig, ""
|
| except Exception as e:
|
| return None, f"An error occurred during decomposition: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| def analyze_weather(location, variable, decomposition_model, decomposition_period, days):
|
| if not location:
|
| return (
|
| "Please enter a location.", None,
|
| None, None, None,
|
| None, None, "",
|
| )
|
|
|
|
|
| loc_data = get_lat_lon(location)
|
| if "error" in loc_data:
|
| return (
|
| f"Error: {loc_data['error']}", None,
|
| None, None, None,
|
| None, None, "",
|
| )
|
|
|
| location_label = f"{loc_data['location']}, {loc_data['country']}"
|
|
|
|
|
| df, fetch_error = fetch_historical_weather(loc_data['latitude'], loc_data['longitude'], days=int(days))
|
| if fetch_error or df.empty:
|
| return (
|
| f"Failed to fetch weather data: {fetch_error}", None,
|
| None, None, None,
|
| None, None, "",
|
| )
|
|
|
|
|
| display_df = df[['ds', variable]].copy()
|
| display_df['ds'] = display_df['ds'].dt.strftime('%Y-%m-%d')
|
| display_df = display_df.rename(columns={'ds': 'Date'})
|
|
|
|
|
| lineplot_fig = plot_lineplot(df, variable, location_label)
|
| boxplot_fig = plot_boxplot(df, variable, location_label)
|
| autocorr_fig = plot_autocorrelation(df, variable)
|
|
|
|
|
| decomp_fig, _ = plot_decomposition(df, variable, decomposition_model, int(decomposition_period))
|
|
|
|
|
| stats_df, stats_conclusion = perform_statistical_tests(df, variable)
|
|
|
| status_msg = f"✅ Analysis complete for **{location_label}** | Variable: **{variable}** | {len(df)} days of data loaded."
|
|
|
| return (
|
| status_msg,
|
| display_df,
|
| lineplot_fig,
|
| boxplot_fig,
|
| autocorr_fig,
|
| decomp_fig,
|
| stats_df,
|
| stats_conclusion,
|
| )
|
|
|
|
|
|
|
|
|
|
|
|
|
| def build_interface():
|
| with gr.Blocks() as iface:
|
| gr.Markdown("# 🌦️ Weather Data Analyst Dashboard")
|
| gr.Markdown("""
|
| Enter a location and configure the analysis parameters.
|
| Weather data is fetched from the [NASA POWER API](https://power.larc.nasa.gov).
|
| """)
|
|
|
|
|
| with gr.Row():
|
| location_input = gr.Textbox(label="Location", placeholder="e.g., London, Tokyo, Milan")
|
| variable_input = gr.Dropdown(
|
| choices=["Temperature", "Precipitation", "Wind_Speed"],
|
| value="Temperature",
|
| label="Weather Variable"
|
| )
|
| decomp_model_display = gr.Textbox(
|
| value="additive",
|
| label="Decomposition Model (auto)",
|
| interactive=False
|
| )
|
| decomp_period_input = gr.Number(
|
| value=7,
|
| label="Decomposition Period (days)",
|
| precision=0
|
| )
|
| days_input = gr.Dropdown(
|
| choices=[90, 180, 365, 730, 1095, 1825],
|
| value=90,
|
| label="Historical Window (days)"
|
| )
|
|
|
| analyze_button = gr.Button("Analyze 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.Plot(label="Line Plot")
|
| boxplot_output = gr.Plot(label="Box Plot")
|
| autocorr_output = gr.Plot(label="Autocorrelation Plots")
|
|
|
| with gr.Tab("Time Series Decomposition"):
|
| decomposition_output = gr.Plot(label="Decomposition Plot")
|
|
|
| with gr.Tab("Statistical Analysis"):
|
| stats_table_output = gr.Dataframe(label="Statistical Tests (ADF & KPSS)")
|
| stats_conclusion_output = gr.Textbox(label="Conclusion", interactive=False)
|
|
|
| def set_decomp_model(variable):
|
| return "multiplicative" if variable == "Wind_Speed" else "additive"
|
|
|
| variable_input.change(
|
| set_decomp_model,
|
| inputs=[variable_input],
|
| outputs=[decomp_model_display]
|
| )
|
|
|
| analyze_button.click(
|
| analyze_weather,
|
| inputs=[
|
| location_input,
|
| variable_input,
|
| decomp_model_display,
|
| decomp_period_input,
|
| days_input,
|
| ],
|
| outputs=[
|
| status_output,
|
| data_output,
|
| lineplot_output,
|
| boxplot_output,
|
| autocorr_output,
|
| decomposition_output,
|
| stats_table_output,
|
| stats_conclusion_output,
|
| ]
|
| )
|
|
|
| return iface
|
|
|
|
|
|
|
|
|
|
|
|
|
| if __name__ == "__main__":
|
| interface = build_interface()
|
| interface.launch()
|
|
|