import asyncio import os import time import folium import httpx import joblib import numpy as np import pandas as pd import torch import torch.nn as nn from fastapi import FastAPI from fastapi.responses import FileResponse, HTMLResponse # ========================================================= # APP # ========================================================= app = FastAPI(title="Cyprus Multi-Hazard Risk Platform", version="3.0") # ========================================================= # LIGHT MLP — reconstruct architecture to match saved weights # Architecture: Linear(9→64) → ReLU → Dropout(0.3) # Linear(64→32) → ReLU → Dropout(0.2) # Linear(32→2) # Output indices: 0 = wildfire logit, 1 = flood logit # ========================================================= class LightMLP(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Linear(9, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 32), nn.ReLU(), nn.Dropout(0.2), nn.Linear(32, 2), ) def forward(self, x): return self.net(x) # ========================================================= # STARTUP — load models & cities once # ========================================================= # 1. MLM — sklearn pipeline (GradientBoosting MultiOutputClassifier) # predict_proba() → list[2] where [0]=wildfire proba, [1]=flood proba mlm: object = joblib.load("best_mlm.pkl") # 2. DLM — PyTorch LightMLP + its scaler hyb: dict = joblib.load("hybrid_components.pkl") dlm_scaler = hyb["dlm_scaler"] # StandardScaler fitted on raw features dlm_features = hyb["dlm_features"] # feature column order for the DLM meta_wf = hyb["meta_wf"] # LogisticRegression meta-learner (wildfire) meta_fl = hyb["meta_fl"] # LogisticRegression meta-learner (flood) dlm = LightMLP() dlm.load_state_dict( torch.load("light_mlp.pth", map_location="cpu", weights_only=False) ) dlm.eval() # 3. City data cities_df = pd.read_csv("cyprus_cities_full.csv") FLOOD_MAP = "flood_map.html" WILDFIRE_MAP = "wildfire_map.html" # ========================================================= # WEATHER CACHE (15-minute TTL) # ========================================================= _weather_cache: dict = {} CACHE_TTL = 900 def _cache_get(key: str): entry = _weather_cache.get(key) if entry and (time.time() - entry[0]) < CACHE_TTL: return entry[1] return None def _cache_set(key: str, data): _weather_cache[key] = (time.time(), data) # ========================================================= # OPEN-METEO BATCH FETCH # ========================================================= _DAILY_VARS = ( "precipitation_sum," "temperature_2m_max," "relative_humidity_2m_mean," "wind_speed_10m_max" ) OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" async def fetch_all_weather(lats: list, lons: list) -> list: cached = _cache_get("batch_all") if cached: return cached lat_str = ",".join(str(round(v, 4)) for v in lats) lon_str = ",".join(str(round(v, 4)) for v in lons) url = ( f"{OPEN_METEO_URL}" f"?latitude={lat_str}" f"&longitude={lon_str}" f"&daily={_DAILY_VARS}" "&forecast_days=7" "&timezone=auto" ) async with httpx.AsyncClient(timeout=20) as client: try: resp = await client.get(url) resp.raise_for_status() raw = resp.json() results = raw if isinstance(raw, list) else [raw] _cache_set("batch_all", results) return results except Exception: return await _fetch_parallel(client, lats, lons) async def _fetch_parallel(client: httpx.AsyncClient, lats: list, lons: list) -> list: async def _one(lat, lon): url = ( f"{OPEN_METEO_URL}" f"?latitude={lat}&longitude={lon}" f"&daily={_DAILY_VARS}" "&forecast_days=7&timezone=auto" ) r = await client.get(url) return r.json() return await asyncio.gather(*[_one(la, lo) for la, lo in zip(lats, lons)]) # ========================================================= # FEATURE BUILDER # ========================================================= def build_features(cities: pd.DataFrame, weather_list: list) -> pd.DataFrame: rows = [] for city_row, w in zip(cities.itertuples(), weather_list): daily = w.get("daily", {}) precip = daily.get("precipitation_sum", [0] * 7) temp = daily.get("temperature_2m_max", [0] * 7) hum = daily.get("relative_humidity_2m_mean", [0] * 7) wind = daily.get("wind_speed_10m_max", [0] * 7) rain_mm = precip[-1] if precip else 0 rain_3d = sum(precip[-3:]) if len(precip) >= 3 else sum(precip) rain_7d = sum(precip) rows.append({ "latitude": city_row.lat, "longitude": city_row.lon, "rain_mm": rain_mm, "rain_3d": rain_3d, "rain_7d": rain_7d, "temperature": temp[-1] if temp else 0, "humidity": hum[-1] if hum else 0, "wind_speed": wind[-1] if wind else 0, "precipitation": rain_mm, }) return pd.DataFrame(rows) # ========================================================= # HYBRID ENSEMBLE PREDICT # Stacks MLM (sklearn) + DLM (PyTorch) via two meta-learners. # Returns (wf_labels, fl_labels, wf_probas, fl_probas) # where label ∈ {"Low", "Medium", "High"} # ========================================================= def _risk_label(p: float) -> tuple[str, str]: """Map probability → (label, folium_color).""" if p < 0.35: return "Low", "green" elif p < 0.65: return "Medium", "orange" else: return "High", "red" def hybrid_predict(X: pd.DataFrame): """ X must have columns matching dlm_features / mlm feature_names_in_. Returns four parallel lists: wf_labels, fl_labels, wf_probas, fl_probas """ n = len(X) # ── 1. MLM probabilities ───────────────────────────────────────── # predict_proba → list of 2 arrays, each shape (n, 2) mlm_proba = mlm.predict_proba(X) mlm_p_wf = mlm_proba[0][:, 1] # wildfire P(class=1) mlm_p_fl = mlm_proba[1][:, 1] # flood P(class=1) # ── 2. DLM probabilities ───────────────────────────────────────── X_vals = X[dlm_features].values.astype(float) X_scaled = dlm_scaler.transform(X_vals) X_tensor = torch.tensor(X_scaled, dtype=torch.float32) with torch.no_grad(): logits = dlm(X_tensor) # (n, 2) dlm_prob = torch.sigmoid(logits).numpy() # (n, 2) dlm_p_wf = dlm_prob[:, 0] # wildfire logit index 0 dlm_p_fl = dlm_prob[:, 1] # flood logit index 1 # ── 3. Meta-learner stacking ────────────────────────────────────── meta_X_wf = np.column_stack([mlm_p_wf, dlm_p_wf]) # (n, 2) meta_X_fl = np.column_stack([mlm_p_fl, dlm_p_fl]) # (n, 2) final_wf_proba = meta_wf.predict_proba(meta_X_wf)[:, 1] final_fl_proba = meta_fl.predict_proba(meta_X_fl)[:, 1] # ── 4. Convert to labels ────────────────────────────────────────── wf_labels = [_risk_label(p) for p in final_wf_proba] fl_labels = [_risk_label(p) for p in final_fl_proba] return wf_labels, fl_labels, final_wf_proba, final_fl_proba # ========================================================= # MAP BUILDER (shared logic for flood & wildfire) # ========================================================= async def _build_map(hazard: str): t0 = time.time() lats = cities_df["lat"].tolist() lons = cities_df["lon"].tolist() # 1. Weather data (single batched network call) weather_list = await fetch_all_weather(lats, lons) # 2. Feature matrix X = build_features(cities_df, weather_list) # 3. Hybrid ensemble inference wf_labels, fl_labels, wf_probas, fl_probas = hybrid_predict(X) # 4. Build folium map m = folium.Map(location=[35.1, 33.4], zoom_start=8) # Risk level → circle radius _radius = {"Low": 7, "Medium": 10, "High": 13} for i, city_row in enumerate(cities_df.itertuples()): daily = weather_list[i].get("daily", {}) if hazard == "flood": label, color = fl_labels[i] proba = fl_probas[i] rain_7d = sum(daily.get("precipitation_sum", [0] * 7)) detail = f"Rain 7D: {rain_7d:.1f} mm" hazard_name = "Flood" else: label, color = wf_labels[i] proba = wf_probas[i] temp_list = daily.get("temperature_2m_max", [0] * 7) detail = f"Temperature: {temp_list[-1]:.1f} °C" hazard_name = "Wildfire" folium.CircleMarker( [city_row.lat, city_row.lon], radius=_radius.get(label, 8), color=color, fill=True, fill_color=color, fill_opacity=0.75, popup=( f"{city_row.city}
" f"{hazard_name} Risk: {label}
" f"Confidence: {proba * 100:.1f}%
" f"{detail}" ), ).add_to(m) # Legend legend_html = """
Risk Level

  High (>65%)
  Medium (35–65%)
  Low (<35%)

Hybrid MLM + DLM ensemble
""" m.get_root().html.add_child(folium.Element(legend_html)) elapsed = round(time.time() - t0, 2) return m, elapsed # ========================================================= # HOME PAGE # ========================================================= @app.get("/", response_class=HTMLResponse) def home(): return """ Cyprus Multi-Hazard Risk Platform
Real-time hazard intelligence · Cyprus

MULTI-HAZARD
RISK PLATFORM

Hybrid ensemble predictions (MLM + DLM) powered by live Open-Meteo weather data across all major Cyprus cities.

Low <35%
Medium 35–65%
High >65%
🌊

Flood Risk

Analyse 7-day cumulative rainfall, humidity, and precipitation patterns using the hybrid MLM + DLM ensemble to classify flood risk per city.

View Last Map
🔥

Wildfire Risk

Combine temperature peaks, wind speed, and drought proxies with the hybrid MLM + DLM ensemble to surface high-risk wildfire zones in real time.

View Last Map

Open-Meteo Forecast API · 7-day horizon · Hybrid MLM + DLM ensemble · Results cached 15 min
⚙️Generating map…
""" # ========================================================= # GENERATE FLOOD MAP # ========================================================= @app.post("/generate-flood-map", response_class=HTMLResponse) async def generate_flood_map(): m, elapsed = await _build_map("flood") m.save(FLOOD_MAP) return _result_page( title="Flood Risk Map", icon="🌊", accent="#3b82f6", accent_glow="#1d4ed8", map_url="/view-flood-map", back_url="/", elapsed=elapsed, ) # ========================================================= # VIEW FLOOD MAP # ========================================================= @app.get("/view-flood-map") def view_flood_map(): if os.path.exists(FLOOD_MAP): return FileResponse(FLOOD_MAP) return HTMLResponse("

Generate the flood map first.

", status_code=404) # ========================================================= # GENERATE WILDFIRE MAP # ========================================================= @app.post("/generate-wildfire-map", response_class=HTMLResponse) async def generate_wildfire_map(): m, elapsed = await _build_map("wildfire") m.save(WILDFIRE_MAP) return _result_page( title="Wildfire Risk Map", icon="🔥", accent="#ef4444", accent_glow="#991b1b", map_url="/view-wildfire-map", back_url="/", elapsed=elapsed, ) # ========================================================= # VIEW WILDFIRE MAP # ========================================================= @app.get("/view-wildfire-map") def view_wildfire_map(): if os.path.exists(WILDFIRE_MAP): return FileResponse(WILDFIRE_MAP) return HTMLResponse("

Generate the wildfire map first.

", status_code=404) # ========================================================= # RESULT PAGE (shared helper) # ========================================================= def _result_page(title, icon, accent, accent_glow, map_url, back_url, elapsed=None): timing = f"Generated in {elapsed}s" if elapsed else "Map ready" return f""" {title} · Cyprus Risk Platform
{icon}

{title} Ready

Generated using live Open-Meteo forecast data with hybrid MLM + DLM ensemble predictions for all Cyprus cities.

⚡ {timing}
🤖 GradientBoosting MLM  +  LightMLP DLM
Stacked via Logistic Regression meta-learner
Risk levels: Low · Medium · High
View Map ← Back to Dashboard
"""