Spaces:
Running
Running
Download index.html from jonathan4e/qai-laya: direct link, hf CLI and curl.
- Browser
- Download file 35.9 kB
-
https://huggingface.co/spaces/jonathan4e/qai-laya/resolve/main/index.html
- Command line
-
hf download hf://spaces/jonathan4e/qai-laya/index.html
-
curl -L -o index.html https://huggingface.co/spaces/jonathan4e/qai-laya/resolve/main/index.html
35.9 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>QAI Connect · Decision Reserve</title> | |
| <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700;800&family=JetBrains+Mono:wght@400;700&display=swap"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css"> | |
| <style> | |
| :root{--ink:#1E2A44;--paper:#FBF9F3;--sand:#F5F2E9;--maroon:#7A1D3C;--sun:#E3C477;--lime:#B6E36A;--sky:#9DB8E6;--coral:#F2836B;--lilac:#C9B6E8;--mono:"JetBrains Mono",ui-monospace,monospace} | |
| *{box-sizing:border-box} | |
| body{margin:0;font:16px/1.55 "DM Sans",system-ui,sans-serif;color:var(--ink);background:var(--sand) radial-gradient(#1E2A441f 1px,transparent 1px) 0 0/22px 22px} | |
| main{max-width:980px;margin:0 auto;padding:20px 16px 60px;display:grid;gap:18px} | |
| html{scroll-behavior:smooth} | |
| nav.top{position:sticky;top:0;z-index:1000;background:var(--paper);border-bottom:3px solid var(--ink)} | |
| nav.top .in{max-width:980px;margin:0 auto;padding:10px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap} | |
| nav.top a.l{color:var(--ink);text-decoration:none;font-weight:700;padding:8px 10px;border:3px solid transparent;border-radius:6px;min-height:44px;display:inline-flex;align-items:center} | |
| nav.top a.l:hover{border-color:var(--ink);background:var(--sand)} | |
| .brand{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--ink)} | |
| .logo{width:38px;height:38px;display:grid;place-items:center;border:3px solid var(--ink);border-radius:6px;background:var(--maroon);color:#fff;font:800 18px var(--mono);box-shadow:3px 3px 0 var(--ink)} | |
| .hero{background:var(--ink);color:var(--paper);border-bottom:3px solid var(--ink)} | |
| .hero .in{max-width:980px;margin:0 auto;padding:56px 16px 48px;display:grid;gap:18px} | |
| .hero h1{font-size:clamp(34px,6.5vw,64px);margin:0;line-height:1.02;letter-spacing:-.02em} | |
| .hero h1 em{font-style:normal;background:var(--maroon);padding:0 .12em} | |
| .hero p{font-size:19px;max-width:640px;margin:0;opacity:.9} | |
| .cta{display:inline-flex;align-items:center;min-height:52px;padding:0 22px;border:3px solid var(--paper);border-radius:6px;background:var(--lime);color:var(--ink);font-weight:800;font-size:17px;text-decoration:none;box-shadow:5px 5px 0 var(--paper);transition:transform .12s,box-shadow .12s} | |
| .cta:hover{transform:translate(-2px,-2px);box-shadow:7px 7px 0 var(--paper)} | |
| .cta.alt{background:transparent;color:var(--paper)} | |
| .feat{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(210px,1fr))} | |
| .feat .box{padding:16px;animation:fade .5s ease-out both} | |
| .feat h3{margin:6px 0 4px} | |
| .kpis{display:flex;flex-wrap:wrap;gap:22px;margin-top:6px} | |
| .kpis b{display:block;font:800 28px var(--mono);color:var(--sun)} | |
| .sec-title{font-size:28px;margin:12px 0 0} | |
| @media (max-width:640px){nav.top .links{display:none}} | |
| .box{background:var(--paper);border:3px solid var(--ink);border-radius:6px;box-shadow:6px 6px 0 var(--ink);min-width:0;overflow:hidden} | |
| .pad{padding:16px} | |
| .hd{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 16px;border-bottom:3px solid var(--ink);font-weight:800} | |
| .caps{font:700 12px var(--mono);letter-spacing:.1em;text-transform:uppercase} | |
| header.box{background:var(--ink);color:var(--paper);padding:18px} | |
| h1{margin:6px 0;font-size:clamp(28px,5vw,46px);line-height:1.05} | |
| h1 em{font-style:normal;background:var(--maroon);padding:0 .12em} | |
| .row{display:flex;flex-wrap:wrap;gap:10px;align-items:center} | |
| input{flex:1;min-width:200px;min-height:48px;font:16px "DM Sans",sans-serif;padding:0 14px;border:3px solid var(--ink);border-radius:6px;background:#fff;color:var(--ink)} | |
| button{min-height:44px;padding:0 16px;font:700 15px "DM Sans",sans-serif;border:3px solid var(--ink);border-radius:6px;background:var(--paper);color:var(--ink);box-shadow:3px 3px 0 var(--ink);cursor:pointer} | |
| button.main{background:var(--maroon);color:#fff} | |
| button:disabled{opacity:.5;cursor:wait} | |
| button:active:not(:disabled){transform:translate(3px,3px);box-shadow:none} | |
| #map{height:380px} | |
| .grid4{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(170px,1fr))} | |
| .stat{padding:12px}.stat b{display:block;font:700 24px var(--mono)} | |
| .step{display:grid;grid-template-columns:44px 1fr;gap:12px;opacity:.3;transition:opacity .3s} | |
| .step.done,.step.run{opacity:1} | |
| .step .n{width:44px;height:44px;display:grid;place-items:center;border:3px solid var(--ink);border-radius:6px;font:700 15px var(--mono);background:var(--paper)} | |
| .step.run .n{background:var(--sun);animation:pulse .7s infinite alternate}.step.done .n{background:var(--lime)} | |
| @keyframes pulse{to{transform:scale(1.08)}} | |
| pre{margin:6px 0 0;background:var(--ink);color:#d7e3ff;border-radius:6px;padding:10px;font:12px/1.45 var(--mono);overflow:auto;max-height:180px;animation:fade .4s ease-out both} | |
| @keyframes fade{from{opacity:0}to{opacity:1}} | |
| table{border-collapse:collapse;width:100%;font-size:14px} | |
| td,th{border-bottom:2px solid #1E2A4422;padding:8px 12px;text-align:left} | |
| th{font:700 12px var(--mono);text-transform:uppercase;background:var(--ink);color:var(--paper)} | |
| .scroll{overflow-x:auto} | |
| .mono{font-family:var(--mono)} | |
| .chip{display:inline-block;border:2px solid var(--ink);border-radius:6px;padding:2px 8px;font:700 12px var(--mono);background:var(--sun)} | |
| .onto{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:16px} | |
| .onto .e{border:3px solid var(--ink);border-radius:6px;padding:6px 10px;box-shadow:3px 3px 0 var(--ink);max-width:240px} | |
| .onto .r{font:12px var(--mono)} | |
| .caret{display:inline-block;width:8px;height:1em;background:var(--ink);vertical-align:middle;animation:b .8s steps(1) infinite}@keyframes b{50%{opacity:0}} | |
| a{color:var(--maroon);font-weight:700} | |
| .err{background:var(--coral);padding:12px 16px;font-weight:700} | |
| .chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px} | |
| .chips span{border:2px solid var(--paper);border-radius:6px;padding:3px 8px;font:700 11px var(--mono);letter-spacing:.06em;text-transform:uppercase} | |
| .chips span i{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--lime);margin-right:6px;animation:pulse .9s infinite alternate} | |
| .ms{font:700 11px var(--mono);margin-left:8px;padding:1px 6px;border:2px solid var(--ink);border-radius:4px;background:var(--paper)} | |
| .sub{display:block;font:12px var(--mono);opacity:.8} | |
| .hist{display:flex;align-items:flex-end;gap:2px;height:120px;padding:12px 16px 0} | |
| .hist div{flex:1;border:2px solid var(--ink);border-bottom:none;border-radius:3px 3px 0 0;transform-origin:bottom;animation:grow .7s cubic-bezier(.2,.8,.2,1) both} | |
| @keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}} | |
| .axis{display:flex;justify-content:space-between;padding:4px 16px 12px;font:11px var(--mono);border-top:3px solid var(--ink);margin:0 16px} | |
| @media (prefers-reduced-motion:reduce){*{animation:none;transition:none}} | |
| </style> | |
| </head> | |
| <body> | |
| <nav class="top"><div class="in"> | |
| <a class="brand" href="#top"><span class="logo">Q</span><b style="font-size:20px">QAI <span style="color:var(--maroon)">Connect</span></b></a> | |
| <div class="links row" style="gap:2px"><a class="l" href="#app">Analyse</a><a class="l" href="#how">How it works</a><a class="l" href="#data">Data</a></div> | |
| <span id="auth"></span> | |
| </div></nav> | |
| <section class="hero" id="top"><div class="in"> | |
| <span class="caps" style="color:var(--sun)">Decision intelligence for energy sites</span> | |
| <h1>Know how much time is left <em>to act.</em></h1> | |
| <p>QAI Connect turns live weather, air quality and power-plant data into a Decision Reserve: the hours left before a site hits a critical limit. Qwen on Hugging Face explains it in plain English.</p> | |
| <div class="row" style="gap:14px"><a class="cta" href="#app">Analyse a location →</a><a class="cta alt" href="#how">How it works</a></div> | |
| <div class="kpis"><div><b>42,094</b><span class="caps">power plants</span></div><div><b>1,500</b><span class="caps">simulations per site</span></div><div><b>72 h</b><span class="caps">live forecast</span></div><div><b>Qwen 72B</b><span class="caps">on Hugging Face</span></div></div> | |
| </div></section> | |
| <main> | |
| <section class="feat"> | |
| <div class="box" style="background:var(--sky)"><span class="caps">01 · Pick</span><h3>Any place on the map</h3><p style="margin:0">Search or click. Everything within 60 km is analysed.</p></div> | |
| <div class="box" style="background:var(--sun)"><span class="caps">02 · Measure</span><h3>Time left to act</h3><p style="margin:0">Time to failure minus detection, approval, execution and a safety buffer.</p></div> | |
| <div class="box" style="background:var(--lime)"><span class="caps">03 · Explain</span><h3>Plain-English answer</h3><p style="margin:0">Qwen2.5-72B on Hugging Face says what binds, why, and what to do first.</p></div> | |
| </section> | |
| <h2 class="sec-title" id="app">Analyse a location</h2> | |
| <section class="box pad"> | |
| <form id="f" class="row"><label for="q" class="caps" style="position:absolute;left:-9999px">Location</label> | |
| <input id="q" value="Ras Laffan" placeholder='Place name or "lat, lon"' autocomplete="off"> | |
| <button class="main" id="go">Analyse</button></form> | |
| <div class="row" style="margin-top:10px" id="ex"></div> | |
| </section> | |
| <section class="box"><div class="hd" style="background:var(--sky)">Click the map to analyse any point <span class="caps" id="maphint">60 km radius</span></div><div id="map"></div> | |
| <div class="row" style="padding:10px 16px;font-size:13px;border-top:3px solid var(--ink)"><span class="caps">Time left</span> | |
| <span><b style="color:#3d6b12">●</b> > 3 days</span><span><b style="color:#6f8fc7">●</b> 1–3 days</span><span><b style="color:#b8953f">●</b> < 1 day</span><span><b style="color:#c4553f">●</b> out of time</span><span><b style="color:var(--maroon)">●</b> your point</span></div></section> | |
| <section class="box" id="procBox" hidden><div class="hd" style="background:var(--ink);color:var(--paper)">Backend process <span class="caps" id="procState"></span></div><div class="pad" id="proc" style="display:grid;gap:8px"></div></section> | |
| <div id="out"></div> | |
| <section class="box" id="how"><div class="hd">How it works</div> | |
| <div class="pad" style="display:grid;gap:8px"> | |
| <p style="margin:0"><b>Decision Reserve</b> = time to failure (Tf) − time to detect (Td) − time to approve (Ta) − time to execute (Te) − safety buffer (Tb).</p> | |
| <p style="margin:0">Tf comes from each plant's climate exposure, the 72-hour heat forecast against its design limit, airborne dust and coastal salt. Every term is uncertain, so the app runs 1,500 random draws and reports the median (P50) and the chance there is still time, P(DR > 0).</p> | |
| <p style="margin:0">The numbers are computed in your browser. Qwen only explains them; it never changes a figure.</p> | |
| </div></section> | |
| <footer id="data" class="caps" style="text-align:center;font-weight:400;opacity:.8;padding-top:8px">Data: Open-Meteo · CAMS · OpenStreetMap · Hugging Face dataset Inzinion/power-plant-climate-exposure-index · AI: Qwen2.5-72B-Instruct on Hugging Face Inference</footer> | |
| </main> | |
| <script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script> | |
| <script type="module"> | |
| import { oauthLoginUrl, oauthHandleRedirectIfPresent } from 'https://cdn.jsdelivr.net/npm/@huggingface/hub@0.21.0/+esm' | |
| const SYSTEM = "You are the QAI Decision Reserve analyst, the decision-intelligence engine of QAI Connect. You receive a location, live weather and air quality, and a Monte-Carlo Decision Reserve per energy site, where DR = T_f - T_d - T_a - T_e - T_s in hours and p_positive = P(DR > 0). Answer like a control-room analyst in 90-150 words of plain text with no headings. Always state (1) the binding site and its DR P50 in hours with P(DR > 0), (2) the binding driver in physical terms, (3) one concrete intervention with its gain in hours and the latest responsible time to start it. Use only numbers present in the context. TAILSTRIKE needs human approval for any action; never suggest overriding safety systems." | |
| const MODEL = 'Qwen/Qwen2.5-72B-Instruct' | |
| const DATASET = 'Inzinion/power-plant-climate-exposure-index' | |
| const EXEC = { Gas: 6, Oil: 8, Coal: 12, Solar: 2, Wind: 3, Hydro: 4, Nuclear: 24, Biomass: 8, Waste: 8, Geothermal: 10 } | |
| const DERATE = { Gas: [15, 0.0065], Oil: [15, 0.0055], Coal: [25, 0.002], Solar: [25, 0.004] } | |
| const N = 1500 | |
| const $ = (id) => document.getElementById(id) | |
| const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) | |
| const fmtH = (h) => (Math.abs(h) >= 48 ? (h / 24).toFixed(1) + ' days' : h.toFixed(1) + ' h') | |
| const pct = (p) => Math.round(p * 100) + '%' | |
| const PLACES = { | |
| 'ras laffan': { name: 'Ras Laffan Industrial City, Qatar', lat: 25.9125, lon: 51.5517 }, | |
| 'mesaieed': { name: 'Mesaieed Industrial City, Qatar', lat: 24.9909, lon: 51.5493 }, | |
| 'doha': { name: 'Doha, Qatar', lat: 25.2854, lon: 51.531 }, | |
| 'dukhan': { name: 'Dukhan, Qatar', lat: 25.4248, lon: 50.7822 }, | |
| 'al khor': { name: 'Al Khor, Qatar', lat: 25.6839, lon: 51.5058 }, | |
| 'ras abu fontas': { name: 'Ras Abu Fontas, Qatar', lat: 25.2056, lon: 51.6167 }, | |
| 'dubai': { name: 'Dubai, United Arab Emirates', lat: 25.2048, lon: 55.2708 }, | |
| 'riyadh': { name: 'Riyadh, Saudi Arabia', lat: 24.7136, lon: 46.6753 }, | |
| } | |
| const SEV = { CRITICAL: '#F2836B', ELEVATED: '#E3C477', WATCH: '#9DB8E6', NOMINAL: '#B6E36A' } | |
| /* ---------------- Hugging Face sign-in (OAuth) → token for Inference ---------------- */ | |
| let hf = null | |
| try { hf = JSON.parse(localStorage.getItem('qai-hf-oauth') || 'null') } catch {} | |
| try { | |
| const r = await oauthHandleRedirectIfPresent() | |
| if (r) { hf = { token: r.accessToken, user: r.userInfo?.name || r.userInfo?.preferred_username, exp: r.accessTokenExpiresAt }; try { localStorage.setItem('qai-hf-oauth', JSON.stringify(hf)) } catch {} } | |
| } catch (e) { console.warn('oauth', e) } | |
| if (hf?.exp && new Date(hf.exp) < new Date()) hf = null | |
| function authUI() { | |
| $('auth').innerHTML = hf | |
| ? `<span class="chip">🤗 signed in as ${esc(hf.user)}</span> <button id="out_" style="min-height:32px">Sign out</button>` | |
| : `<button id="in_">🤗 Sign in with Hugging Face for Qwen</button>` | |
| if (hf) $('out_').onclick = () => { hf = null; try { localStorage.removeItem('qai-hf-oauth') } catch {}; authUI() } | |
| else $('in_').onclick = async () => { location.href = (await oauthLoginUrl({ scopes: 'openid profile inference-api' })) + '&prompt=consent' } | |
| } | |
| authUI() | |
| /* ---------------- maths ---------------- */ | |
| let seed = 1 | |
| const rnd = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296 } | |
| const normal = () => Math.sqrt(-2 * Math.log(rnd() + 1e-12)) * Math.cos(2 * Math.PI * rnd()) | |
| const lognorm = (s) => Array.from({ length: N }, () => Math.exp(normal() * s)) | |
| const pctl = (a, q) => { const b = [...a].sort((x, y) => x - y); return b[Math.min(b.length - 1, Math.floor(q * b.length))] } | |
| const median = (a) => pctl(a, 0.5) | |
| const hav = (a, b, c, d) => { const R = 6371, t = Math.PI / 180, x = Math.sin(((c - a) * t) / 2) ** 2 + Math.cos(a * t) * Math.cos(c * t) * Math.sin(((d - b) * t) / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(x)) } | |
| const sevOf = (p50, pp) => (p50 <= 0 || pp < 0.5 ? 'CRITICAL' : p50 < 24 ? 'ELEVATED' : p50 < 72 ? 'WATCH' : 'NOMINAL') | |
| function reserve(site, peak, now, fc, pm10) { | |
| const sev = site.severity_index ?? 40, design = (site.summer_max_c ?? 36) + 10, onset = design - 8, excess = Math.max(0, peak - onset) | |
| const heat = 1 + 0.18 * excess, dust = 1 + Math.max(0, (pm10 || 0) - 50) / 120, corr = site.coast_km != null && site.coast_km < 3 ? 1.12 : 1 | |
| const tf0 = 720 * (1 - sev / 120) | |
| const cross = fc.findIndex((t) => t >= design) | |
| let tf = lognorm(0.28).map((m) => (tf0 / (heat * dust * corr)) * m) | |
| if (cross >= 0) tf = tf.map((v) => Math.min(v, Math.max(0.5, cross))) | |
| const cap = site.capacity_mw || 50, fuel = site.fuel || 'Gas' | |
| const td = lognorm(0.3).map((m) => 2 * m), ta = lognorm(0.35).map((m) => (cap >= 1000 ? 2 : 4) * m) | |
| const te = lognorm(0.3).map((m) => (EXEC[fuel] ?? 6) * (1 + cap / 4000) * m), ts = tf.map((v) => Math.max(1, 0.08 * v)) | |
| const dr = tf.map((v, i) => v - td[i] - ta[i] - te[i] - ts[i]) | |
| const p50 = median(dr), pp = dr.filter((v) => v > 0).length / N | |
| const lo = Math.min(...dr), hi = Math.max(...dr), bins = Array(28).fill(0) | |
| dr.forEach((v) => bins[Math.min(27, Math.floor(((v - lo) / (hi - lo || 1)) * 28))]++) | |
| const drivers = { heat, dust, corrosion: corr } | |
| const top = Object.entries(drivers).sort((a, b) => b[1] - a[1])[0] | |
| const der = DERATE[fuel] && now != null ? Math.max(0, (now - DERATE[fuel][0]) * DERATE[fuel][1]) * 100 : 0 | |
| return { terms: { T_f: +median(tf).toFixed(1), T_d: +median(td).toFixed(2), T_a: +median(ta).toFixed(1), T_e: +median(te).toFixed(1), T_s: +median(ts).toFixed(1) }, | |
| p10: +pctl(dr, 0.1).toFixed(1), p50: +p50.toFixed(1), p90: +pctl(dr, 0.9).toFixed(1), p_positive: +pp.toFixed(3), severity: sevOf(p50, pp), | |
| factors: drivers, binding_driver: cross >= 0 ? 'threshold' : top[1] > 1.02 ? top[0] : 'exposure', design_limit_c: +design.toFixed(1), peak_72h_c: +peak.toFixed(1), | |
| forecast_cross_hour: cross >= 0 ? cross : null, derate_pct: +der.toFixed(1), hist: { bins, lo, hi } } | |
| } | |
| function actions(s, all) { | |
| const r = s.reserve, a = [] | |
| if (r.p50 <= 0) a.push({ action: `Controlled pre-emptive de-rate of ${s.name} to 80% and dispatch spinning reserve`, gain_hours: +(r.terms.T_e + Math.abs(r.p50)).toFixed(1) }) | |
| if (r.factors.dust > 1.02) a.push({ action: `Bring forward the inlet-filter change-out at ${s.name}`, gain_hours: +(r.terms.T_f * (1 - 1 / r.factors.dust)).toFixed(1) }) | |
| const better = all.filter((x) => x !== s && x.reserve.p50 > r.p50 + 24).sort((x, y) => y.reserve.p50 - x.reserve.p50)[0] | |
| if (better) { const mw = Math.round(Math.min(s.capacity_mw, better.capacity_mw) * 0.15); if (mw > 0) a.push({ action: `Shift ${mw} MW of dispatch from ${s.name} to ${better.name}`, gain_hours: +(r.terms.T_f * 0.15).toFixed(1) }) } | |
| if (r.factors.corrosion > 1) a.push({ action: `Increase compressor wash cycle at ${s.name} (marine salt)`, gain_hours: +(r.terms.T_f * 0.08).toFixed(1) }) | |
| a.push({ action: `Pre-authorise the contingency for ${s.name} to cut approval time`, gain_hours: +(r.terms.T_a * 0.6).toFixed(1) }) | |
| return a.sort((x, y) => y.gain_hours - x.gain_hours) | |
| } | |
| /* ---------------- data sources ---------------- */ | |
| const j = async (u, ms = 15000) => { const r = await fetch(u, { signal: AbortSignal.timeout(ms) }); if (!r.ok) throw new Error(`${new URL(u).host} ${r.status}`); return r.json() } | |
| async function geocode(q) { | |
| const m = q.match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/) | |
| if (m) return { name: `${(+m[1]).toFixed(3)}, ${(+m[2]).toFixed(3)}`, lat: +m[1], lon: +m[2] } | |
| const known = PLACES[q.trim().toLowerCase()] | |
| if (known) return known | |
| const d = await j(`https://geocoding-api.open-meteo.com/v1/search?count=1&name=${encodeURIComponent(q)}`).catch(() => ({})) | |
| if (d.results?.length) { const r = d.results[0]; return { name: [r.name, r.country].filter(Boolean).join(', '), lat: r.latitude, lon: r.longitude } } | |
| const o = await j(`https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(q)}`) | |
| if (!o.length) throw new Error(`could not find "${q}"`) | |
| return { name: o[0].display_name.split(',').slice(0, 2).join(','), lat: +o[0].lat, lon: +o[0].lon } | |
| } | |
| async function conditions(lat, lon) { | |
| const [w, a] = await Promise.all([ | |
| j(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=temperature_2m¤t=temperature_2m&forecast_days=3&timezone=UTC`), | |
| j(`https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}¤t=pm10,dust,nitrogen_dioxide`), | |
| ]) | |
| return { temp_now_c: w.current?.temperature_2m ?? null, forecast: (w.hourly?.temperature_2m || []).slice(0, 72), pm10: a.current?.pm10 ?? null, dust: a.current?.dust ?? null, no2: a.current?.nitrogen_dioxide ?? null } | |
| } | |
| let plantSource = '' | |
| let gulf = null | |
| async function plants(lat, lon, km) { | |
| const dl = km / 111, dn = km / (111 * Math.cos((lat * Math.PI) / 180)) | |
| const near = (rows) => rows.map((p) => ({ ...p, distance_km: hav(lat, lon, p.latitude, p.longitude) })).filter((p) => p.distance_km <= km).sort((a, b) => a.distance_km - b.distance_km).slice(0, 30) | |
| // 1) live query on the Hugging Face dataset viewer API | |
| try { | |
| const where = `"latitude">${(lat - dl).toFixed(4)} AND "latitude"<${(lat + dl).toFixed(4)} AND "longitude">${(lon - dn).toFixed(4)} AND "longitude"<${(lon + dn).toFixed(4)}` | |
| const d = await j(`https://datasets-server.huggingface.co/filter?dataset=${encodeURIComponent(DATASET)}&config=default&split=train&where=${encodeURIComponent(where)}&length=100`, 6000) | |
| plantSource = 'live · Hugging Face dataset viewer API' | |
| return near(d.rows.map((r) => r.row)) | |
| } catch { | |
| // 2) the same dataset's Gulf rows, bundled with this Space (the viewer API is sometimes busy) | |
| gulf = gulf || (await j('./plants_gulf.json')) | |
| plantSource = 'bundled snapshot of the Hugging Face dataset (Gulf region)' | |
| return near(gulf) | |
| } | |
| } | |
| /* ---------------- Qwen on Hugging Face Inference ---------------- */ | |
| function context(loc, cond, sites, summary) { | |
| return { location: loc.name, radius_km: 60, temp_now_c: cond.temp_now_c, forecast_max_72h_c: cond.forecast.length ? Math.max(...cond.forecast) : null, pm10: cond.pm10, dust: cond.dust, | |
| sites: summary.sites, capacity_mw: summary.capacity_mw, capacity_at_risk_mw: summary.at_risk, region_dr_p50_hours: summary.dr, region_p_positive: summary.pp, counts: summary.counts, | |
| binding: sites.slice(0, 3).map((x) => ({ name: x.name, fuel: x.fuel, capacity_mw: x.capacity_mw, dr_p50_h: x.reserve.p50, latest_start_h: +Math.max(0, x.reserve.p50 - x.reserve.terms.T_e).toFixed(1), | |
| p_positive: x.reserve.p_positive, severity: x.reserve.severity, driver: x.reserve.binding_driver, design_limit_c: x.reserve.design_limit_c, forecast_cross_hour: x.reserve.forecast_cross_hour, | |
| terms: x.reserve.terms, derate_pct: x.reserve.derate_pct, top_action: x.actions[0] })) } | |
| } | |
| function fallback(c) { | |
| const b = c.binding[0] | |
| if (!b) return `${c.location}: no power plants in the dataset within ${c.radius_km} km.` | |
| return `${c.location}: ${c.sites} sites, ${Math.round(c.capacity_mw)} MW within ${c.radius_km} km. Binding site is ${b.name} (${b.fuel}, ${b.capacity_mw} MW) with a Decision Reserve P50 of ${b.dr_p50_h} h and P(DR > 0) of ${pct(b.p_positive)}. Its constraint is ${b.driver}. Recommended: ${b.top_action.action}, worth about +${b.top_action.gain_hours} h. Latest responsible start is in ${b.latest_start_h} h.` | |
| } | |
| async function qwen(ctx, onText) { | |
| const t0 = performance.now() | |
| if (!hf) return { text: fallback(ctx), model: 'built-in summary · sign in with Hugging Face to use Qwen', seconds: 0, live: false } | |
| let r | |
| try { | |
| r = await fetch('https://router.huggingface.co/v1/chat/completions', { | |
| method: 'POST', headers: { Authorization: `Bearer ${hf.token}`, 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ model: MODEL, stream: true, max_tokens: 300, temperature: 0.3, messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: 'Location context (JSON):\n' + JSON.stringify(ctx) + '\n\nRespond in plain English.' }] }), | |
| signal: AbortSignal.timeout(120000), | |
| }) | |
| } catch (e) { | |
| return { text: fallback(ctx), model: `Qwen did not answer (${e.name}); built-in summary shown`, seconds: 0, live: false } | |
| } | |
| if (r.status === 401 || r.status === 403) { | |
| hf = null; try { localStorage.removeItem('qai-hf-oauth') } catch {}; authUI() | |
| return { text: fallback(ctx), model: 'Hugging Face sign-in expired · sign in again to use Qwen', seconds: 0, live: false } | |
| } | |
| if (!r.ok || !r.body) return { text: fallback(ctx), model: `Qwen unavailable (HTTP ${r.status}); built-in summary shown`, seconds: 0, live: false } | |
| // Server-sent events: append each token as it arrives | |
| const reader = r.body.getReader(), dec = new TextDecoder() | |
| let buf = '', text = '' | |
| for (;;) { | |
| const { done, value } = await reader.read() | |
| if (done) break | |
| buf += dec.decode(value, { stream: true }) | |
| const lines = buf.split('\n'); buf = lines.pop() | |
| for (const line of lines) { | |
| if (!line.startsWith('data:')) continue | |
| const data = line.slice(5).trim() | |
| if (data === '[DONE]') continue | |
| try { const delta = JSON.parse(data).choices?.[0]?.delta?.content; if (delta) { text += delta; onText(text) } } catch {} | |
| } | |
| } | |
| return { text: text.trim() || fallback(ctx), model: `${MODEL} · Hugging Face Inference (signed in as ${hf?.user})`, seconds: (performance.now() - t0) / 1000, live: !!text.trim() } | |
| } | |
| /* ---------------- map ---------------- */ | |
| const map = L.map('map').setView([25.3, 51.3], 8) | |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(map) | |
| const layer = L.layerGroup().addTo(map) | |
| map.on('click', (e) => { if (!busy) run(`${e.latlng.lat.toFixed(4)}, ${e.latlng.lng.toFixed(4)}`) }) | |
| /* ---------------- pipeline UI ---------------- */ | |
| const STEPS = [ | |
| ['Geospatial resolution', 'place → WGS84 coordinates · 60 km analysis radius'], | |
| ['Environmental telemetry ingest', 'Open-Meteo 72 h forecast · CAMS PM10 / dust / NO₂'], | |
| ['Asset graph query', 'Hugging Face dataset · haversine radius filter · exposure attributes'], | |
| ['Stochastic reserve estimation', 'log-normal term sampling · 1,500 paths · P10 / P50 / P90 · P(DR > 0)'], | |
| ['LLM synthesis', 'Qwen2.5-72B-Instruct · Hugging Face Inference · streamed, numbers grounded in context'], | |
| ] | |
| const t0s = {} | |
| function stepUI(k, state, data) { | |
| const el = $('st' + k) | |
| el.className = 'step ' + state | |
| if (state === 'run') t0s[k] = performance.now() | |
| if (state === 'done' && t0s[k] != null) { const m = $('ms' + k); m.hidden = false; m.textContent = `${Math.round(performance.now() - t0s[k])} ms` } | |
| if (data !== undefined) el.querySelector('.p').innerHTML = `<pre>${esc(JSON.stringify(data, null, 2))}</pre>` | |
| } | |
| let busy = false | |
| async function run(q) { | |
| if (busy || !q.trim()) return | |
| busy = true; $('go').disabled = true; document.querySelectorAll('#ex button').forEach((b) => (b.disabled = true)); $('q').value = q; $('out').innerHTML = '' | |
| $('procBox').hidden = false; $('procState').textContent = 'analysing…' | |
| $('proc').innerHTML = STEPS.map(([s, d], k) => `<div class="step" id="st${k}"><span class="n">${k + 1}</span><div><b>${esc(s)}</b><span class="ms" id="ms${k}" hidden></span><span class="sub">${esc(d)}</span><div class="p"></div></div></div>`).join('') | |
| try { | |
| stepUI(0, 'run'); const loc = await geocode(q); stepUI(0, 'done', loc) | |
| seed = Math.floor(Math.abs(loc.lat * 1000) + Math.abs(loc.lon * 1000)) || 1 | |
| layer.clearLayers(); map.flyTo([loc.lat, loc.lon], 9, { duration: 0.8 }) | |
| L.circle([loc.lat, loc.lon], { radius: 60000, color: '#1E2A44', weight: 2, dashArray: '6 6', fillOpacity: 0.04 }).addTo(layer) | |
| L.circleMarker([loc.lat, loc.lon], { radius: 8, color: '#1E2A44', weight: 3, fillColor: '#7A1D3C', fillOpacity: 1 }).addTo(layer) | |
| stepUI(1, 'run'); const cond = await conditions(loc.lat, loc.lon); stepUI(1, 'done', { temp_now_c: cond.temp_now_c, peak_72h_c: Math.max(...cond.forecast), pm10: cond.pm10, dust: cond.dust }) | |
| stepUI(2, 'run'); const ps = await plants(loc.lat, loc.lon, 60); stepUI(2, 'done', { dataset: DATASET, via: plantSource, sites: ps.length, nearest: ps.slice(0, 3).map((p) => p.name) }) | |
| stepUI(3, 'run') | |
| const peak = cond.forecast.length ? Math.max(...cond.forecast) : cond.temp_now_c ?? 35 | |
| const sites = ps.map((p) => ({ id: p.plant_id, name: p.name, fuel: p.fuel, capacity_mw: p.capacity_mw, lat: p.latitude, lon: p.longitude, reserve: reserve(p, peak, cond.temp_now_c, cond.forecast, cond.pm10) })) | |
| sites.forEach((s) => (s.actions = actions(s, sites))) | |
| sites.sort((a, b) => a.reserve.p50 - b.reserve.p50) | |
| const cap = sites.reduce((a, s) => a + s.capacity_mw, 0) | |
| const summary = { sites: sites.length, capacity_mw: Math.round(cap), at_risk: Math.round(sites.filter((s) => s.reserve.severity !== 'NOMINAL').reduce((a, s) => a + s.capacity_mw, 0)), | |
| dr: sites.length ? +(sites.reduce((a, s) => a + s.reserve.p50 * s.capacity_mw, 0) / cap).toFixed(1) : 0, pp: sites.length ? +(sites.reduce((a, s) => a + s.reserve.p_positive * s.capacity_mw, 0) / cap).toFixed(3) : 0, | |
| counts: sites.reduce((c, s) => ((c[s.reserve.severity] = (c[s.reserve.severity] || 0) + 1), c), {}) } | |
| sites.forEach((s) => L.circleMarker([s.lat, s.lon], { radius: 6 + Math.min(10, s.capacity_mw / 400), color: '#1E2A44', weight: 2, fillColor: SEV[s.reserve.severity], fillOpacity: 0.95 }).bindTooltip(`${s.name} · ${Math.round(s.capacity_mw)} MW · ${Math.round(s.reserve.p50)} h left`).addTo(layer)) | |
| const b = sites[0] | |
| stepUI(3, 'done', b ? { binding_site: b.name, terms_h: b.reserve.terms, dr_p50_h: b.reserve.p50, p_dr_positive: b.reserve.p_positive, driver: b.reserve.binding_driver } : { sites: 0 }) | |
| render(loc, cond, sites, summary, null) | |
| stepUI(4, 'run') | |
| const ctx = context(loc, cond, sites, summary) | |
| if (!hf) $('qt').innerHTML = 'Signed out: showing the built-in summary. <button type="button" id="in2">🤗 Sign in with Hugging Face to use Qwen</button>' | |
| const in2 = $('in2'); if (in2) in2.onclick = () => $('in_')?.click() | |
| const ans = await qwen(ctx, (t) => { $('qt').innerHTML = esc(t) + ' <span class="caret"></span>' }) | |
| stepUI(4, 'done', { model: ans.model, seconds: +ans.seconds.toFixed(1), live: ans.live }) | |
| render(loc, cond, sites, summary, ans, true) | |
| $('procState').textContent = 'done' | |
| } catch (e) { | |
| $('out').innerHTML = `<div class="box err">Could not analyse: ${esc(e.message)}</div>`; $('procState').textContent = 'error' | |
| } finally { busy = false; $('go').disabled = false; document.querySelectorAll('#ex button').forEach((b) => (b.disabled = false)) } | |
| } | |
| function render(loc, cond, sites, sm, ans, streamed = false) { | |
| const b = sites[0] | |
| if (!b) { $('out').innerHTML = `<div class="box pad">No power plants from the dataset within 60 km of ${esc(loc.name)}. Try Ras Laffan, Doha or Dubai.</div>`; return } | |
| const t = b.reserve.terms | |
| const rows = [['Tf', `Time to failure (${b.reserve.binding_driver})`, t.T_f, ''], ['Td', 'Detect', t.T_d, '−'], ['Ta', 'Approve', t.T_a, '−'], ['Te', 'Execute', t.T_e, '−'], ['Tb', 'Buffer', t.T_s, '−']] | |
| const onto = [['Location', loc.name, 'var(--sky)'], ['Conditions', `${cond.temp_now_c ?? '—'} °C · PM10 ${cond.pm10 ?? '—'}`, 'var(--paper)'], ['Energy sites', `${sm.sites} sites · ${sm.capacity_mw.toLocaleString()} MW`, 'var(--sun)'], | |
| ['Binding asset', b.name, 'var(--coral)'], ['Constraint', `${b.reserve.binding_driver} · Tf ${fmtH(t.T_f)}`, 'var(--paper)'], ['Decision Reserve', `${fmtH(b.reserve.p50)} · ${pct(b.reserve.p_positive)}`, 'var(--lime)'], | |
| ['Intervention', b.actions[0].action, 'var(--paper)'], ['Statement', ans ? (ans.live ? 'Qwen · Hugging Face' : 'built-in') : 'pending…', 'var(--lilac)']] | |
| const rel = ['observed at', 'stress', 'located in', 'limited by', 'sets Tf for', 'protected by', 'explained by'] | |
| $('out').innerHTML = ` | |
| <div style="display:grid;gap:18px"> | |
| <section class="grid4">${[['Time left to act', fmtH(sm.dr), 'var(--lime)'], ['Chance there is still time', pct(sm.pp), 'var(--sky)'], ['Energy sites', sm.sites, 'var(--paper)'], ['Capacity', sm.capacity_mw.toLocaleString() + ' MW', 'var(--sun)']] | |
| .map(([l, v, c]) => `<div class="box stat" style="background:${c}"><span class="caps">${l}</span><b>${v}</b></div>`).join('')}</section> | |
| <section class="box"><div class="hd" style="background:var(--lilac)">Qwen's assessment <span class="chip">🤗 Hugging Face</span></div> | |
| <div class="pad"><p id="qt" style="margin:0;font-size:18px">${ans ? '' : 'Qwen is reading the numbers… <span class="caret"></span>'}</p> | |
| ${ans ? `<p class="mono" style="font-size:12px;margin:10px 0 0">${esc(ans.model)}${ans.live ? ` · ${ans.seconds.toFixed(1)} s` : ''} · <a href="https://huggingface.co/${MODEL}" target="_blank" rel="noopener">model</a> · <a href="https://huggingface.co/datasets/${DATASET}" target="_blank" rel="noopener">dataset</a></p>` : ''}</div></section> | |
| <section class="box"><div class="hd" style="background:${SEV[b.reserve.severity]}">${esc(b.name)} <span class="caps">tightest site · ${b.reserve.severity}</span></div> | |
| <div class="scroll"><table><tbody>${rows.map((r) => `<tr><td class="mono"><b>${r[0]}</b></td><td>${r[1]}</td><td class="mono" style="text-align:right">${r[3]}${fmtH(r[2])}</td></tr>`).join('')} | |
| <tr style="background:var(--lime);font-weight:700"><td class="mono">DR</td><td>Decision Reserve · time left to act</td><td class="mono" style="text-align:right">${fmtH(b.reserve.p50)}</td></tr></tbody></table></div></section> | |
| <section class="box"><div class="hd">Reserve distribution · ${esc(b.name)} <span class="caps">1,500 Monte Carlo paths</span></div> | |
| <div class="hist">${b.reserve.hist.bins.map((c, i) => { const x = b.reserve.hist.lo + ((i + 0.5) / 28) * (b.reserve.hist.hi - b.reserve.hist.lo); return `<div title="${Math.round(x)} h · ${c} paths" style="height:${(c / Math.max(...b.reserve.hist.bins)) * 100}%;background:${x <= 0 ? 'var(--coral)' : x < b.reserve.p50 ? 'var(--sun)' : 'var(--lime)'};animation-delay:${i * 18}ms"></div>` }).join('')}</div> | |
| <div class="axis"><span>${fmtH(b.reserve.hist.lo)}</span><span>P10 ${fmtH(b.reserve.p10)} · <b>P50 ${fmtH(b.reserve.p50)}</b> · P90 ${fmtH(b.reserve.p90)}</span><span>${fmtH(b.reserve.hist.hi)}</span></div></section> | |
| <section class="box"><div class="hd">Ontology · how this answer is built <span class="caps">entities → relations</span></div> | |
| <div class="onto">${onto.map(([ty, v, c], i) => `<div class="e" style="background:${c}"><div class="caps">${ty}</div><b style="font-size:14px">${esc(v)}</b></div>${i < rel.length ? `<span class="r">—${rel[i]}→</span>` : ''}`).join('')}</div></section> | |
| <section class="box"><div class="hd">All sites within 60 km</div><div class="scroll"><table><thead><tr><th>Site</th><th>Fuel</th><th>MW</th><th>Driver</th><th>Time left</th><th>Chance</th></tr></thead><tbody> | |
| ${sites.map((s) => `<tr><td>${esc(s.name)}</td><td>${esc(s.fuel)}</td><td class="mono">${Math.round(s.capacity_mw)}</td><td>${esc(s.reserve.binding_driver)}</td><td class="mono">${fmtH(s.reserve.p50)}</td><td class="mono">${pct(s.reserve.p_positive)}</td></tr>`).join('')}</tbody></table></div></section> | |
| </div>` | |
| if (ans && streamed && ans.live) { $('qt').textContent = ans.text } | |
| else if (ans) { const w = ans.text.split(' '); let i = 0; const tick = () => { i = Math.min(w.length, i + 3); $('qt').innerHTML = esc(w.slice(0, i).join(' ')) + (i < w.length ? ' <span class="caret"></span>' : ''); if (i < w.length) setTimeout(tick, 45) }; tick() } | |
| } | |
| $('ex').innerHTML = ['Ras Laffan', 'Mesaieed', 'Doha', 'Dukhan', 'Dubai', 'Riyadh'].map((x) => `<button type="button">${x}</button>`).join('') | |
| $('ex').onclick = (e) => { const b = e.target.closest('button'); if (b && !busy) run(b.textContent) } | |
| $('f').onsubmit = (e) => { e.preventDefault(); run($('q').value) } | |
| </script> | |
| </body> | |
| </html> | |