Spaces:
Sleeping
Sleeping
| 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> High (>65%)<br> | |
| <span style="color:orange">●</span> Medium (35–65%)<br> | |
| <span style="color:green">●</span> Low (<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 | |
| # ========================================================= | |
| 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 <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 >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 | |
| # ========================================================= | |
| 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 | |
| # ========================================================= | |
| 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 | |
| # ========================================================= | |
| 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 | |
| # ========================================================= | |
| 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 + 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>""" |