Download app_v2.js from cardef/grid-lab: direct link, hf CLI and curl.
- Browser
- Download file 98.1 kB
-
https://huggingface.co/spaces/cardef/grid-lab/resolve/main/app_v2.js
- Command line
-
hf download hf://spaces/cardef/grid-lab/app_v2.js
-
curl -L -o app_v2.js https://huggingface.co/spaces/cardef/grid-lab/resolve/main/app_v2.js
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 (> 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&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>`); }); | |