// A grid lab in your browser (v2): the page. One live what-if, opening on a real heat-wave day: // a real day, a hurricane's damage draw or heat domes (weather layers in JS at once; GENCO, the repair, the // check and the exact AC-OPF in the engine worker), shown as a step rail from prediction to a certified point. Below, live // studies: a heat wave, a dome scan, a hurricane's Monte Carlo. import { loadGrid, scenarioArrays, rangeFlags, connected } from "./weather.js"; import { sampleDamage, damageSeed } from "./storm_damage.js"; import { Engine, validateResult, validateExact } from "./engine_api.js"; import { GridMap, LOAD_STOPS, DOT_STOPS, BOTT_STOPS, GEN_FILL, cssRamp, OVER, BAND_LIMIT, shortBranches, rgb, ramp, ARROW, SERIOUS } from "./map_v2.js"; import { $, el, segmented, slider, toggle, picker, countTo, info, REDUCED } from "./controls_v2.js"; const money = (x) => (x == null || !Number.isFinite(x) ? "n/a" : "$" + Math.round(x).toLocaleString("en-US")); const secs = (ms) => (ms == null || !Number.isFinite(ms) ? "—" : `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)} s`); const maxOf = (a) => { let m = -Infinity; if (a) for (const v of a) if (Number.isFinite(v) && v > m) m = v; return Number.isFinite(m) ? m : null; }; const keyOf = (k) => JSON.stringify(k); // the gate tile's badge: green and red only for a final verdict (certified, or no dispatch found: a flag, not a proof); // grey while the point is not certified yet const badge = (cls, text) => `${text}`; const LAT_RANGE = [25.8, 36.6], LON_RANGE = [-106.7, -93.4]; const CUSTOM = "Custom position"; const VIEWS = [["Line loading", "loading"], ["Temperature ΔT", "dT"], ["Damaged zone", "load"], ["Capacity", "capacity"]]; // the pipeline's steps: name, sub-title, one-line explanation, the longer text of "How it works" const STEPS = { genco: { name: "Predict", sub: "GENCO", ex: "GENCO predicts every voltage and dispatch in one pass.", long: "GENCO, a graph neural network, reads the what-if (loads, line ratings, available capacity, prices) and predicts every bus voltage and every generator's output in one pass. Fast, but a prediction: power does not quite balance." }, repair: { name: "Repair", sub: "AC power flow", ex: "Solves the AC equations: power balances at every bus; limits may still be exceeded.", long: "We keep GENCO's dispatch and solve the AC power-flow equations, with each generator's reactive limits, so power balances exactly at every bus. The point is now physical; some lines may still exceed their rating." }, check: { name: "Certify", sub: "AC check", ex: "Steps inside every limit, then verifies the point with a full AC power flow.", long: "If the repaired point breaks a limit, a few quick linearised steps, each a small linear program for the smallest change of dispatch and voltage set-points, move it inside every limit, and a full AC power flow verifies the result against every limit. A certified point is typically 0.3-0.7% above the optimum (the median, by test set). If the steps cannot get there, the next step takes over. It runs by itself after every change." }, exact: { name: "Exact step", sub: "if needed", ex: "Only when the check cannot certify: the exact AC-OPF, started from GENCO's prediction.", get long() { return `Only when the check cannot certify a point, the page solves the exact AC-OPF (an interior-point solver) starting from GENCO's prediction, with a ${CASCADE.limit_s} s limit. If it converges and its optimum passes the same full AC check, that optimum is the answer. If not, the page says "no dispatch found", a flag and not a proof: the grid may have none, or the solver missed it. It runs by itself.`; } }, }; const ORDER = ["genco", "repair", "check", "exact"]; // the cascade's last step (ENGINE_CONTRACT.md exact()): MIPS from GENCO's raw prediction, when the check does not certify; // the limit comes from WORK/D31/cascade_realdays.json and casadi_bench.json (MIPS from GENCO, browser about 1.45x native) const CASCADE = { start: "genco", limit_s: 30 }; const ST = { G: null, app: null, engine: null, ready: false, mock: false, view: "loading", showGen: false, key: null, res: {}, state: {}, sel: "repair", busy: false, queued: false, t0: {}, phase: {}, timer: null, map: null }; const C = { domes: [] }; // a control-less value: the per-area wind and solar the real days carry (set by a day or a hurricane, read by knobs()) const cell = (v = 0) => ({ get: () => v, set: (x) => { v = x; } }); // untrusted text (an engine error) into HTML const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); // ================================================================ controls // The "i" text of each slider (slider()'s `info` option): what it is, its unit and reference, how it reaches GENCO's inputs. // Facts: whatif/scenario.py (weather.js mirrors it) for the knobs, scripts/weather_real.py for the real days. const TIPS = { load: (gw) => `Total demand as a share of the case's 2016 summer peak (${gw.toFixed(1)} GW). On a real day: ERCOT's demand at the day's peak hour over that summer's peak. Under a dome it also rises 2.5% per °C.`, windAll: "Share of wind capacity available: it multiplies the maximum output of every wind unit. On a real day: a generic power curve (cut-in 3, rated 12, cut-out 25 m/s) at the peak hour's 100 m ERA5 wind, averaged over the areas.", solarAll: "Share of solar capacity available: it multiplies the maximum output of every solar unit. On a real day: ERA5 shortwave radiation at the peak hour over 1,000 W/m² (at most 1), averaged over the areas.", gas: "Cost of every gas unit as a multiple of the case's own: GENCO sees it in their cost coefficients. On a real day: the day's Henry Hub price over the 2016 mean of 2.516 $/MMBtu (EIA).", coal: "Cost of every coal unit as a multiple of the case's own: GENCO sees it in their cost coefficients. Real days keep ×1: there is no daily coal price.", day: (n) => `Steps through the heat wave's ${n} days. Each day sets load, wind, sun and gas price to their real values at that day's ERCOT peak hour, and the heat to that day's ERA5 anomaly.`, amp: "Temperature rise above normal at the dome's centre, in °C (capped at 10). How it reaches GENCO's inputs is in How it works.", rad: "How far the dome reaches, in km. At distance d from the centre the heat is the peak × exp(−d²/2R²): 61% of it at d = R, 14% at d = 2R.", lat: "Latitude of the dome's centre, in °N. Each bus's ΔT, and so the grid data GENCO sees, depends on its distance from this point.", lon: "Longitude of the dome's centre, in °E (negative is west). Each bus's ΔT, and so the grid data GENCO sees, depends on its distance from this point.", }; function buildControls() { const G = ST.G, P = G.places, def = G.defaultKnobs; const placeOf = (lat, lon) => Object.entries(P).find(([, [a, b]]) => Math.abs(lat - a) <= 0.02 && Math.abs(lon - b) <= 0.02)?.[0] || CUSTOM; for (let j = 0; j < 3; j++) { const on0 = j < def.domes.length; const [la, lo, rad, amp] = on0 ? def.domes[j] : [...P[G.offPlaces[j]], 300, 4]; const box = el("div", { className: "dome" }); const d = {}; d.sw = toggle(box, { label: `Dome ${j + 1}`, checked: on0, id: `dome-on-${j}`, onChange: (v) => { d.body.hidden = !v; changed(); } }); d.body = el("div", { className: "dome-body", hidden: !on0 }); const f = el("div", { className: "field" }); f.append(el("label", { className: "flabel", htmlFor: `dome-place-${j}`, textContent: "Centre" })); d.place = el("select", { className: "select", id: `dome-place-${j}` }); for (const n of [...Object.keys(P), CUSTOM]) d.place.append(el("option", { value: n, textContent: n })); d.place.value = placeOf(la, lo); f.append(d.place); d.body.append(f); d.amp = slider(d.body, { label: "Peak ΔT", info: TIPS.amp, unit: "°C", min: 0, max: 10, step: 0.1, value: amp, id: `dome-amp-${j}`, onInput: live, onChange: changed }); d.rad = slider(d.body, { label: "Radius", info: TIPS.rad, unit: "km", min: 50, max: 800, step: 10, value: rad, id: `dome-rad-${j}`, onInput: live, onChange: changed }); const adj = el("details", { className: "more" }, "Adjust position"); const sync = () => { d.place.value = placeOf(d.lat.get(), d.lon.get()); }; d.lat = slider(adj, { label: "Latitude", info: TIPS.lat, unit: "°", min: LAT_RANGE[0], max: LAT_RANGE[1], step: 0.01, value: la, id: `dome-lat-${j}`, onInput: () => { sync(); live(); }, onChange: changed }); d.lon = slider(adj, { label: "Longitude", info: TIPS.lon, unit: "°", min: LON_RANGE[0], max: LON_RANGE[1], step: 0.01, value: lo, id: `dome-lon-${j}`, onInput: () => { sync(); live(); }, onChange: changed }); d.body.append(adj); box.append(d.body); d.place.addEventListener("change", () => { if (P[d.place.value]) { d.lat.set(P[d.place.value][0]); d.lon.set(P[d.place.value][1]); changed(); } }); $("#domes").append(box); C.domes.push(d); } // the weather: a real heat-wave day (ERA5 field, the page's landing state), a hurricane's damage draw, or up to three // synthetic heat domes C.src = "hist"; C.srcSeg = segmented($("#heat-src"), [["Heat wave", "hist"], ["Hurricane", "storm"], ["Heat domes", "domes"]], "hist", (v) => setHeatSource(v), { label: "Weather", id: "seg-heat-src" }); C.srcSeg.el.classList.add("full"); const dem = $("#demand"); C.r = slider(dem, { label: "Load level", info: TIPS.load(G.bus.Pd.reduce((p, q) => p + q, 0) / 1000), unit: "× peak", min: 0.5, max: 1.1, step: 0.01, value: def.r, id: "s-r", onInput: live, onChange: changed }); // wind and sun per area (the case's 8 weather zones) are data the real days and the hurricanes carry, not controls: one // wind slider and one sun slider move all areas together C.wind = G.areas.map((a, i) => cell(def.wind_area[i])); C.solar = G.areas.map((a, i) => cell(def.solar_area[i])); C.windAll = slider(dem, { label: "Wind availability", info: TIPS.windAll, min: 0, max: 1, step: 0.01, value: def.wind_area[0], id: "s-wind", onInput: (v) => { C.wind.forEach((s) => s.set(v)); live(); }, onChange: changed }); C.solarAll = slider(dem, { label: "Solar availability", info: TIPS.solarAll, min: 0, max: 1, step: 0.01, value: def.solar_area[0], id: "s-solar", onInput: (v) => { C.solar.forEach((s) => s.set(v)); live(); }, onChange: changed }); C.gas = slider($("#prices"), { label: "Gas price", info: TIPS.gas, unit: "×", min: 0.25, max: 6, step: 0.05, value: 1, id: "s-gas", onInput: live, onChange: changed }); C.coal = slider($("#prices"), { label: "Coal price", info: TIPS.coal, unit: "×", min: 0.5, max: 2, step: 0.05, value: 1, id: "s-coal", onInput: live, onChange: changed }); const B = G.bus, R = G.branch, U = G.gen, cand = new Set(G.sets.outage_candidates), fuel = { ng: "gas", unknown: "other" }; const brOpts = []; for (let j = 0; j < G.nBranch; j++) if (R.status[j] === 1) brOpts.push([`Line ${j}: bus ${B.number[R.from[j]]}-${B.number[R.to[j]]}, ${B.kv[R.from[j]].toFixed(0)}/${B.kv[R.to[j]].toFixed(0)} kV, ${R.rate[j].toFixed(0)} MVA`, j]); const genOpts = []; for (let g = 0; g < G.nGen; g++) if (U.status[g] === 1) genOpts.push([`Unit ${g}: ${fuel[U.fuel[g]] || U.fuel[g]} ${U.Pmax[g].toFixed(0)} MW, bus ${B.number[U.bus[g]]}${cand.has(g) ? "" : " (not in training)"}`, g]); C.brs = picker($("#outages"), { label: "Lines out", info: "The network must stay connected.", options: brOpts, onChange: changed, id: "p-lines" }); C.gens = picker($("#outages"), { label: "Units out", info: "Training only took gas and coal units out.", options: genOpts, onChange: changed, id: "p-units" }); } // ---------------------------------------------------------------- real weather // assets/data/weather_real.json (scripts/weather_real.py): real Texas heat-wave days. Per day: the temperature anomaly // field (ERA5, against the July-August 2012-2021 normal), ERCOT's load at the day's peak hour as a share of that // summer's peak (EIA-930), wind and sun per area at that hour (ERA5), and the Henry Hub gas price against 2016 (EIA). // Choosing a day sets the sliders to it; the heat then acts on ratings and thermal capacity only (heat_load_wind: // the load and the wind are real and already include it). // The page opens on LANDING_EVENT's ERCOT peak day (the event's peak_day, as for any event); event null = that one. const LANDING_EVENT = "June 2023"; const WX = { data: null, event: null, day: 0 }; async function wxData() { if (!WX.data) WX.data = await fetch("assets/data/weather_real.json").then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); return WX.data; } function wxField() { const d = WX.data; if (!d || C.src !== "hist") return []; const dT = d.events[WX.event].dT[WX.day]; return d.points.map((p, j) => [p[0], p[1], +dT[j].toFixed(2)]); } const fmtDay = (iso) => new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); function applyDay() { // the sliders take the chosen day's real values const x = WX.data.events[WX.event].data[WX.day]; C.r.set(x.r); x.wind_area.forEach((v, i) => C.wind[i].set(v)); x.solar_area.forEach((v, i) => C.solar[i].set(v)); C.windAll.set(+(x.wind_area.reduce((a, b) => a + b, 0) / x.wind_area.length).toFixed(2)); C.solarAll.set(+(x.solar_area.reduce((a, b) => a + b, 0) / x.solar_area.length).toFixed(2)); C.gas.set(Math.min(6, Math.max(0.25, x.gas_m))); C.coal.set(1); } function dayNote() { // what the sliders do not show: the day's ERCOT demand and its heat const ev = WX.data.events[WX.event], x = ev.data[WX.day], dT = ev.dT[WX.day]; let mx = 0, mean = 0; for (const v of dT) { mx = Math.max(mx, v); mean += v / dT.length; } return `ERCOT ${x.ercot_gw.toFixed(1)} GW at ${x.peak_hour} (${Math.round(100 * x.r)}% of the ${ev.days[0].slice(0, 4)} summer peak) · heat +${mean.toFixed(1)} °C on average, up to +${mx.toFixed(1)} °C`; } // at: {event, day} opens that real day (the insights card), else the event's ERCOT peak day; {storm, shift, scenario} // opens that hurricane draw (a study's strip) async function setHeatSource(v, at = null) { const was = C.src; C.src = v; $("#domes").hidden = v !== "domes"; $("#storm").hidden = $("#storm-damage").hidden = v !== "storm"; sourceViews(v); const box = $("#realwx"); box.hidden = v !== "hist"; box.innerHTML = ""; if (v === "domes") { if (was !== "domes") resetDemand(); changed(); return; } if (v === "storm") { if ((await stormPanel(at, was === "storm" && !at)) && C.src === "storm") changed(); return; } const status = el("p", { className: "note", textContent: "Loading the day's data…" }); box.append(status); let d; try { d = await wxData(); } catch (e) { status.textContent = `Could not load the data (${e.message}).`; return; } if (C.src !== v) return; status.remove(); if (at && at.event && d.events.some((e) => e.name === at.event)) WX.event = d.events.findIndex((e) => e.name === at.event); if (WX.event == null) WX.event = Math.max(0, d.events.findIndex((e) => e.name === LANDING_EVENT)); let want = at && at.day; // a requested day, used by the first slider only const f = el("div", { className: "field" }); f.append(el("label", { className: "flabel", htmlFor: "wx-event", textContent: "Heat wave" })); const sel = el("select", { className: "select", id: "wx-event" }); d.events.forEach((e, i) => sel.append(el("option", { value: String(i), textContent: e.name }))); sel.value = String(WX.event); f.append(sel); box.append(f); const ev = () => d.events[WX.event]; const date = el("p", { className: "wx-date" }), note = el("p", { className: "note" }); let day; const show = () => { date.textContent = fmtDay(ev().days[WX.day]) + (ev().days[WX.day] === ev().peak_day ? " · ERCOT peak" : ""); note.textContent = dayNote(); }; const mk = () => { const i = want ? ev().days.indexOf(want) : -1; want = null; WX.day = i >= 0 ? i : ev().days.indexOf(ev().peak_day); if (day) day.el.remove(); day = slider(box, { label: "Day", info: TIPS.day(ev().days.length), min: 0, max: ev().days.length - 1, step: 1, value: WX.day, id: "wx-day", onInput: (i) => { WX.day = i; applyDay(); show(); live(); }, onChange: changed }); day.num.hidden = true; box.append(date, note); applyDay(); show(); }; sel.addEventListener("change", () => { WX.event = +sel.value; mk(); changed(); }); mk(); changed(); } function resetDemand() { const def = ST.G.defaultKnobs; C.r.set(def.r); C.windAll.set(def.wind_area[0]); C.solarAll.set(def.solar_area[0]); C.wind.forEach((s, i) => s.set(def.wind_area[i])); C.solar.forEach((s, i) => s.set(def.solar_area[i])); C.gas.set(1); C.coal.set(1); } // a hurricane draw adds its damage to the sliders: its lines and units out, the damaged zone, the generator's noise seed function knobs() { const d = C.src === "storm" ? HZ.draw : null, both = (a, b) => [...new Set([...a, ...b])].sort((p, q) => p - q); return { domes: C.src !== "domes" ? [] : C.domes.filter((x) => x.sw.get()).map((x) => [x.lat.get(), x.lon.get(), x.rad.get(), x.amp.get()]), weather: wxField(), heat_load_wind: C.src !== "hist", r: C.r.get(), wind_area: C.wind.map((s) => s.get()), solar_area: C.solar.map((s) => s.get()), gas_m: C.gas.get(), coal_m: C.coal.get(), gens_out: d ? both(d.gens, C.gens.get()) : C.gens.get(), branches_out: d ? both(d.branches, C.brs.get()) : C.brs.get(), noise: d && d.noise ? d.noise.slice() : null, dark: d ? d.dark.slice() : [] }; // a generated draw has no noise seed (null) } // back to the landing state: the landing event's peak day, no outages; the heat domes to their defaults function resetControls() { const G = ST.G, def = G.defaultKnobs, P = G.places; C.domes.forEach((d, j) => { const on = j < def.domes.length, [la, lo, rad, amp] = on ? def.domes[j] : [...P[G.offPlaces[j]], 300, 4]; d.sw.set(on); d.body.hidden = !on; d.lat.set(la); d.lon.set(lo); d.rad.set(rad); d.amp.set(amp); d.place.value = Object.entries(P).find(([, [a, b]]) => Math.abs(la - a) <= 0.02 && Math.abs(lo - b) <= 0.02)?.[0] || CUSTOM; }); C.brs.set([]); C.gens.set([]); WX.event = null; Object.assign(HZ, { storm: "harvey", shift: 0, alpha: 1.0, n: 0, draw: null, shown: null }); // a hurricane's state too if (C.stormSeg) syncStorm(); C.srcSeg.set("hist"); return setHeatSource("hist"); // the day's load, wind, sun and prices; then changed() } // ================================================================ the points the rail produces const eventMs = (c) => c.time_ms + (c.preview_reused && Number.isFinite(c.preview_ms) ? c.preview_ms : 0); const ownCheckMs = (c) => (c.preview_reused ? c.time_ms : c.time_ms - (Number.isFinite(c.preview_ms) ? c.preview_ms : 0)); const setupMs = (x) => (Number.isFinite(x.build_ms) ? x.build_ms : 0) + (Number.isFinite(x.package_ms) ? x.package_ms : 0); // The live clocks and the final numbers are the same quantity: the engine's own time for the work (a check's // time_ms, an exact's solve). A clock starts only when the engine says that work has started ({type: "progress"}); the // rest of a request (queue, the one-time setup, messaging) is shown apart after the result: rt_ms - time_ms. const outside = (r) => (r && Number.isFinite(r.rt_ms) && Number.isFinite(r.time_ms) ? Math.max(0, r.rt_ms - r.time_ms) : r ? setupMs(r) : 0); const extra = (ms) => (ms >= 100 ? `+ ${(ms / 1000).toFixed(1)} s setup and queue` : ""); function point(node) { const p = ST.res.preview, c = ST.res.check; if (node === "genco" && p) return { node, cost: Number.isFinite(p.raw_cost) ? p.raw_cost : null, loading: p.raw_line_loading || null, pg: p.gen_p, mismatch: p.raw_max_mismatch_mw, prediction: true, fromSliders: Number.isFinite(p.forward_ms) ? p.forward_ms : null, label: "GENCO's prediction" }; if (node === "repair" && p) return { node, cost: p.cost, loading: p.line_loading, pg: p.gen_p, vOut: p.v_out, maxLoading: p.max_loading, nViol: p.n_viol, viol: p.viol_detail, feasible: p.feasible, fromSliders: p.time_ms, label: "after the repair" }; if (node === "check" && c) return { node, cost: c.cost, loading: c.line_loading, pg: c.gen_p, vOut: c.v_out, maxLoading: c.max_loading, nViol: c.n_viol, viol: c.viol_detail, feasible: c.feasible, fromSliders: eventMs(c), label: "after the check" }; const x = ST.res.exact; if (node === "exact" && c && x && x.status === "optimal") return { node, cost: x.cost, loading: x.line_loading, pg: x.gen_p, vOut: x.v_out, maxLoading: Number.isFinite(x.max_loading) ? x.max_loading : maxOf(x.line_loading), nViol: x.n_viol, viol: x.viol_detail || "the optimum", feasible: !!x.feasible, fromSliders: eventMs(c) + x.time_ms, setup: outside(x), optimum: true, label: "the exact optimum" }; return null; } const viewable = (node) => !!point(node); // ================================================================ rendering: KPIs function kpis() { const P = point(ST.sel); const box = $("#kpis"); if (!box.dataset.built) { box.innerHTML = ["cost", "max", "gate", "time"].map((k) => `
`).join(""); box.dataset.built = "1"; } const tile = (k) => box.querySelector(`[data-k=${k}]`); // outages that split the grid have no solution: the previews never run, so nothing is "computing" const split = !connected(ST.G, knobs().branches_out); const wait = split ? "no solution: the grid splits" : ST.ready ? "computing…" : ST.engineFailed ? "engine error" : "engine loading…"; const set = (k, valueHtml, label, num, fmt) => { const t = tile(k), v = t.querySelector(".kpi-v"); v.classList.toggle("wait", num === undefined && valueHtml === "—"); v.classList.remove("stale"); if (num !== undefined && Number.isFinite(num)) countTo(v, num, fmt); else { v.dataset.v = ""; delete v.dataset.text; v.innerHTML = valueHtml; } t.querySelector(".kpi-l").innerHTML = label; }; if (!P) { // computing: a value already on screen stays, dimmed, and rolls to the new one when it comes (controls_v2.js // countTo); the badge turns neutral at once, so a dimmed number is never shown as a certified answer for (const [k, label] of [["cost", `Dispatch cost${wait}`], ["max", "Max line loading"], ["time", "Time from the sliders"]]) { const t = tile(k), v = t.querySelector(".kpi-v"); if (!ST.ready || split || !v.dataset.text) { set(k, "—", label); continue; } v.classList.add("stale"); t.querySelector(".kpi-l").innerHTML = label; } set("gate", badge("wait", wait), "full AC check"); return; } const mock = ST.mock ? " · mock engine" : ""; set("cost", "", `${P.optimum ? "Optimal" : "Dispatch"} cost${P.label}${mock}`, P.cost, (v) => `${money(v)}/h`); const mx = Number.isFinite(P.maxLoading) ? P.maxLoading : maxOf(P.loading); const gateNote = P.feasible && mx > 1.0 && mx <= OVER + 1e-9 ? '(1% tolerance)' : ""; if (mx != null) set("max", "", `Max line loading${gateNote}`, mx * 100, (v) => `${v.toFixed(1)}%`); else set("max", "—", "Max line loadingnot provided"); if (P.prediction) set("gate", badge("neutral", "prediction"), "not yet a physical point"); else { // one vocabulary: green and red are final verdicts, grey is everything else (the rail's amber "violations" after the // repair are expected, not an error) const detail = P.nViol ? P.viol || `${P.nViol} violation${P.nViol === 1 ? "" : "s"}` : "within every limit", ex = ST.state.exact; if ((P.node === "check" || P.node === "exact") && P.feasible) set("gate", badge("ok", "✓ Certified"), "full AC check"); else if (P.node === "check" && ex === "warn") set("gate", badge("no", "✕ No dispatch found"), "check and exact stepa flag, not a proof"); else if (P.node === "check" && (ex === "running" || ex === "idle")) set("gate", badge("neutral", "Solving exactly…"), detail); else set("gate", badge("neutral", P.node === "repair" ? "Not certified yet" : "Not certified"), detail); } const setup = P.setup >= 100 ? `${extra(P.setup)}, not counted` : ""; if (Number.isFinite(P.fromSliders)) set("time", "", `Time from the sliders${setup}`, P.fromSliders / 1000, (v) => `${v.toFixed(2)} s`); else set("time", "—", "Time from the sliders"); } function chip(r, warn, text) { r.className = warn ? "range-chip warn" : "range-chip"; r.innerHTML = ``; r.lastChild.textContent = text; r.title = text; } function range(k) { const r = $("#range"); if (!connected(ST.G, k.branches_out)) { chip(r, true, "These line outages split the network into islands; choose another set."); return; } if (C.src === "storm") { chip(r, true, "Hurricane: not among GENCO's 12 training storms"); r.title = ST.app.storm_range_note; return; } const flags = rangeFlags(ST.G, k); // a real day is outside the training range by design (the model saw heat domes); a certified answer does not rely on it if (C.src === "hist" && (k.weather || []).length) { chip(r, true, "Real weather: GENCO trained on synthetic domes"); r.title = `Outside the training range: ${flags.join("; ")}. A certified answer does not rely on the model.`; return; } chip(r, flags.length > 0, flags.length ? `Outside the training range: ${flags.join("; ")}` : "Inside GENCO's training range"); } // ================================================================ rendering: the rail // a node's key number and what it is: [value, label, muted, amber] function nodeKey(node) { const s = ST.state[node], p = ST.res.preview, c = ST.res.check; if (s === "running") return ["running…", "", true]; if (s === "cancelled") return ["cancelled", "", true]; if (s === "failed") return ["failed", "", true]; if (node === "genco" && p) return Number.isFinite(p.raw_max_mismatch_mw) ? [`${p.raw_max_mismatch_mw.toFixed(0)} MW`, "imbalance"] : [money(p.raw_cost ?? p.cost), "/h"]; // exceeding limits after the repair is expected: the count in amber, not an error if (node === "repair" && p) return [String(p.n_viol ?? 0), p.n_viol === 1 ? "violation" : "violations", false, (p.n_viol ?? 0) > 0]; if (node === "check" && c) return [money(c.cost), c.feasible ? "/h · certified" : "/h · not certified", false, !c.feasible]; if (node === "exact") { const x = ST.res.exact; if (s === "skip") return ["not needed", "", true]; if (s === "done" && x) return [money(x.cost), "/h · optimum"]; if (s === "warn") return ["no dispatch found", "", false, true]; return ["—", "", true]; } return [ST.ready ? "not run" : "—", "", true]; } function nodeTime(node) { const p = ST.res.preview, c = ST.res.check; if (ST.state[node] === "running") return ST.t0[node] ? `${((performance.now() - ST.t0[node]) / 1000).toFixed(1)} s so far` : ST.phase[node] === "setup" ? "setting up…" : ""; if (node === "genco" && p) return Number.isFinite(p.forward_ms) ? secs(p.forward_ms) : ""; if (node === "repair" && p) return Number.isFinite(p.repair_ms) ? secs(p.repair_ms) : `${secs(p.time_ms)} with GENCO`; if (node === "check" && c) return secs(ownCheckMs(c)); if (node === "exact" && ST.res.exact && Number.isFinite(ST.res.exact.time_ms) && ST.state.exact !== "skip") return secs(ST.res.exact.time_ms); return ""; } // the cascade's exact solve is running (the check running by itself does not block) const solving = () => ST.state.exact === "running"; const railBusy = () => ST.state.check === "running" || solving(); // one node of a rail; the connector before it is cyan when it is at or before the last node that finished function railNode({ id, state, num, name, sub, tip, key, keyLabel, muted, amber, time, sel, can, linked }) { const icon = { done: "✓", warn: "!", failed: "✕" }[state] || (state === "running" ? "" : String(num)); return `
  • ${icon}
    ${name}${sub}
    ${key}${keyLabel || ""}
    ${time}
  • `; } const isDone = (s) => s === "done" || s === "warn"; function rail() { let last = -1; ORDER.forEach((n, i) => { if (isDone(ST.state[n])) last = i; }); $("#rail").innerHTML = ORDER.map((n, i) => { const D = STEPS[n], [key, keyLabel, muted, amber] = nodeKey(n); return railNode({ id: n, state: ST.state[n] || "idle", num: i + 1, name: D.name, sub: D.sub, tip: D.ex, key, keyLabel, muted, amber, time: nodeTime(n), sel: ST.sel === n, can: viewable(n), linked: i <= last }); }).join(""); } // ================================================================ rendering: the map, the tables const lgBar = (stops, a, b) => `${a}${b}`; const LG_XFO = `overloaded transformer`; // any overloaded transformer (a branch too short to draw as a line) in these loadings let SHORT = null; const overXfo = (ld, out) => { if (!ld) return false; SHORT = SHORT || shortBranches(ST.G); for (const j of SHORT) if (!out.has(j) && ld[j] >= OVER) return true; return false; }; // 90% bands of the optimal loading (gridfm-trustlayer split-conformal, docs/trust_plan.md): per-branch residual // quantiles in per mille, for the preview ("repair") and the checked point; heat calibrators on this tab let TRUST = null; const BANDS = new WeakMap(); function bandsFor(P, sel) { // real days use their own calibrators (heat_real*, calibrated on other hot days); hurricanes the storms' (calibrated on // half of the Harvey and Beryl what-ifs, tested on the other half) const set = C.src === "hist" ? "heat_real" : C.src === "storm" ? "storms" : "heat"; const cal = TRUST && (sel === "repair" ? TRUST[set] : sel === "check" ? TRUST[`${set}_check`] : null); if (!cal || !P || !P.loading) return null; if (BANDS.has(P)) return BANDS.get(P); const ld = P.loading, n = ld.length, lo = new Float32Array(n), hi = new Float32Array(n); for (let j = 0; j < n; j++) { lo[j] = ld[j] + cal.lower[j] / 1000; hi[j] = ld[j] + cal.upper[j] / 1000; } const b = { lo, hi }; BANDS.set(P, b); return b; } function nUncertain(P, b) { if (!b) return 0; let n = 0; for (let j = 0; j < b.lo.length; j++) if (P.loading[j] < OVER && b.lo[j] < BAND_LIMIT && b.hi[j] >= BAND_LIMIT) n++; return n; } // ================================================================ what the what-ifs teach, checked live // assets/data/insights.json (scripts/insights.py): the findings the card quotes (exact AC-OPF, offline) and the offline // answers for the what-ifs a live study recomputes (the real days' verdicts and lines at their limit; the heat scan's // places and classes). Every map and number under the findings comes from a study run in this browser. let INS = null; // the exact step's limit inside a study: real-day rescues take 3-5 s in a browser (cascade_realdays.json, x 1.45); the // what-if above gives it CASCADE.limit_s const STUDY_LIMIT_S = 15; function insightsHtml() { const I = INS, pc = (x, d = 0) => `${(100 * x).toFixed(d)}%`, [a, b] = I.top, s4 = I.scan["4"], s8 = I.scan["8"], R = I.real; const hv = I.storms && I.storms.harvey, be = I.storms && I.storms.beryl, nOf = (o, keys) => keys.reduce((t, k) => [t[0] + o[k][0], t[1] + o[k][1]], [0, 0]); const span = (v) => { const lo = Math.min(...v), hi = Math.max(...v); return Math.round(100 * lo) === Math.round(100 * hi) ? pc(lo) : `${Math.round(100 * lo)}-${pc(hi)}`; }; const tile = (n, head, body, act, label) => `
    ${n}

    ${head}

    ${body}

    ` + `
    `; return `

    Found offline with Ipopt, an open-source exact AC-OPF solver (it does not run in your browser), on this synthetic model, not ERCOT's network: ${I.n_whatifs.toLocaleString("en-US")} unseen heat-dome what-ifs, a scan of heat domes over Texas, ${R.n} real peak hours${hv ? ` and ${(hv.n + be.n).toLocaleString("en-US")} hurricane damage draws` : ""}. Each button reruns one live, here, with GENCO.

    ${tile(`${I.lines_for_80pct}lines`, "hit most of this grid's limits", `Two 115 kV lines, near ${a.near} and ${b.near}, are at their limit in ${pc(a.share, 1)} and ${pc(b.share)} of the heat-dome what-ifs: over 80% of all the limits hit.${hv ? ` In hurricane draws: ${span([hv.top[0].share, be.top[0].share])} and ${span([hv.top[1].share, be.top[1].share])}.` : ""} Only ${I.lines_ever} of ${I.n_lines.toLocaleString("en-US")} lines ever reach theirs.`, "days", "Run a heat wave")} ${tile(`${s8.infeasible}of ${s8.n}`, "places where Ipopt finds a +8 °C dome infeasible", `A +4 °C dome is absorbed anywhere, at ${pc(s4.cost_increase_median, 1)} more cost (median). At +8 °C Ipopt finds no feasible dispatch around Houston and Austin (a local verdict, not a proof).`, "scan", "Run the +8 °C scan")} ${tile(`${R.infeasible + R.undecided}of ${R.n}`, "real peak hours with no dispatch found", `Ipopt found none (${R.infeasible} infeasible, ${R.undecided} undecided). All within ${(Math.ceil(1000 * (1 - R.r_min)) / 10).toFixed(1)}% of the summer peak, with low wind: ${pc(R.wind_no_dispatch)} of capacity against ${pc(R.wind_feasible)} where a dispatch exists (median; exploratory, ${R.n_high} hours).`, "days-aug", "Run August 2024")} ${hv ? tile(`${hv.by_shift["-50"][0]}of ${hv.by_shift["-50"][1]}`, "of Harvey's damage draws have no dispatch found, its track shifted 50 km southwest", `${nOf(hv.by_shift, ["25", "50"]).join(" of ")} do with the track shifted northeast. The draws that fail have more lines out (${hv.lines_out[0]} against ${hv.lines_out[1]} for Harvey); load and wind do not separate them.`, "storm-mc", "Run Harvey's Monte Carlo") : ""}

    Run a study

    A set of what-ifs run one after the other in this browser. Click a cell to open one above.

      `; } // ---------------------------------------------------------------- the study's state, its what-ifs, its engine // A second engine (its own worker, 2 threads: the what-if above stays responsive) runs the app's cascade on each what-if: // preview, check, and where the check does not certify, the exact step from GENCO's prediction. const STUDY = { engine: null, starting: null, running: false, token: 0, kind: "days", name: "", items: [], rows: [], t0: 0, ms: 0, msg: "", base: null, amp: 8, storm: null, shift: null }; // a hurricane study: STORM_N of the track's draws, drawn by a seeded generator (mulberry32), the same on every run const STORM_N = 20; const seeded = (a) => () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; // the 90% Wilson interval of a share x of n (as scripts/insights.py) function wilson(x, n, z = 1.645) { const p = x / n, d = 1 + z * z / n, c = (p + z * z / (2 * n)) / d, h = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d; return [Math.max(0, c - h), Math.min(1, c + h)]; } const atLimit = (ld) => { const a = []; if (ld) for (let j = 0; j < ld.length; j++) if (ld[j] >= 0.99) a.push(j); return a; }; // a real day's knobs, as knobs() builds them when the panel shows that day function dayKnobs(ev, j) { const x = ev.data[j]; return { domes: [], weather: WX.data.points.map((p, q) => [p[0], p[1], +ev.dT[j][q].toFixed(2)]), heat_load_wind: false, r: x.r, wind_area: x.wind_area.slice(), solar_area: x.solar_area.slice(), gas_m: Math.min(6, Math.max(0.25, x.gas_m)), coal_m: 1, gens_out: [], branches_out: [], noise: null, dark: [] }; } // a scan place's knobs, as scripts/heat_scan.py builds them: the app's default what-if plus one dome const scanKnobs = (S, la, lo, amp) => ({ ...S.base_knobs, domes: [[la, lo, S.radius_km, amp]], weather: [], heat_load_wind: true }); const REF_TEXT = { feasible: "exact: a dispatch exists", infeasible: "exact: infeasible (Ipopt)", undecided: "exact: undecided", nodispatch: "exact: no dispatch found (Ipopt)" }; function studyItems() { if (STUDY.kind === "storm") { const st = $("#study-storm").value, shift = +$("#study-shift").value, ds = shiftDraws(st, shift).slice().sort((p, q) => p.scenario - q.scenario); const rnd = seeded(20261021 + (st === "beryl" ? 100 : 0) + shift); for (let i = ds.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [ds[i], ds[j]] = [ds[j], ds[i]]; } const pick = ds.slice(0, STORM_N); return { name: `${HZ.data.storms[st].label}, track ${shiftText(shift)}: ${pick.length} damage draws`, storm: st, shift, items: pick.map((d) => ({ id: String(d.scenario), label: drawLabel(d), knobs: drawKnobs(d), ref: d.exact ? "feasible" : "nodispatch", k: d.k })) }; } if (STUDY.kind === "days") { const ev = WX.data.events[+$("#study-event").value]; return { name: `the ${ev.name} heat wave`, ev, items: ev.days.map((day, j) => ({ id: day, label: fmtDay(day), knobs: dayKnobs(ev, j), ref: INS.real.days[day] || null, load: ev.data[j].r })) }; } const S = INS.scan_live, amp = +$("#study-amp").value, two = $("#study-grid").value === "2", A = S.by_amp[String(amp)], items = []; S.points.forEach(([la, lo], i) => { if (two && !(la % 2 === 0 && lo % 2 === 0)) return; const c = A.class[i]; items.push({ id: String(i), label: `+${amp} °C dome at ${la.toFixed(0)}°N ${(-lo).toFixed(0)}°W`, knobs: scanKnobs(S, la, lo, amp), lat: la, lon: lo, ref: c === "certified" || c === "exact_only" ? "feasible" : c }); }); return { name: `a +${amp} °C dome at ${items.length} places`, amp, items }; } function studyEngine() { if (STUDY.engine) return Promise.resolve(STUDY.engine); if (!STUDY.starting) STUDY.starting = (async () => { const e = new Engine(ST.workerUrl); e.onStatus((m) => { if (m.stage !== "ready") { STUDY.msg = `Starting a second engine in this browser… ${m.detail || ""}`; renderStudy(); } }); await e.init({ threads: 2, ...(ST.engineOpts || {}) }); STUDY.engine = e; return e; })().catch((err) => { STUDY.starting = null; throw err; }); return STUDY.starting; } // the app's cascade on one what-if, on the study's engine; alive() turns false when the study was stopped: no exact step then async function cascadeOn(E, k, alive = () => true) { validateResult(await E.preview(k), ST.G); const c = validateResult(await E.check(k), ST.G, true); if (!alive()) throw new Error("stopped"); const x = c.feasible ? null : validateExact(await E.exact(k, { start: CASCADE.start, limit_s: STUDY_LIMIT_S }), ST.G); const by = c.feasible ? "check" : x && x.status === "optimal" && x.feasible ? "exact" : null; const P = by === "check" ? c : by === "exact" ? x : null; return { by, cost: P ? P.cost : null, atLimit: P ? atLimit(P.line_loading) : [] }; } // ---------------------------------------------------------------- running a study function setStudyKind(kind) { STUDY.kind = kind; if (C.kindSeg) C.kindSeg.set(kind); document.querySelectorAll(".study-opt").forEach((o) => { o.hidden = o.dataset.kind !== kind; }); renderStudyDesc(); } function resetStudy() { if (STUDY.running) return; Object.assign(STUDY, { name: "", items: [], rows: [], msg: "", base: null }); BOTT_CACHE.clear(); renderStudy(); renderInsMap(); } function stopStudy() { STUDY.token++; STUDY.running = false; STUDY.msg = "Stopped."; for (const r of STUDY.rows) if (r.state === "running") r.state = "pending"; // the what-if in flight does not finish if (STUDY.engine) STUDY.engine.cancel().catch(() => {}); renderStudy(); renderInsMap(); } async function runStudy() { const tok = ++STUDY.token; await wxData(); if (STUDY.kind === "storm") await stormData(); const S = studyItems(); renderStudyDesc(); // the options a programmatic start has just set Object.assign(STUDY, { running: true, name: S.name, amp: S.amp || null, storm: S.storm || null, shift: S.shift ?? null, items: S.items, rows: S.items.map(() => ({ state: "pending" })), msg: "", base: null }); BOTT_CACHE.clear(); renderStudy(); renderInsMap(); let E; try { E = await studyEngine(); } catch (e) { STUDY.running = false; STUDY.msg = `Could not start the study's engine: ${e.message}`; renderStudy(); return; } if (tok !== STUDY.token) return; STUDY.t0 = performance.now(); STUDY.msg = ""; const alive = () => tok === STUDY.token; if (STUDY.kind === "scan") { // the no-dome what-if, for each place's cost of the dome try { STUDY.base = (await cascadeOn(E, INS.scan_live.base_knobs, alive)).cost; } catch (e) { console.warn("study base:", e); } } for (let i = 0; i < STUDY.items.length; i++) { if (tok !== STUDY.token) return; const row = STUDY.rows[i], t0 = performance.now(); row.state = "running"; renderStudy(); renderInsMap(); try { const r = await cascadeOn(E, STUDY.items[i].knobs, alive); if (tok !== STUDY.token) return; Object.assign(row, r, { state: r.by ? "done" : "warn", ms: performance.now() - t0 }); } catch (e) { if (tok !== STUDY.token) return; row.state = "failed"; row.err = e.message; console.warn("study:", e); } BOTT_CACHE.delete("live"); renderStudy(); renderInsMap(); } STUDY.running = false; STUDY.ms = performance.now() - STUDY.t0; renderStudy(); renderInsMap(); } async function buildStudy() { const d = await wxData(), sel = $("#study-event"); d.events.forEach((e, i) => sel.append(el("option", { value: String(i), textContent: e.name }))); sel.value = String(WX.event ?? Math.max(0, d.events.findIndex((e) => e.name === LANDING_EVENT))); C.kindSeg = segmented($("#study-kind"), [["A heat wave", "days"], ["A dome scan", "scan"], ["A hurricane Monte Carlo", "storm"]], STUDY.kind, (v) => { if (STUDY.running) { C.kindSeg.set(STUDY.kind); return; } setStudyKind(v); resetStudy(); if (v === "storm") stormData().then(renderStudy).catch(() => {}); }, { label: "Study", id: "seg-study", small: true }); $("#study-run").addEventListener("click", () => (STUDY.running ? stopStudy() : runStudy())); $("#study-strip").addEventListener("keydown", (e) => { // the cells are buttons: Enter and Space open one const c = (e.key === "Enter" || e.key === " ") && e.target.closest(".sday"); if (c) { e.preventDefault(); c.click(); } }); for (const id of ["study-event", "study-amp", "study-grid", "study-storm", "study-shift"]) $(`#${id}`).addEventListener("change", resetStudy); $("#study-strip").addEventListener("click", (e) => { const dr = e.target.closest("[data-draw]"); if (dr && STUDY.kind === "storm" && STUDY.storm) { // a draw of the study: open it in the what-if above ST.view = "loading"; C.layerSeg.set("loading"); C.srcSeg.set("storm"); C.brs.set([]); C.gens.set([]); setHeatSource("storm", { storm: STUDY.storm, shift: STUDY.shift, scenario: +dr.dataset.draw }); $("#map").scrollIntoView({ behavior: REDUCED ? "auto" : "smooth", block: "center" }); return; } const c = e.target.closest("[data-day]"); if (!c || STUDY.kind !== "days") return; const ev = WX.data.events[+$("#study-event").value]; ST.view = "loading"; C.layerSeg.set("loading"); C.srcSeg.set("hist"); C.brs.set([]); C.gens.set([]); setHeatSource("hist", { event: ev.name, day: c.dataset.day }); $("#map").scrollIntoView({ behavior: REDUCED ? "auto" : "smooth", block: "center" }); }); renderStudy(); } // ---------------------------------------------------------------- what the study shows // What the selected study is, in words: which what-ifs, how many, what varies, what the result means. The counts come from // the data (weather_real.json's event days, insights.json's scan places and base what-if, storm_draws.json's draws per // track shift, STORM_N); "no dispatch found" is the cascade's own flag (a flag, not a proof). function studyDesc() { const row = (t, d) => `
      ${t}
      ${d}
      `, md = (iso) => new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" }); if (STUDY.kind === "days") { const ev = WX.data && WX.data.events[+$("#study-event").value]; if (!ev) return ""; const n = ev.days.length; return row("What-ifs", `${n} days of the ${ev.name} heat wave (${md(ev.days[0])} to ${md(ev.days[n - 1])}), each at that day's ERCOT peak hour. What varies: the day's real data, the heat (ERA5), ERCOT's demand as a share of that summer's peak, wind, sun and the gas price.`) + row("Result", `On how many days a dispatch is found: GENCO's point, or the exact step's optimum (${STUDY_LIMIT_S} s limit per what-if here), certified by the full AC check. If neither is, no dispatch is found, a flag and not a proof that none exists.`); } if (STUDY.kind === "scan") { const S = INS.scan_live, b = S.base_knobs, amp = $("#study-amp").value, two = $("#study-grid").value === "2"; const n = S.points.filter(([la, lo]) => !two || (la % 2 === 0 && lo % 2 === 0)).length; const pc = (v) => `${Math.round(100 * v)}%`, avg = (a) => a.reduce((p, q) => p + q, 0) / a.length; return row("What-ifs", `${n} places on a ${two ? 2 : 1}° grid over Texas, each with one +${amp} °C heat dome of ${S.radius_km} km radius, on one base what-if: ${pc(b.r)} of peak load, wind ${pc(avg(b.wind_area))}, sun ${pc(avg(b.solar_area))}, no outages. Only where the dome sits varies.`) + row("Result", `Where the grid can take the dome (a dispatch is certified; ${STUDY_LIMIT_S} s limit per what-if here) and where not (none is found, a flag and not a proof). Each place is a disc on the map below, coloured by the dome's extra cost.`); } const st = $("#study-storm").value, shift = +$("#study-shift").value; if (!HZ.data) return row("What-ifs", `${STORM_N} damage draws of the chosen storm and track.`); // its data is still loading const total = shiftDraws(st, shift).length, n = Math.min(STORM_N, total); return row("What-ifs", `${n} damage draws of ${HZ.data.storms[st].label}, its track ${shiftText(shift)}, picked at random from the ${total} stored for that track (the same ${n} on every run). What varies: the damage (which lines and units the storm takes out, a damaged zone that keeps 40-100% of its load, wind speed ×0.9-1.1) and a random load, wind, sun and fuel prices.`) + row("Result", `The share of draws with no dispatch found (a flag, not a proof; ${STUDY_LIMIT_S} s limit per what-if here), with its 90% interval: a Monte Carlo estimate for this track.`); } function renderStudyDesc() { const d = $("#study-desc"); if (!d || STUDY.running) return; // a running study keeps the description of what it runs const html = studyDesc(); if (d._html !== html) { d._html = html; d.innerHTML = html ? `
      ${html}
      ` : ""; } } function renderStudy() { const strip = $("#study-strip"), sum = $("#study-sum"), btn = $("#study-run"), leg = $("#study-legend"), key = $("#study-key"); if (!strip) return; // progress: the what-ifs done, plus half of the one running (a what-if takes seconds, so the bar keeps moving) const prog = $("#study-prog"), nRows = STUDY.rows.length; const frac = nRows ? (STUDY.rows.filter((r) => ["done", "warn", "failed"].includes(r.state)).length + (STUDY.rows.some((r) => r.state === "running") ? 0.5 : 0)) / nRows : 0; prog.hidden = !nRows; prog.firstElementChild.style.width = `${(100 * frac).toFixed(1)}%`; prog.setAttribute("aria-valuenow", String(Math.round(100 * frac))); btn.textContent = STUDY.running ? "Stop" : STUDY.rows.length ? "Run it again" : "Run it"; btn.classList.toggle("ghost", STUDY.running); btn.classList.toggle("primary", !STUDY.running); // while a study runs its options and the findings' buttons wait: a click would act on a study half done for (const id of ["study-event", "study-amp", "study-grid", "study-storm", "study-shift"]) $(`#${id}`).disabled = STUDY.running; document.querySelectorAll("#insights .ins .tbtn").forEach((b) => { b.disabled = STUDY.running; }); const days = STUDY.kind === "days", storm = STUDY.kind === "storm"; renderStudyDesc(); strip.hidden = key.hidden = !days && !storm; // the cell's data, top to bottom: the bar (and the number), then the dot and the mark, which the legend explains key.textContent = days ? "One cell per day. Bar: ERCOT's load at that day's peak hour, on a scale from 85% to 100% of that summer's peak. Click a cell to open that day above." : storm ? "One cell per damage draw. Bar and number: the lines out in that draw (bar full at 60). Click a cell to open that draw above." : ""; leg.innerHTML = days || storm ? `certified liveno dispatch found live` + `Exact AC-OPF, offline:✓a dispatch exists✕none found` + (days ? `?undecided` : "") : "Each place is a disc on the map below: its colour is the dome's extra cost where a dispatch is certified, red where none is found. Hover one for its live result and the offline exact verdict."; if (storm) { const items = STUDY.items.length ? STUDY.items : HZ.data ? studyItems().items : []; strip.style.setProperty("--n", String(Math.max(1, items.length))); strip.innerHTML = items.map((it, j) => { const r = STUDY.rows[j] || { state: "idle" }, ok = it.ref === "feasible"; return `
    1. ${it.k}${ok ? "✓" : "✕"}
    2. `; }).join(""); } if (days) { const ev = WX.data && WX.data.events[+$("#study-event").value]; const items = STUDY.items.length ? STUDY.items : ev ? ev.days.map((day, j) => ({ id: day, label: fmtDay(day), ref: INS.real.days[day] || null, load: ev.data[j].r })) : []; strip.style.setProperty("--n", String(Math.max(1, items.length))); strip.innerHTML = items.map((it, j) => { const r = STUDY.rows[j] || { state: "idle" }, dd = new Date(`${it.id}T12:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" }); const mark = { feasible: "✓", infeasible: "✕", undecided: "?" }[it.ref] || ""; return `
    3. ${dd}${mark}
    4. `; }).join(""); } if (STUDY.msg) { sum.textContent = STUDY.msg; return; } if (!STUDY.rows.length) { sum.textContent = days ? "Takes one to two minutes." : storm ? "Takes a few minutes." : $("#study-grid").value === "2" ? "Takes about 2 minutes." : "Takes several minutes."; return; } const n = STUDY.rows.length, done = STUDY.rows.filter((r) => ["done", "warn", "failed"].includes(r.state)).length; const by = (b) => STUDY.rows.filter((r) => r.by === b).length, none = STUDY.rows.filter((r) => r.state === "warn").length; const t = ((STUDY.running ? performance.now() : STUDY.t0 + STUDY.ms) - STUDY.t0) / 1000, unit = days ? "days" : storm ? "draws" : "places"; const counts = `${by("check")} certified by the check, ${by("exact")} by the exact step, ${none} without a dispatch found`; if (STUDY.running) { // the counters roll (controls_v2.js countTo) while the study runs let live = sum.querySelector(".st-live"); if (!live || live.dataset.n !== String(n)) { sum.innerHTML = ` of ${n} ${unit} · s · certified by the check, ` + ` by the exact step, without a dispatch found.`; live = sum.querySelector(".st-live"); } const put = (k, v) => countTo(live.querySelector(`[data-c=${k}]`), v, String); put("i", Math.min(done + 1, n)); put("t", Math.round(t)); put("c", by("check")); put("x", by("exact")); put("w", none); return; } let cost = ""; if (!days && STUDY.base) { const inc = STUDY.rows.filter((r) => r.by).map((r) => (r.cost - STUDY.base) / STUDY.base).sort((p, q) => p - q); if (inc.length) cost = ` The dome costs ${(100 * inc[Math.floor((inc.length - 1) / 2)]).toFixed(1)}% more at the median where a dispatch exists.`; } // a hurricane study is a Monte Carlo: the share of draws without a dispatch, with its 90% interval if (storm) { const [lo, hi] = wilson(none, n); cost = ` No dispatch found in ${Math.round(100 * none / n)}% of the draws; the 90% interval for this track is ${Math.round(100 * lo)}-${Math.round(100 * hi)}%.`; } // the agreement with the offline exact answers is reported in the post, not here (user, 2026-10-21) sum.textContent = `${n} ${unit} in ${t.toFixed(0)} s in this browser: ${counts}.${cost}`; } const rowText = (r) => (r.state === "done" ? `certified by the ${r.by === "check" ? "check" : "exact step"} in ${secs(r.ms)}, ${money(r.cost)}/h, ${r.atLimit.length} lines at their limit` : r.state === "warn" ? `no dispatch found (${secs(r.ms)})` : r.state === "running" ? "running…" : r.state === "failed" ? `failed: ${esc(r.err)}` : "not run yet"); // the card map: lines at their limit over the study's certified what-ifs, and the scan's places as discs. It is built on the // first run: before one there is nothing to show. (The exact AC-OPF's answers sit in each cell's mark and tooltip.) const BOTT_CACHE = new Map(); function bottShares() { if (BOTT_CACHE.has("live")) return BOTT_CACHE.get("live"); const cnt = {}; let n = 0; for (const r of STUDY.rows) if (r.by) { n++; for (const j of r.atLimit) cnt[j] = (cnt[j] || 0) + 1; } const o = Object.fromEntries(Object.entries(cnt).map(([j, c]) => [j, c / Math.max(1, n)])); BOTT_CACHE.set("live", o); return o; } const INC_STOPS = [[0, "#F2A900"], [6, "#C77B04"], [12, "#9A5604"], [18, "#5E3100"]]; // the dome's cost, % (F_scan's scale) function scanDiscs() { if (STUDY.kind !== "scan" || !STUDY.items.length) return []; return STUDY.items.map((it, i) => { const r = STUDY.rows[i] || { state: "pending" }; const inc = r.by && STUDY.base ? 100 * (r.cost - STUDY.base) / STUDY.base : null; let c = "#C9D3D8", rad = 4, sc = "#FFFFFF", sw = 1; if (r.state === "running") { c = "#FFFFFF"; sc = "#00A7C4"; sw = 2.5; rad = 6; } else if (r.by) { c = inc != null ? rgb(ramp(INC_STOPS, Math.max(0, inc))) : "#00A7C4"; rad = 7; sc = r.by === "exact" ? "#006F96" : "#FFFFFF"; sw = r.by === "exact" ? 2.5 : 1; } else if (r.state === "warn") { c = "#D03B3B"; rad = 7; } const tip = `${it.label}
      ${rowText(r)}${inc != null ? ` · +${inc.toFixed(1)}% cost` : ""}
      ${REF_TEXT[it.ref] || ""}`; return { p: [it.lon, it.lat], c, r: rad, sc, sw, tip }; }); } // a map drawn while off screen can show blank when it scrolls into view (seen in headless Chrome): repaint it then let INS_MAP = null; function ensureInsMap() { if (INS_MAP || INS_MAP === false) return INS_MAP; try { INS_MAP = new GridMap($("#ins-map"), ST.G); new IntersectionObserver((es) => { if (es.some((e) => e.isIntersecting)) { INS_MAP.map.resize(); INS_MAP.map.triggerRepaint(); } }).observe($("#ins-map")); } catch (e) { console.warn("study map:", e); INS_MAP = false; } // no WebGL: the strip and the counts still work return INS_MAP; } function renderInsMap() { const ran = STUDY.rows.length > 0, box = document.querySelector(".ins-map"); if (!box) return; box.hidden = !ran; if (!ran || !ensureInsMap()) return; const nOk = STUDY.rows.filter((r) => r.by).length; const track = STUDY.kind === "storm" && STUDY.storm ? trackOf(STUDY.storm, STUDY.shift) : null; INS_MAP.render({ view: "bottleneck", A: null, rate: null, loading: null, pg: null, vOut: null, domes: [], weather: [], bands: null, showGen: false, out: new Set(), track, bott: bottShares(), bottLabel: `the ${nOk} certified what-ifs of the study`, scan: scanDiscs() }); $("#ins-legend").innerHTML = lgBar(BOTT_STOPS, "0", `100% of the ${STUDY.kind === "days" ? "days" : STUDY.kind === "storm" ? "draws" : "places"} at the limit`) + (track ? 'storm track' : "") + `transformer` + (STUDY.kind === "scan" ? `place: the dome's costno dispatch` : ""); $("#ins-map-sub").textContent = `${STUDY.name}: ${nOk} of ${STUDY.rows.length} certified in this browser${STUDY.running ? " so far" : ""}. A line's colour is the share of them with that line at its limit (≥ 99% of its rating${STUDY.kind === "storm" ? "" : ", derated by the heat"}).`; $("#ins-cap").textContent = "Each line is coloured by the share of the study's certified what-ifs (the check's point, or the exact step's optimum) with that line at its limit. Hover a line or a place for details."; } async function insightAct(a) { if (STUDY.running) return; const pickEvent = (name) => { const i = WX.data.events.findIndex((e) => e.name === name); if (i >= 0) $("#study-event").value = String(i); }; if (a === "storm-mc") { // Harvey with its track 50 km southwest: where its draws fail most $("#study-storm").value = "harvey"; $("#study-shift").value = "-50"; setStudyKind("storm"); } else if (a === "days" || a === "days-aug") { pickEvent(a === "days-aug" ? "August 2024" : LANDING_EVENT); setStudyKind("days"); } else if (a === "scan") { $("#study-amp").value = "8"; $("#study-grid").value = "2"; setStudyKind("scan"); } else return; $("#study").scrollIntoView({ behavior: REDUCED ? "auto" : "smooth", block: "start" }); runStudy(); } const LG_OVER = `overloaded (> rating + 1%)`; function legend() { const v = ST.view, L = [], bar = lgBar; if (v === "loading") { L.push(bar(LOAD_STOPS, "0", "100% loading")); const P = point(ST.sel), kn = knobs(); if (P && P.loading && maxOf(P.loading) >= OVER) L.push(LG_OVER); if (kn.domes.length || kn.weather.length) L.push('heat anomaly'); if (P && overXfo(P.loading, new Set(kn.branches_out))) L.push(LG_XFO); // a voltage out of band is never on a certified point (the gate checks voltages; every bus's band is 0.90-1.10 p.u.) if (P && P.vOut && P.vOut.length) L.push(`voltage out of band · not certified`); const b = bandsFor(P, ST.sel); if (b && nUncertain(P, b)) L.push(`may be at its limit (${C.src === "hist" ? "indicative band" : "90% band"})`); if (ST.showGen) L.push(`generators, by dispatch`); } else if (v === "dT") L.push(bar(DOT_STOPS, "0.5", "10 °C"), 'no dot: under 0.5 °C'); else if (v === "load") L.push(`damaged zone, keeps 40-100% of its load`); else L.push('wind & solargas & coalhydro & nucleardisc: available · halo: nameplate'); const k = knobs(), storm = C.src === "storm"; if (k.branches_out.length) L.push('line out'); if (storm) L.push('storm track'); if (k.domes.length) L.push('dome centre'); $("#legend").innerHTML = L.join(""); } function mapScene(A, k) { const P = point(ST.sel); const bands = bandsFor(P, ST.sel); const storm = C.src === "storm" && HZ.draw; ST.map.render({ view: ST.view, A, rate: A.rate, loading: P ? P.loading : null, pg: P ? P.pg : null, vOut: P ? P.vOut : null, domes: k.domes, weather: k.weather, bands, showGen: ST.showGen, out: new Set(k.branches_out), track: storm ? trackOf(HZ.storm, HZ.draw.shift) : null, dark: k.dark, bandLabel: C.src === "hist" ? "indicative band" : "90% band" }); $("#gen-sw").hidden = ST.view !== "loading"; // the dispatch discs belong to the loading view const cap = $("#map-cap"); // ΔT is against normal: on a real day the day's maximum minus the July-August 2012-2021 mean daily maximum // (scripts/weather_real.py, capped at 10 °C); in the heat-domes source what the domes add on top of a normal summer day if (ST.view !== "loading") cap.textContent = { dT: C.src === "hist" ? "Temperature above normal per bus: the day's maximum minus the July-August 2012-2021 mean daily maximum (ERA5)" : "Temperature the domes add per bus, on top of a normal summer day", load: "The storm's damaged zone: buses that keep 40-100% of their load, each dot sized by that load", capacity: "Each disc is a plant's available capacity; the faint halo around it is its nameplate, so the gap is what the weather or an outage took" }[ST.view]; else if (!P) cap.textContent = ST.ready ? "Line loading appears with GENCO's prediction…" : "The grid. Line loading appears when the engine is ready."; else if (P.prediction && !P.loading) cap.textContent = "GENCO's prediction: the engine reports no line loadings before the repair."; else { const nu = nUncertain(P, bands); // real days: bands calibrated on other hot days, below the pre-registered coverage, so indicative (trust.json real_days) const R = TRUST && TRUST.real_days, real = C.src === "hist"; const kind = real && R ? `indicative bands, ${Math.round(100 * (ST.sel === "repair" ? R.coverage_preview : R.coverage_check))}% coverage on held-out real days, not 90%` : "90% bands"; const noBands = !bands && real && (ST.sel === "repair" || ST.sel === "check") ? " No bands on real days in this build." : ""; cap.textContent = `Line loading, ${P.label}.` + (bands ? ` ${nu} line${nu === 1 ? "" : "s"} may be at their limit at the exact optimum (dashed; ${kind}).` : noBands); } } let raf = false; function live() { if (raf) return; raf = true; requestAnimationFrame(() => { raf = false; render(false); }); } function render(full = true) { const k = knobs(), A = scenarioArrays(ST.G, k); mapScene(A, k); legend(); range(k); const storm = C.src === "storm" && HZ.draw; $("#storm-card").hidden = !storm; // only a hurricane has an inputs card: what the storm took out if (storm) $("#weather").innerHTML = stormHtml(k); if (full) { kpis(); rail(); } } // ================================================================ the engine // The check runs by itself after every preview, about 150 ms after it arrives, so the rail moves from the map to a // feasible point without a click. A newer slider move supersedes it: the timer is cleared, a running check's result is // dropped by its key, and the next preview's interrupt stops it where the engine supports that. function autoCheck(key) { clearTimeout(ST.autoT); ST.autoT = setTimeout(() => { if (key === ST.key && !ST.busy && !ST.queued && ST.state.check === "idle" && !ST.res.check) doCheck(); }, 150); } function changed() { const k = knobs(), key = keyOf(k); if (key !== ST.key) { clearTimeout(ST.autoT); if (solving() && ST.engine) ST.engine.cancel().catch(() => {}); ST.key = key; ST.res = {}; ST.sel = "repair"; ST.state = { genco: ST.ready ? "running" : "idle", repair: ST.ready ? "running" : "idle", check: "idle", exact: "idle" }; } render(); preview(); } async function preview() { if (!ST.ready) return; if (ST.busy) { ST.queued = true; return; } const k = knobs(), key = keyOf(k); if (!connected(ST.G, k.branches_out)) { ST.state.genco = ST.state.repair = "idle"; render(); return; } ST.busy = true; try { const r = validateResult(await ST.engine.preview(k), ST.G); if (key === ST.key) { ST.res.preview = r; ST.state.genco = "done"; ST.state.repair = "done"; if ($("#engine").classList.contains("error")) $("#engine").className = "engine ready"; // a failed request, then a good one render(); autoCheck(key); } } catch (e) { if (key === ST.key) { ST.state.genco = ST.state.repair = "failed"; render(); } engineError(e); } ST.busy = false; if (ST.queued) { ST.queued = false; preview(); } } function ticking(on) { // the rail's clocks while a step runs clearInterval(ST.timer); if (on) ST.timer = setInterval(() => { if (!railBusy()) { ticking(false); return; } for (const n of ORDER) if (ST.state[n] === "running") { const e = document.querySelector(`.node[data-node=${n}] .node-time`); if (e) e.innerHTML = nodeTime(n); } }, 250); } async function doCheck() { const key = ST.key, k = knobs(); ST.state.check = "running"; ST.t0.check = null; ticking(true); render(); try { const raw = await ST.engine.check(k, { onProgress: (m) => { if (m.stage === "check" && key === ST.key) ST.t0.check = performance.now(); } }); if (raw && raw.status === "cancelled") { // a newer request stopped it (ENGINE_CONTRACT.md: no cost, NaN arrays): not an error. For the same what-if // (a compare interrupted it) the automatic check comes back. if (key === ST.key) { ST.state.check = "idle"; autoCheck(key); } } else { const r = validateResult(raw, ST.G, true); if (key === ST.key) { ST.res.check = r; ST.state.check = r.feasible ? "done" : "warn"; ST.sel = "check"; ST.state.exact = r.feasible ? "skip" : "idle"; if (!r.feasible) runCascade(key); // the cascade's last step, by itself } } } catch (e) { if (key === ST.key) ST.state.check = "failed"; engineError(e); } render(); } // the cascade's last step: the exact AC-OPF from GENCO's prediction, when the check did not certify; its optimum is the // answer if it passes the gate, else the page says no dispatch was found (a flag, not a proof) async function runCascade(key) { const k = knobs(); ST.state.exact = "running"; ST.t0.exact = null; ST.phase.exact = "waiting"; ticking(true); render(); const onProgress = (m) => { if (key !== ST.key) return; if (m.stage === "setup") ST.phase.exact = "setup"; if (m.stage === "solve") { ST.phase.exact = "solve"; ST.t0.exact = performance.now(); } rail(); }; try { const r = validateExact(await ST.engine.exact(k, { start: CASCADE.start, limit_s: CASCADE.limit_s, onProgress }), ST.G); if (key === ST.key) { ST.res.exact = r; ST.state.exact = r.status === "cancelled" ? "cancelled" : r.status === "optimal" && r.feasible ? "done" : "warn"; if (ST.state.exact === "done") ST.sel = "exact"; } } catch (e) { if (key === ST.key) ST.state.exact = "failed"; console.warn("cascade:", e); } render(); } function engineError(e) { console.warn("engine:", e); const first = !ST.ready; // it never started: the KPIs say so, not "loading…" for ever if (first) ST.engineFailed = true; $("#engine").className = "engine error"; $("#engine-title").textContent = "Engine error"; $("#engine-text").textContent = String(e.message).split("\n")[0].slice(0, 160); if (first) render(); } async function startEngine() { const txt = $("#engine-text"); try { // ?worker= and ?mock_delay_ms= are for tests, honoured only when the page is served from this machine const local = ["127.0.0.1", "localhost", "[::1]"].includes(location.hostname) || location.hostname.endsWith(".localhost"); const params = new URLSearchParams(local ? location.search : ""), w = params.get("worker"); ST.workerUrl = w && /^[\w.-]+\.js$/.test(w) ? w : undefined; ST.engineOpts = params.has("mock_delay_ms") ? { mock_delay_ms: +params.get("mock_delay_ms") } : {}; ST.engine = new Engine(ST.workerUrl); ST.engine.onStatus((m) => { // the worker's own stage and share of the loading if (m.stage === "ready" || m.stage === "error") return; txt.textContent = m.detail || ""; if (Number.isFinite(m.progress)) { const pc = `${Math.round(100 * m.progress)}%`; $("#engine-bar").style.width = pc; $("#engine-pct").textContent = pc; } }); const r = await ST.engine.init(params.has("mock_delay_ms") ? { mock_delay_ms: +params.get("mock_delay_ms") } : {}); ST.ready = !!r.ready; ST.mock = !!r.mock; $("#engine").className = "engine ready"; // shown only while loading or after an error (styles_v2.css) txt.innerHTML = `Engine ready in ${(r.load_ms / 1000).toFixed(1)} s · ${r.threads} thread${r.threads !== 1 ? "s" : ""}` + (ST.mock ? " · mock" : ""); ST.key = null; changed(); } catch (e) { engineError(e); } } // ================================================================ hurricanes // assets/data/storm_draws.json (tools/export_storm_draws.py): Harvey's and Beryl's 600 what-ifs each. A what-if is one // draw of the storm's outage ensemble (lines and units out, a damaged zone whose buses keep 40-100% of their load) under // a track variant (shifted -50 to +50 km across the track, wind speed x0.9-1.1), on the broad space's load, wind, sun and // prices. Each carries the engine's knobs with the generator's noise seed (the stored what-if, bit for bit: // tools/storm_equiv.py) and the offline exact AC-OPF: its cost and the lines at their limit, or null where it found no // dispatch. assets/data/storms.json: each track shift's path (HURDAT2, shifted across the track). // The stored draws serve the Monte Carlo study (and its answer key), and open in the what-if from its strip. The what-if's // own hurricane damage is generated: assets/data/storm_ensemble.json (tools/export_storm_ensemble.py) holds, per storm and // track variant, the line failure probabilities of the same ensemble, the wind farms the winds stop and the damaged zone; // storm_damage.js draws the lines from them (seeded) and keeps a draw only if the network stays connected, as D44 did. // Such damage has no offline exact answer. The sliders carry the operating point: the middle of what the stored draws // were drawn on (ens.operating_point), or a stored draw's own when one is opened from the study. const HZ = { data: null, ens: null, tracks: null, storm: "harvey", shift: 0, alpha: 1.0, n: 0, draw: null, shown: null }; const getJson = (u) => fetch(u).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); async function stormData() { // the stored draws (the study's) and the tracks if (!HZ.data) [HZ.data, HZ.tracks] = await Promise.all([getJson("assets/data/storm_draws.json"), HZ.tracks || getJson("assets/data/storms.json")]); return HZ.data; } async function ensembleData() { // the generator's data (0.24 MB) and the tracks if (!HZ.ens) [HZ.ens, HZ.tracks] = await Promise.all([getJson("assets/data/storm_ensemble.json"), HZ.tracks || getJson("assets/data/storms.json")]); return HZ.ens; } const shiftDraws = (storm, shift) => HZ.data.storms[storm].draws.filter((d) => d.shift === shift); const trackOf = (storm, shift) => HZ.tracks[storm].tracks[`${shift >= 0 ? "+" : ""}${shift}`]; const shiftText = (shift) => (shift === 0 ? "as it was" : `shifted ${Math.abs(shift)} km ${shift < 0 ? "southwest" : "northeast"}`); const drawLabel = (d) => `Wind speed ×${d.alpha.toFixed(1)} · ${d.k} line${d.k !== 1 ? "s" : ""}, ${d.k_gen} unit${d.k_gen !== 1 ? "s" : ""} out`; // a draw's knobs, as knobs() builds them while the panel shows that draw const drawKnobs = (d) => ({ domes: [], weather: [], heat_load_wind: true, r: d.r, wind_area: d.wind_area.slice(), solar_area: d.solar_area.slice(), gas_m: d.gas_m, coal_m: d.coal_m, gens_out: d.gens.slice(), branches_out: d.branches.slice(), noise: d.noise.slice(), dark: d.dark.slice() }); const mean = (a) => a.reduce((p, q) => p + q, 0) / a.length; function setOperating(x) { // the sliders take an operating point: {r, wind_area, solar_area, gas_m, coal_m} C.r.set(x.r); x.wind_area.forEach((v, i) => C.wind[i].set(v)); x.solar_area.forEach((v, i) => C.solar[i].set(v)); C.windAll.set(+mean(x.wind_area).toFixed(2)); C.solarAll.set(+mean(x.solar_area).toFixed(2)); C.gas.set(x.gas_m); C.coal.set(x.coal_m); C.brs.set([]); C.gens.set([]); } // where the sliders start in a hurricane: the median of the stored what-ifs' load, wind, sun and prices, the same in every area function typicalOperating() { const o = HZ.ens.operating_point, n = ST.G.areas.length; return { r: o.r, wind_area: Array(n).fill(o.wind), solar_area: Array(n).fill(o.solar), gas_m: o.gas_m, coal_m: o.coal_m }; } // the "i" texts of the hurricane controls; facts: s2s/hazard/holland.py (Variant, max_wind), s2s/sampler/storm.py and // fragility.py (the ensemble), docs/decisions.md D44 (what was kept) const STORM_TIPS = { shift: "Moves the storm's whole track sideways, across its direction of travel: negative values to the southwest, positive to the northeast. 0 is NOAA's best track (HURDAT2). A track nearer the grid or further from it changes which lines the winds reach, so which fail.", alpha: "Scales the storm's winds: the maximum wind speed by this factor and the pressure drop by its square (Holland wind model). ×1.0 is the storm as recorded. Stronger winds raise each line's failure probability and stop more wind farms (they cut out above 25 m/s at hub height).", again: "Draws new random damage for this track and wind speed. Each line fails with the probability the winds give it, independently of the others (from a Holland wind field along the track and a fragility curve per line); the wind farms and the damaged zone are fixed by the track and the wind. A draw that would cut part of the grid off is thrown away and drawn again, as for the stored what-ifs. The draws are seeded: the same sequence on every run. Load, wind, sun and prices stay on the sliders.", }; const nearest = (v, a) => a.reduce((b, x) => (Math.abs(x - v) < Math.abs(b - v) ? x : b)); // the left panel's hurricane block: the storm, its track shift, its wind speed, a button for new random damage. keep: the // source was already the hurricane (a second click on it), so the sliders stay where they are. async function stormPanel(at = null, keep = false) { const box = $("#storm"), stored = !!at && at.scenario != null; try { await ensembleData(); if (stored) await stormData(); } catch (e) { box.innerHTML = `

      Could not load the hurricanes (${e.message}).

      `; return false; } if (at && at.storm) { HZ.storm = at.storm; HZ.shift = at.shift; } if (!box.dataset.built) { box.dataset.built = "1"; const go = () => { if (redraw()) changed(); }; // a change of storm, track or wind speed: new damage for it C.stormSeg = segmented(box, [["Harvey (2017)", "harvey"], ["Beryl (2024)", "beryl"]], HZ.storm, (v) => { HZ.storm = v; go(); }, { label: "Storm", id: "seg-storm" }); C.stormSeg.el.classList.add("full"); C.shift = slider(box, { label: "Track shift", info: STORM_TIPS.shift, unit: "km", min: -50, max: 50, step: 25, value: HZ.shift, id: "s-shift", onChange: (v) => { HZ.shift = nearest(v, HZ.ens.shifts); go(); } }); C.alpha = slider(box, { label: "Storm wind speed", info: STORM_TIPS.alpha, unit: "×", min: 0.9, max: 1.1, step: 0.1, value: HZ.alpha, id: "s-alpha", fmt: (v) => (+v).toFixed(1), onChange: (v) => { HZ.alpha = nearest(v, HZ.ens.alphas); go(); } }); const dmg = el("div", { className: "dmg" }); const again = el("button", { type: "button", className: "btn ghost", id: "s-again", textContent: "New random damage" }); const tip = info(STORM_TIPS.again); tip.setAttribute("aria-label", "About new random damage"); dmg.append(again, tip); box.append(dmg, el("p", { className: "note", id: "s-note" })); again.addEventListener("click", () => { if (redraw(true)) changed(); }); } const x = stored ? HZ.data.storms[HZ.storm].draws.find((d) => d.scenario === at.scenario) : null; if (x) { HZ.alpha = x.alpha; HZ.shift = x.shift; setOperating(x); showDraw({ ...x, storm: HZ.storm }); } // a stored what-if, with its operating point else { if (!keep) setOperating(typicalOperating()); redraw(); } return true; } const syncStorm = () => { C.stormSeg.set(HZ.storm); C.shift.set(HZ.shift); C.alpha.set(HZ.alpha); }; // the damage of the chosen storm, track and wind speed: draw number HZ.n (the next one if `next`), seeded by storm, variant // and number so that going back to a track shows the damage it had. False, with the previous state back, if none of the // draws leaves the grid connected (the rarest variant needs about 2,300 of the 100,000 allowed). function redraw(next = false) { const E = HZ.ens, v = E.shifts.indexOf(HZ.shift) * E.alphas.length + E.alphas.indexOf(HZ.alpha), V = E.storms[HZ.storm].variants[v], n = HZ.n + (next ? 1 : 0); const r = sampleDamage(ST.G, V, seeded(damageSeed(HZ.storm, v, n))); if (!r) { const p = HZ.shown, was = `${HZ.tracks[HZ.storm].label}, track ${shiftText(HZ.shift)}, wind speed ×${HZ.alpha.toFixed(1)}`; if (p) { Object.assign(HZ, p); syncStorm(); } $("#s-note").textContent = `${was}: no damage that leaves the grid in one piece was found in 100,000 draws. Back on the previous track and wind speed.`; return false; } HZ.n = n; showDraw({ storm: HZ.storm, shift: HZ.shift, alpha: HZ.alpha, variant: v, branches: r.branches, gens: V.gens, dark: V.dark, noise: null, exact: null, k: r.branches.length, k_gen: V.gens.length, n_dark: V.dark.length, kept: V.connected }); return true; } // shows a draw (generated, or a stored what-if opened from the study) in the panel; a stored one adds its answer key function showDraw(d) { HZ.draw = d; HZ.shown = { storm: d.storm, shift: d.shift, alpha: d.alpha }; syncStorm(); if (d.scenario != null) { const x = d.exact; $("#s-note").textContent = `One of the Monte Carlo's stored what-ifs: its load, wind, sun and prices came with it. Offline exact AC-OPF: ${x ? `${money(x.cost)}/h` : "no dispatch found"}. ` + "Change the track or the wind speed, or draw new damage, to leave it."; } else { const o = HZ.ens.operating_point; $("#s-note").textContent = `Random damage, kept only if the grid stays in one piece (${d.kept} of the ensemble's ${HZ.ens.n_draws} draws for this track and wind speed do). ` + `Load, wind, sun and prices start at the median of the ${o.n.toLocaleString("en-US")} stored hurricane what-ifs; set them below. This damage has no offline exact answer.`; } $("#storm-damage").textContent = "Lines or units added here go out on top of the storm's damage."; } // views that mean nothing for a source: a hurricane has no temperature field; the damaged zone belongs to hurricanes alone // (a real day's load is ERCOT's, one factor on every bus; the domes' load change is 2.5% x ΔT, which the ΔT view and its // tooltip already show). The highlight is re-placed after buttons hide. const HIDDEN_VIEWS = { storm: ["dT"], hist: ["load"], domes: ["load"] }; function sourceViews(src) { const hide = HIDDEN_VIEWS[src] || []; document.querySelectorAll("#layers [data-value]").forEach((b) => { b.hidden = hide.includes(b.dataset.value); }); if (hide.includes(ST.view)) ST.view = "loading"; C.layerSeg.set(ST.view); } // The storm card: what the storm took out (lines, units with their capacity, the damaged zone with its load), what GENCO // therefore sees (the same what-if without the damage, for the comparison), and apart, the operating point on the sliders. // Every number is computed from the knobs and the grid; the zone's buses are shown at the 70% share weather.js applies. function stormHtml(k) { const d = HZ.draw, G = ST.G, B = G.bus, U = G.gen, stored = d.scenario != null, en = (n) => n.toLocaleString("en-US"); const sum = (a) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]; return s; }; const S = scenarioArrays(G, k), N = scenarioArrays(G, { ...k, gens_out: [], branches_out: [], dark: [] }); const avail = (A) => { let s = 0; for (let g = 0; g < G.nGen; g++) if (A.in_service[g] === 1) s += A.pmax[g]; return s / 1000; }; const gw = (idx, a) => idx.reduce((t, i) => t + a[i], 0) / 1000; const lines = k.branches_out.length, addL = lines - d.branches.length, units = k.gens_out.length, addU = units - d.gens.length; const nl = sum(N.br_status), nlLeft = sum(S.br_status), nu = sum(N.in_service), nuLeft = sum(S.in_service); const cap = gw(k.gens_out, U.Pmax), allWind = k.gens_out.every((g) => G.isWind[g]); const loadGW = sum(B.Pd) * k.r / 1000, zoneGW = gw(k.dark, B.Pd) * k.r, zoneBuses = k.dark.filter((b) => B.Pd[b] !== 0).length; // the buses the map draws const dem = sum(S.Pd) / 1000, dem0 = sum(N.Pd) / 1000, gen = avail(S), gen0 = avail(N); const plural = (n, w) => `${w}${n === 1 ? "" : "s"}`, added = (n) => (n > 0 ? `; ${n} added here` : ""); const tile = (key, v, name, sub) => `
      ${v}
      ${name}
      ${sub}
      `; return `

      ${HZ.tracks[d.storm].label} · track ${shiftText(d.shift)} · wind speed ×${d.alpha.toFixed(1)}

      What the storm took out

      ${tile("lines", lines, `${plural(lines, "line")} out`, `of ${en(nl)}, dashed on the map${added(addL)}`)} ${tile("units", units, `${allWind ? "wind " : ""}${plural(units, allWind ? "farm" : "unit")} out`, `${cap.toFixed(2)} GW of capacity${added(addU)}`)} ${tile("zone", zoneBuses, "load buses in the damaged zone", `${zoneGW.toFixed(1)} GW of load (${Math.round(100 * zoneGW / loadGW)}% of the grid's); each bus keeps 40-100% of it`)}

      What GENCO sees

      GENCO never sees the wind field, only these inputs.${stored ? " For a stored what-if the engine applies each zone bus's own share and a per-bus load noise from the generator; the figures here use the 70% average." : ""}

      `; } // ================================================================ the "How it works" sheet // The page's four claims, each with its evidence (sources: WORK/D31/heat_scan_a{4,6,8}.json and weather_validate.json for // the 181 certified answers; trust_eval.json and trust_realdays.json for the bands); the proof strip opens them. const CLAIMS = [ ["data", "Real data", "On a real day the page takes that day's temperature anomaly (ERA5 via Open-Meteo, against the July-August 2012-2021 normal), ERCOT's load at the peak hour as a share of that summer's peak (EIA-930), wind and sun at that hour (ERA5) and the Henry Hub gas price: 52 days of four Texas heat waves. The grid is Texas A&M's Texas2k (ACTIVSg2000, case 1, 2016 summer peak, CC BY 4.0; PowerModels-corrected, every unit's minimum output set to 0), 2,000 buses and 544 units on Texas's geography: a synthetic model, not ERCOT's network. Heat domes replace the real heat by up to three synthetic ones. A hurricane what-if is one draw of Harvey's or Beryl's outage ensemble along NOAA's HURDAT2 track, shifted up to 50 km and scaled ×0.9-1.1 in wind speed: the lines and units its winds take out, and a damaged zone that keeps 40-100% of its load. The Monte Carlo uses 600 stored draws per storm, each with its exact AC-OPF as the answer key (no dispatch found on 31 and 20 of them); GENCO was trained on 12 other storms."], ["browser", "In your browser", "GENCO runs as ONNX (onnxruntime-web); the AC power flow, the check and the exact solver run as Python compiled to WebAssembly (Pyodide), in a Web Worker. Your what-ifs stay in your browser: nothing about them is sent to a server (only the basemap tiles come from CARTO), and no licensed solver is used."], ["certified", "Certified", "A point is certified when a full AC power flow of its dispatch meets every limit: line ratings within 1%, voltages within 0.005 p.u. of the 0.90-1.10 band, reactive and slack mismatches within 1 Mvar and 1 MW. The check covers the base case only (no N-1), and it does not depend on trusting GENCO. Against the exact reference solver, Ipopt, run offline, none of the 181 certified answers was wrong: Ipopt found a dispatch for each, on what-ifs that included 30 where it found none (infeasible or undecided). Not guaranteed: the optimum (on the real peak hours, the 27 points the check certified were 0.2-1.1% above the exact optimum, measured offline) and an answer for every what-if. Near the breaking point the check may give up; the page then solves the exact AC-OPF from GENCO's prediction. Measured offline on the 52 real peak hours (60 s limit; the page gives 30 s), that rescued all 14 the check missed: the cascade certified all 41 that have a dispatch, and none of the 11 where Ipopt found none (8 infeasible, 3 undecided). \"No dispatch found\" stays a flag, not a proof."], ["quantified", "Quantified", "Each line carries a 90% band of its loading at the optimum (split-conformal). On unseen heat-dome what-ifs the bands covered 90.5% of all line loadings, and 87-90% of the lines loaded above 90% of their rating (80-88% on hurricanes). Real days have bands of their own, calibrated on 129 other hot days of 2019-2024; on the 41 held-out real heat-wave days they covered 87% (84% at the preview), below the 90% target, so the page marks them as indicative. A hurricane's Monte Carlo study reports the share of damage draws left without a dispatch, with its 90% interval."], ]; // the sheet's tabs: what the page claims, the pipeline, how the weather reaches the model, the model and its results const HOW_TABS = [["Overview", "overview"], ["Pipeline", "pipeline"], ["Weather", "weather"], ["Model & results", "model"]]; function howPanels() { const c = ST.G.C, T = c.TRAINING, p = (x) => `${(x - 1) * 100 >= 0 ? "+" : ""}${((x - 1) * 100).toFixed(0)}%`; const claims = `
      ${CLAIMS.map(([id, h, t]) => `

      ${h}

      ${t}

      `).join("")}
      `; const steps = ORDER.map((n, i) => `
      ${i + 1}

      ${STEPS[n].name}${STEPS[n].sub}

      ${STEPS[n].long}

      `).join(""); const rel = [["Heat dome", "bus load", `× (1 + ${c.ALPHA} ΔT)`, p(1 + c.ALPHA * 8)], ["Heat dome", "line rating", `× √((${c.T_RATING} − ΔT)/${c.T_RATING})`, p(Math.sqrt((c.T_RATING - 8) / c.T_RATING))], ["Heat dome", "wind capacity", `× (1 − ΔT/${(1 / c.WIND_STALL).toFixed(0)})`, p(1 - c.WIND_STALL * 8)], ["Heat dome", "gas and coal capacity", `× (1 − ${c.THERMAL_DERATE} ΔT)`, p(1 - c.THERMAL_DERATE * 8)]]; const a = ST.app; return { overview: `

      GENCO, a neural-network grid foundation model, predicts a near-optimal dispatch of a 2,000-bus grid in about a second. Physics then certifies it, and calibrated bands say how far to trust each line. That makes AC optimal power flow fast enough for a browser tab, on real data.

      ${claims}`, pipeline: steps, weather: `

      GENCO never sees temperature. The heat changes the grid data it sees, through these relations (ΔT at each bus, capped at ${c.CAP_C} °C). On a real day the load and the wind are real data that already include the heat, so it acts through line ratings and thermal capacity only. A hurricane acts on the network itself: the lines and units out, and the damaged zone's lower loads.

      ${rel.map((r) => ``).join("")}
      WeatherGrid dataRelationAt +8 °C
      ${r[0]}${r[1]}${r[2]}${r[3]}

      Training ranges: load level ${T.r[0]}-${T.r[1]}, gas price ×${T.gas_m[0]}-${T.gas_m[1]}, coal ×${T.coal_m[0]}-${T.coal_m[1]}, up to ${T.k_branch[1]} lines and ${T.k_gen[1]} gas or coal units out.

      `, model: `

      ${a.model_description}. With the fast correction: heat, ${a.claim_heat}; storms, ${a.claim_storm}. ${a.settings_note}

      Checkpoint run ${a.model.run_id}.

      `, }; } // ================================================================ start async function main() { const [grid, app] = await Promise.all([fetch("assets/data/grid.json").then((r) => r.json()), fetch("assets/data/app.json").then((r) => r.json())]); fetch("assets/data/trust.json").then((r) => (r.ok ? r.json() : null)).then((t) => { TRUST = t; if (ST.res) render(false); }).catch(() => {}); const insights = fetch("assets/data/insights.json").then((r) => (r.ok ? r.json() : null)).catch(() => null); ST.G = loadGrid(grid); ST.app = app; ST.state = { genco: "idle", repair: "idle", check: "idle", exact: "idle" }; buildControls(); try { ST.map = new GridMap($("#map"), ST.G); } catch (e) { // no WebGL: the numbers, the rail and the studies still work console.warn("map:", e); ST.map = { render() {} }; $("#map").innerHTML = '

      This browser has no WebGL, so the map cannot be drawn. The rest of the page works.

      '; } C.layerSeg = segmented($("#layers"), VIEWS, ST.view, (v) => { ST.view = v; render(false); }, { label: "Map layer", id: "seg-layer", small: true }); toggle($("#gen-sw"), { label: "Generators", id: "sw-gen", onChange: (v) => { ST.showGen = v; render(false); } }); $("#rail").addEventListener("click", (e) => { if (e.target.closest(".i-btn")) return; const n = e.target.closest(".node.clickable"); if (n) { ST.sel = n.dataset.node; render(); } }); $("#rail").addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { const n = e.target.closest(".node.clickable"); if (n) { e.preventDefault(); ST.sel = n.dataset.node; render(); } } }); $("#b-reset").addEventListener("click", resetControls); $("#b-how").addEventListener("click", () => openHow()); document.querySelectorAll(".proof-i").forEach((b) => b.addEventListener("click", () => openHow(b.dataset.how))); $("#how-close").addEventListener("click", () => $("#how").close()); $("#how").addEventListener("click", (e) => { if (e.target === $("#how")) $("#how").close(); }); await setHeatSource(C.src); // the landing day's values before the first render INS = await insights; // what the what-ifs teach: the card and the bottleneck layer if (INS) { $("#insights").innerHTML = insightsHtml(); $("#insights").hidden = false; buildStudy(); } $("#insights").addEventListener("click", (e) => { const b = e.target.closest("[data-ins]"); if (b) insightAct(b.dataset.ins); }); ST.key = keyOf(knobs()); render(); document.body.dataset.ready = "1"; startEngine(); } // the How-it-works sheet: on its Overview, at one claim's paragraph when a proof item is clicked function openHow(claim) { const P = howPanels(), body = $("#how-body"); body.innerHTML = HOW_TABS.map(([, id]) => ``).join(""); const show = (id) => { body.querySelectorAll(".how-panel").forEach((x) => { x.hidden = x.dataset.tab !== id; }); $("#how .sheet-in").scrollTop = 0; }; if (!C.howSeg) C.howSeg = segmented($("#how-tabs"), HOW_TABS, "overview", show, { label: "How it works", id: "seg-how", small: true }); C.howSeg.set("overview"); show("overview"); $("#how").showModal(); const t = claim && document.getElementById(`how-${claim}`); if (t) { t.classList.add("hl"); t.scrollIntoView({ block: "start" }); } } main().catch((e) => { console.error(e); document.body.insertAdjacentHTML("afterbegin", `

      Failed to start: ${e.message}

      `); });