FWP / app.py
MohammadAlDalo's picture
Update app.py
c5d07d8 verified
Raw
History Blame Contribute Delete
26 kB
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"<b>{city_row.city}</b><br>"
f"{hazard_name} Risk: <b>{label}</b><br>"
f"Confidence: {proba * 100:.1f}%<br>"
f"{detail}"
),
).add_to(m)
# Legend
legend_html = """
<div style="position:fixed;bottom:40px;left:40px;z-index:9999;
background:#111;border:1px solid #333;border-radius:12px;
padding:14px 18px;font-family:sans-serif;font-size:13px;color:#e2e8f0">
<b style="font-size:14px">Risk Level</b><br><br>
<span style="color:red">●</span>&nbsp; High (&gt;65%)<br>
<span style="color:orange">●</span>&nbsp; Medium (35–65%)<br>
<span style="color:green">●</span>&nbsp; Low (&lt;35%)<br>
<br><small style="color:#64748b">Hybrid MLM + DLM ensemble</small>
</div>
"""
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 """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Cyprus Multi-Hazard Risk Platform</title>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"/>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0a0c10;--surface:#111318;--border:rgba(255,255,255,.07);
--flood:#3b82f6;--flood-glow:#1d4ed8;
--fire:#ef4444;--fire-glow:#991b1b;
--text:#e2e8f0;--muted:#64748b;--accent:#f8fafc;
}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;overflow-x:hidden}
body::before{content:'';position:fixed;inset:0;background-image:linear-gradient(rgba(255,255,255,.015) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.015) 1px,transparent 1px);background-size:48px 48px;pointer-events:none;z-index:0}
.orb{position:fixed;border-radius:50%;filter:blur(120px);opacity:.18;pointer-events:none;z-index:0;animation:drift 18s ease-in-out infinite alternate}
.orb-blue{width:640px;height:640px;background:var(--flood);top:-200px;left:-200px}
.orb-red{width:480px;height:480px;background:var(--fire);bottom:-160px;right:-160px;animation-delay:-9s}
@keyframes drift{from{transform:translate(0,0) scale(1)}to{transform:translate(40px,40px) scale(1.08)}}
.page{position:relative;z-index:1;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px 24px;gap:56px}
header{text-align:center;animation:fadeUp .8s ease both}
.eyebrow{display:inline-flex;align-items:center;gap:8px;font-size:11px;font-weight:500;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:20px}
.eyebrow-dot{width:6px;height:6px;border-radius:50%;background:var(--flood);box-shadow:0 0 8px var(--flood);animation:pulse 2s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(.8)}}
h1{font-family:'Bebas Neue',sans-serif;font-size:clamp(42px,7vw,88px);line-height:.95;letter-spacing:.03em;color:var(--accent)}
h1 span{color:var(--flood)}
.subtitle{margin-top:18px;font-size:15px;font-weight:300;color:var(--muted);max-width:460px;margin-inline:auto;line-height:1.7}
.badge-row{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:14px;flex-wrap:wrap}
.badge{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;font-size:11px;font-weight:500;letter-spacing:.06em;border:1px solid var(--border);color:var(--muted)}
.badge-dot{width:6px;height:6px;border-radius:50%}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px;width:100%;max-width:760px;animation:fadeUp .8s .2s ease both}
.card{background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:36px 32px;display:flex;flex-direction:column;gap:20px;transition:transform .25s ease,box-shadow .25s ease,border-color .25s ease}
.card.flood{--c:var(--flood);--cg:var(--flood-glow)}
.card.fire{--c:var(--fire);--cg:var(--fire-glow)}
.card:hover{transform:translateY(-4px);border-color:var(--c);box-shadow:0 0 40px -10px var(--cg)}
.card-icon{width:52px;height:52px;border-radius:14px;background:color-mix(in srgb,var(--c) 15%,transparent);border:1px solid color-mix(in srgb,var(--c) 30%,transparent);display:flex;align-items:center;justify-content:center;font-size:24px}
.card h2{font-family:'Bebas Neue',sans-serif;font-size:28px;letter-spacing:.06em;color:var(--accent)}
.card p{font-size:13.5px;font-weight:300;color:var(--muted);line-height:1.65;flex:1}
.card-actions{display:flex;flex-direction:column;gap:10px}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:13px 20px;border-radius:10px;font-family:'DM Sans',sans-serif;font-size:14px;font-weight:500;cursor:pointer;border:none;text-decoration:none;transition:all .2s ease;width:100%}
.btn-primary{background:var(--c);color:#fff;box-shadow:0 4px 20px -4px var(--cg)}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 8px 28px -4px var(--cg)}
.btn-ghost{background:transparent;color:var(--muted);border:1px solid var(--border)}
.btn-ghost:hover{color:var(--text);border-color:rgba(255,255,255,.18)}
.btn .spinner{display:none;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.btn.loading .spinner{display:block}
.btn.loading .btn-text{opacity:.6}
.divider{width:100%;max-width:760px;border:none;border-top:1px solid var(--border);animation:fadeUp .8s .35s ease both}
.status-bar{width:100%;max-width:760px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 22px;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:10px;animation:fadeUp .8s .4s ease both}
footer{font-size:12px;color:var(--muted);letter-spacing:.05em;animation:fadeUp .8s .5s ease both}
#toast{position:fixed;bottom:32px;left:50%;transform:translateX(-50%) translateY(80px);background:#1e293b;border:1px solid var(--border);border-radius:12px;padding:14px 22px;font-size:14px;color:var(--text);display:flex;align-items:center;gap:10px;box-shadow:0 8px 40px rgba(0,0,0,.5);transition:transform .4s cubic-bezier(.34,1.56,.64,1);z-index:100;white-space:nowrap}
#toast.show{transform:translateX(-50%) translateY(0)}
@keyframes fadeUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
</style>
</head>
<body>
<div class="orb orb-blue"></div>
<div class="orb orb-red"></div>
<div class="page">
<header>
<div class="eyebrow"><span class="eyebrow-dot"></span>Real-time hazard intelligence · Cyprus</div>
<h1>MULTI-HAZARD<br><span>RISK</span> PLATFORM</h1>
<p class="subtitle">Hybrid ensemble predictions (MLM + DLM) powered by live Open-Meteo weather data across all major Cyprus cities.</p>
<div class="badge-row">
<div class="badge"><span class="badge-dot" style="background:#22c55e"></span>Low &lt;35%</div>
<div class="badge"><span class="badge-dot" style="background:#f97316"></span>Medium 35–65%</div>
<div class="badge"><span class="badge-dot" style="background:#ef4444"></span>High &gt;65%</div>
</div>
</header>
<div class="cards">
<div class="card flood">
<div class="card-icon">🌊</div>
<div>
<h2>Flood Risk</h2>
<p>Analyse 7-day cumulative rainfall, humidity, and precipitation patterns using the hybrid MLM + DLM ensemble to classify flood risk per city.</p>
</div>
<div class="card-actions">
<form action="/generate-flood-map" method="post" onsubmit="startLoading(this,'flood-btn','Generating flood map…')">
<button class="btn btn-primary" id="flood-btn" type="submit">
<span class="spinner"></span><span class="btn-text">Generate Flood Map</span>
</button>
</form>
<a href="/view-flood-map" class="btn btn-ghost">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
View Last Map
</a>
</div>
</div>
<div class="card fire">
<div class="card-icon">🔥</div>
<div>
<h2>Wildfire Risk</h2>
<p>Combine temperature peaks, wind speed, and drought proxies with the hybrid MLM + DLM ensemble to surface high-risk wildfire zones in real time.</p>
</div>
<div class="card-actions">
<form action="/generate-wildfire-map" method="post" onsubmit="startLoading(this,'fire-btn','Generating wildfire map…')">
<button class="btn btn-primary" id="fire-btn" type="submit">
<span class="spinner"></span><span class="btn-text">Generate Wildfire Map</span>
</button>
</form>
<a href="/view-wildfire-map" class="btn btn-ghost">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
View Last Map
</a>
</div>
</div>
</div>
<hr class="divider"/>
<div class="status-bar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Open-Meteo Forecast API · 7-day horizon · Hybrid MLM + DLM ensemble · Results cached 15 min
</div>
<footer>© Cyprus Multi-Hazard Risk Platform · v3.0</footer>
</div>
<div id="toast"><span>⚙️</span><span id="toast-msg">Generating map…</span></div>
<script>
function startLoading(form, btnId, message) {
const btn = document.getElementById(btnId);
btn.classList.add('loading');
btn.disabled = true;
const toast = document.getElementById('toast');
document.getElementById('toast-msg').textContent = message;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 8000);
}
</script>
</body>
</html>"""
# =========================================================
# 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("<p>Generate the flood map first.</p>", 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("<p>Generate the wildfire map first.</p>", 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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{title} · Cyprus Risk Platform</title>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"/>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
:root{{--bg:#0a0c10;--surface:#111318;--border:rgba(255,255,255,.07);--c:{accent};--cg:{accent_glow};--text:#e2e8f0;--muted:#64748b}}
body{{min-height:100vh;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;padding:32px}}
body::before{{content:'';position:fixed;inset:0;background-image:linear-gradient(rgba(255,255,255,.015) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.015) 1px,transparent 1px);background-size:48px 48px;pointer-events:none}}
.orb{{position:fixed;width:500px;height:500px;border-radius:50%;filter:blur(120px);opacity:.2;background:var(--c);top:-150px;right:-150px;pointer-events:none}}
.card{{position:relative;z-index:1;background:var(--surface);border:1px solid var(--border);border-radius:24px;padding:48px 40px;max-width:480px;width:100%;text-align:center;display:flex;flex-direction:column;align-items:center;gap:24px;box-shadow:0 0 60px -20px var(--cg);animation:fadeUp .6s ease both}}
.badge{{width:72px;height:72px;border-radius:20px;background:color-mix(in srgb,var(--c) 15%,transparent);border:1px solid color-mix(in srgb,var(--c) 30%,transparent);font-size:32px;display:flex;align-items:center;justify-content:center}}
h1{{font-family:'Bebas Neue',sans-serif;font-size:36px;letter-spacing:.06em;color:#f8fafc}}
p{{font-size:14px;font-weight:300;color:var(--muted);line-height:1.7}}
.timing{{font-size:12px;color:var(--c);letter-spacing:.05em;font-weight:500}}
.model-info{{font-size:12px;color:var(--muted);padding:10px 16px;border:1px solid var(--border);border-radius:8px;line-height:1.6}}
.actions{{display:flex;flex-direction:column;gap:10px;width:100%}}
.btn{{display:flex;align-items:center;justify-content:center;gap:8px;padding:14px 20px;border-radius:10px;font-family:'DM Sans',sans-serif;font-size:14px;font-weight:500;cursor:pointer;border:none;text-decoration:none;transition:all .2s ease}}
.btn-primary{{background:var(--c);color:#fff;box-shadow:0 4px 20px -4px var(--cg)}}
.btn-primary:hover{{transform:translateY(-1px);box-shadow:0 8px 28px -4px var(--cg)}}
.btn-ghost{{background:transparent;color:var(--muted);border:1px solid var(--border)}}
.btn-ghost:hover{{color:var(--text);border-color:rgba(255,255,255,.18)}}
@keyframes fadeUp{{from{{opacity:0;transform:translateY(20px)}}to{{opacity:1;transform:translateY(0)}}}}
</style>
</head>
<body>
<div class="orb"></div>
<div class="card">
<div class="badge">{icon}</div>
<div>
<h1>{title} Ready</h1>
<p>Generated using live Open-Meteo forecast data with hybrid MLM + DLM ensemble predictions for all Cyprus cities.</p>
</div>
<div class="timing">⚡ {timing}</div>
<div class="model-info">
🤖 GradientBoosting MLM &nbsp;+&nbsp; LightMLP DLM<br>
Stacked via Logistic Regression meta-learner<br>
Risk levels: Low · Medium · High
</div>
<div class="actions">
<a href="{map_url}" class="btn btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
View Map
</a>
<a href="{back_url}" class="btn btn-ghost">← Back to Dashboard</a>
</div>
</div>
</body>
</html>"""