grid-lab / app_v2.js
cardef's picture
Remove the 'What the weather changed' card; only a hurricane keeps its inputs card
e211fef verified
Raw History Blame Contribute Delete
98.1 kB
// 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) => `<span class="badge ${cls}">${text}</span>`;
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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" }, "<summary>Adjust position</summary>");
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) => `<div class="kpi" data-k="${k}"><div class="kpi-v"></div><div class="kpi-l"></div></div>`).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<span class="kpi-d">${wait}</span>`], ["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 ? " <span class='mock'>· mock engine</span>" : "";
set("cost", "", `${P.optimum ? "Optimal" : "Dispatch"} cost<span class="kpi-d">${P.label}${mock}</span>`, P.cost, (v) => `${money(v)}<small>/h</small>`);
const mx = Number.isFinite(P.maxLoading) ? P.maxLoading : maxOf(P.loading);
const gateNote = P.feasible && mx > 1.0 && mx <= OVER + 1e-9 ? '<span class="kpi-d">(1% tolerance)</span>' : "";
if (mx != null) set("max", "", `Max line loading${gateNote}`, mx * 100, (v) => `${v.toFixed(1)}<small>%</small>`);
else set("max", "—", "Max line loading<span class='kpi-d'>not provided</span>");
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 step<span class='kpi-d'>a flag, not a proof</span>");
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 ? `<span class="kpi-d">${extra(P.setup)}, not counted</span>` : "";
if (Number.isFinite(P.fromSliders)) set("time", "", `Time from the sliders${setup}`, P.fromSliders / 1000, (v) => `${v.toFixed(2)}<small> s</small>`);
else set("time", "—", "Time from the sliders");
}
function chip(r, warn, text) {
r.className = warn ? "range-chip warn" : "range-chip";
r.innerHTML = `<span class="dot"></span><span class="txt"></span>`;
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 `<li class="node ${state}${can ? " clickable" : ""}${sel ? " sel" : ""}${linked ? " linked" : ""}" data-node="${id}" ${can ? 'tabindex="0" role="button"' : ""}>
<span class="ind">${icon}</span>
<div class="nb"><div class="nt"><span class="node-name">${name}<small>${sub}</small></span><button type="button" class="i-btn" aria-label="what this step does" data-tip="${tip}">i</button></div>
<div class="nk"><span class="node-key${muted ? " muted" : ""}${amber ? " amber" : ""}">${key}</span><span class="node-kl">${keyLabel || ""}</span></div>
<div class="nm"><span class="node-time">${time}</span></div></div></li>`;
}
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) => `<span class="lg"><span class="lg-tick">${a}</span><span class="lg-bar" style="background:linear-gradient(to right, ${cssRamp(stops)})"></span><span class="lg-tick">${b}</span></span>`;
const LG_XFO = `<span class="lg"><span class="lg-xfo"></span>overloaded transformer</span>`;
// 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) => `<div class="ins"><div class="ins-n">${n}</div><h3>${head}</h3><p>${body}</p>`
+ `<button type="button" class="tbtn" data-ins="${act}">${label} ${ARROW}</button></div>`;
return `<p class="ins-sub">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.</p>
<div class="ins-grid">
${tile(`${I.lines_for_80pct}<small>lines</small>`, "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}<small>of ${s8.n}</small>`, "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}<small>of ${R.n}</small>`, "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]}<small>of ${hv.by_shift["-50"][1]}</small>`, "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") : ""}
</div>
<div class="study" id="study">
<div class="study-h"><div><h3>Run a study</h3>
<p>A set of what-ifs run one after the other in this browser. Click a cell to open one above.</p></div></div>
<div class="study-ctl">
<div id="study-kind"></div>
<span class="study-opt" data-kind="days"><select id="study-event" class="select" aria-label="Heat wave"></select></span>
<span class="study-opt" data-kind="scan" hidden><select id="study-amp" class="select" aria-label="Dome"><option value="4">+4 °C dome</option><option value="6">+6 °C dome</option><option value="8" selected>+8 °C dome</option></select>
<select id="study-grid" class="select" aria-label="Places"><option value="2" selected>16 places</option><option value="1">67 places</option></select></span>
<span class="study-opt" data-kind="storm" hidden><select id="study-storm" class="select" aria-label="Storm"><option value="harvey" selected>Harvey (2017)</option><option value="beryl">Beryl (2024)</option></select>
<select id="study-shift" class="select" aria-label="Track">${[-50, -25, 0, 25, 50].map((v) => `<option value="${v}"${v === -50 ? " selected" : ""}>track ${shiftText(v)}</option>`).join("")}</select></span>
<button type="button" id="study-run" class="btn primary small">Run it</button>
</div>
<div id="study-prog" class="prog" role="progressbar" aria-label="Study progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" hidden><i></i></div>
<div id="study-desc" class="study-desc"></div>
<p id="study-key" class="strip-key"></p>
<ol id="study-strip" class="strip" aria-label="The study's what-ifs"></ol>
<p id="study-legend" class="strip-legend"></p>
<p id="study-sum" class="note study-sum"></p>
</div>
<div class="ins-map" hidden>
<div class="ins-map-h"><div><h3>Where this grid hits its limits, in the study</h3><p id="ins-map-sub"></p></div></div>
<div class="map-wrap ins-map-wrap"><div id="ins-map" class="map"></div><div class="map-over bottom"><div id="ins-legend" class="legend"></div></div></div>
<p id="ins-cap" class="map-cap"></p>
</div>`;
}
// ---------------------------------------------------------------- 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) => `<dt>${t}</dt><dd>${d}</dd>`, 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", `<b>${n} days</b> 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", `<b>${n} places</b> 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", `<b>${STORM_N} damage draws</b> 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", `<b>${n} damage draws</b> 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 ? `<dl class="study-dl">${html}</dl>` : ""; }
}
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
? `<span><i class="lg-s by-check"></i>certified live</span><span><i class="lg-s warn"></i>no dispatch found live</span>`
+ `<span class="lg-grp">Exact AC-OPF, offline:</span><span><b class="ref-feasible">✓</b>a dispatch exists</span><span><b class="ref-infeasible">✕</b>none found</span>`
+ (days ? `<span><b class="ref-undecided">?</b>undecided</span>` : "")
: "<span>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.</span>";
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 `<li class="sday ${r.state}${r.by ? ` by-${r.by}` : ""}" data-draw="${it.id}" title="${it.label} · ${rowText(r)} · ${REF_TEXT[it.ref]}" tabindex="0" role="button">
<span class="sd-barbox"><span class="sd-bar" style="height:${Math.round(4 + 24 * Math.min(1, it.k / 60))}px"></span></span><span class="sd-dot"></span>
<span class="sd-d">${it.k}</span><span class="sd-ref ref-${ok ? "feasible" : "infeasible"}">${ok ? "✓" : "✕"}</span></li>`;
}).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 `<li class="sday ${r.state}${r.by ? ` by-${r.by}` : ""}" data-day="${it.id}" title="${it.label} · ERCOT load ${Math.round(100 * it.load)}% of that summer's peak · ${rowText(r)} · ${REF_TEXT[it.ref] || "no offline verdict"}" tabindex="0" role="button">
<span class="sd-barbox"><span class="sd-bar" style="height:${Math.round(6 + 22 * Math.max(0, (it.load - 0.85) / 0.15))}px"></span></span><span class="sd-dot"></span>
<span class="sd-d">${dd}</span><span class="sd-ref ref-${it.ref || "none"}">${mark}</span></li>`;
}).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 = `<span class="st-live" data-n="${n}"><span data-c="i"></span> of ${n} ${unit} · <span data-c="t"></span> s · <span data-c="c"></span> certified by the check, `
+ `<span data-c="x"></span> by the exact step, <span data-c="w"></span> without a dispatch found.</span>`;
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 = `<b>${it.label}</b><br>${rowText(r)}${inc != null ? ` · +${inc.toFixed(1)}% cost` : ""}<br>${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 ? '<span class="lg"><span class="lg-line" style="background:rgba(99,108,114,.86)"></span>storm track</span>' : "")
+ `<span class="lg"><span class="lg-ring" style="border-color:#9A5604"></span>transformer</span>`
+ (STUDY.kind === "scan" ? `<span class="lg"><span class="lg-dot" style="background:#C77B04"></span>place: the dome's cost</span><span class="lg"><span class="lg-dot" style="background:#D03B3B"></span>no dispatch</span>` : "");
$("#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 = `<span class="lg"><span class="lg-line" style="background:#D03B3B;box-shadow:0 0 0 3px rgba(208,59,59,.18)"></span>overloaded (&gt; rating + 1%)</span>`;
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('<span class="lg"><span class="lg-dot" style="background:rgba(205,136,19,.4)"></span>heat anomaly</span>');
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(`<span class="lg"><span class="lg-ring"></span>voltage out of band · not certified</span>`);
const b = bandsFor(P, ST.sel);
if (b && nUncertain(P, b)) L.push(`<span class="lg"><span class="lg-line" style="background:repeating-linear-gradient(90deg,#EC835A 0 4px,transparent 4px 7px)"></span>may be at its limit (${C.src === "hist" ? "indicative band" : "90% band"})</span>`);
if (ST.showGen) L.push(`<span class="lg"><span class="lg-dot" style="background:${GEN_FILL}"></span>generators, by dispatch</span>`);
} else if (v === "dT") L.push(bar(DOT_STOPS, "0.5", "10 °C"), '<span class="lg quiet">no dot: under 0.5 °C</span>');
else if (v === "load") L.push(`<span class="lg"><span class="lg-dot" style="background:${SERIOUS}"></span>damaged zone, keeps 40-100% of its load</span>`);
else L.push('<span class="lg"><span class="lg-dot" style="background:#00A7C4"></span>wind & solar</span><span class="lg"><span class="lg-dot" style="background:#CD8813"></span>gas & coal</span><span class="lg"><span class="lg-dot" style="background:#B2B2B2"></span>hydro & nuclear</span><span class="lg"><span class="lg-cap"></span>disc: available · halo: nameplate</span>');
const k = knobs(), storm = C.src === "storm";
if (k.branches_out.length) L.push('<span class="lg"><span class="lg-dash"></span>line out</span>');
if (storm) L.push('<span class="lg"><span class="lg-line" style="background:rgba(99,108,114,.86)"></span>storm track</span>');
if (k.domes.length) L.push('<span class="lg"><span class="lg-dot" style="background:#FFE600;box-shadow:0 0 0 2px #231F20"></span>dome centre</span>');
$("#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 ? " · <span class='mock'>mock</span>" : "");
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 = `<p class="note">Could not load the hurricanes (${e.message}).</p>`; 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) => `<div class="stm-t" data-k="${key}"><div class="stm-v">${v}</div><div class="stm-n">${name}</div><div class="stm-s">${sub}</div></div>`;
return `<div class="stm" data-sig="${k.branches_out.join(",")}">
<p class="stm-lead">${HZ.tracks[d.storm].label} · track ${shiftText(d.shift)} · wind speed ×${d.alpha.toFixed(1)}</p>
<h3 class="stm-h">What the storm took out</h3>
<div class="stm-tiles">
${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); <span class="nb">each bus keeps 40-100% of it</span>`)}
</div>
<h3 class="stm-h">What GENCO sees</h3>
<ul class="stm-sees">
<li><b>Grid</b><span>${en(nlLeft)} of ${en(nl)} lines and ${en(nuLeft)} of ${en(nu)} units in service</span></li>
<li><b>Demand</b><span>${dem.toFixed(1)} GW, ${(dem0 - dem).toFixed(1)} GW less: the damaged zone keeps 70% of its load (the average of 40-100%)</span></li>
<li><b>Generation</b><span>${gen.toFixed(1)} GW available${units ? `, ${(gen0 - gen).toFixed(1)} GW less: the ${allWind ? "wind farms" : "units"} that are out` : ": no unit is out"}</span></li>
</ul>
<p class="note">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." : ""}</p>
</div>`;
}
// ================================================================ 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&amp;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 = `<div class="claims">${CLAIMS.map(([id, h, t]) => `<div class="claim" id="how-${id}"><h3>${h}</h3><p>${t}</p></div>`).join("")}</div>`;
const steps = ORDER.map((n, i) => `<div class="step"><div class="step-n">${i + 1}</div><div><h3>${STEPS[n].name}<small>${STEPS[n].sub}</small></h3><p>${STEPS[n].long}</p></div></div>`).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: `<p class="lede">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.</p>${claims}`,
pipeline: steps,
weather: `<p class="body">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.</p>
<div class="tblwrap"><table class="tbl"><tr><th>Weather</th><th>Grid data</th><th>Relation</th><th class="num">At +8 °C</th></tr>${rel.map((r) => `<tr><td>${r[0]}</td><td>${r[1]}</td><td>${r[2]}</td><td class="num">${r[3]}</td></tr>`).join("")}</table></div>
<p class="body">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.</p>`,
model: `<p class="body">${a.model_description}. With the fast correction: heat, ${a.claim_heat}; storms, ${a.claim_storm}. ${a.settings_note}</p>
<p class="body">Checkpoint run <code>${a.model.run_id}</code>.</p>`,
};
}
// ================================================================ 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 = '<p class="map-none">This browser has no WebGL, so the map cannot be drawn. The rest of the page works.</p>';
}
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]) => `<section class="how-panel" data-tab="${id}" hidden>${P[id]}</section>`).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", `<p style="color:#D03B3B;padding:16px">Failed to start: ${e.message}</p>`); });