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 = """
Hybrid ensemble predictions (MLM + DLM) powered by live Open-Meteo weather data across all major Cyprus cities.
Analyse 7-day cumulative rainfall, humidity, and precipitation patterns using the hybrid MLM + DLM ensemble to classify flood risk per city.
Combine temperature peaks, wind speed, and drought proxies with the hybrid MLM + DLM ensemble to surface high-risk wildfire zones in real time.
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"""Generated using live Open-Meteo forecast data with hybrid MLM + DLM ensemble predictions for all Cyprus cities.