File size: 11,065 Bytes
5b7eb93 dbdc890 5b7eb93 dbdc890 5b7eb93 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | // The page's side of the engine: a Web Worker (engine_worker.js) that runs GENCO (ONNX, onnxruntime-web) and the
// repair and fast correction (Pyodide). The contract is in ENGINE_CONTRACT.md; this file is its executable form.
//
// Knobs (JSON, the fields of whatif_app's whatif.scenario.Knobs dataclass):
// domes [[lat, lon, radius_km, amplitude_degC], ...] 0-3 heat domes (the UI offers 3)
// weather [[lat, lon, dT_degC], ...] a real-weather anomaly field (scenario.field_anomaly), [] for none
// heat_load_wind false: r and wind are real data that already include the heat (real days); default true
// r load level, share of the summer peak UI 0.5-1.1 (training 0.6-1.0)
// wind_area 8 numbers, wind availability per area 0-1, the case's area order (grid.json meta.areas)
// solar_area 8 numbers, solar availability per area 0-1
// gas_m, coal_m fuel price multipliers UI 0.25-5 and 0.25-2
// branches_out branch indices (0-based, case order) the network must stay connected (the page checks)
// gens_out unit indices (0-based, case order)
// noise null (always: the browser never draws the generator's per-bus load noise)
// dark [] (bus indices of a hurricane's damaged zone; the heat tab never sets it)
//
// Results (one per call; typed arrays are transferred, not copied):
// preview(knobs) -> {cost, max_loading, n_viol, viol_detail, feasible, time_ms,
// line_loading: Float32Array(n_branch), bus_vm: Float32Array(n_bus), gen_p: Float32Array(n_gen),
// v_out?: Int32Array} (v_out optional: buses out of band at the gate)
// check(knobs) -> the same, plus loops; or {status: "cancelled", loops: 0} when a newer request stopped it
// exact(knobs, {start: "check" | "genco" | "flat" | "mid", limit_s})
// -> {status: "optimal" | "not_certified" | "cancelled", start, cost, iterations, time_ms (solve only),
// build_ms, package_ms, total_ms, feasible, gap_vs_check, line_loading, bus_vm, gen_p}
// (the exact AC-OPF, interior point from the checked point (the check run first if needed;
// its time in check_ms), GENCO's prediction, or a standard start without GENCO; hard limit 20 s
// from the pipeline, 60 s from scratch; arrays may be NaN when not certified)
// cancel() -> stops a running exact solve, which then resolves with {status: "cancelled"}; a preview, check or
// sent while an exact is outstanding stops it the same way (Cancellation in the contract)
// preview results may also carry forward_ms, repair_ms, raw_max_mismatch_mw, raw_cost, raw_line_loading (GENCO's
// own prediction before the repair)
// init(opts) -> {ready, load_ms, threads, can_cancel, interrupt_buffer (SharedArrayBuffer of 2 Int32, or null)}
// progress <- {type: "progress", id, stage: "check" | "solve" | "setup"}: the request's timed work starts (check,
// exact's interior point) or its one-time setup does; passed to check()'s or exact()'s onProgress
// every result also gets rt_ms here: its round trip as the page saw it
// Units: cost $/h; loading as a fraction of the (heat-adjusted) rating, NaN for a branch out of service or without a
// rating; |V| p.u.; Pg MW (0 for units out); time_ms the engine's own time for the call.
export const WORKER_URL = "engine_worker.js";
export const WORKER_TYPE = "module"; // the worker is an ES module (import onnxruntime-web / pyodide.mjs)
// a private Space signs the page (?__sign=): the worker's requests pass only if the worker's own URL carries the signature
const SIGN = typeof location === "undefined" ? null : new URLSearchParams(location.search).get("__sign");
const signed = (u) => (SIGN ? `${u}${String(u).includes("?") ? "&" : "?"}__sign=${encodeURIComponent(SIGN)}` : u);
export function normalizeKnobs(k) {
const n = (x, d) => (Number.isFinite(+x) ? +x : d);
return {
domes: (k.domes || []).map((d) => d.map(Number)),
r: n(k.r, 0.9),
wind_area: (k.wind_area || Array(8).fill(0.5)).map(Number),
solar_area: (k.solar_area || Array(8).fill(0.5)).map(Number),
gas_m: n(k.gas_m, 1.0),
coal_m: n(k.coal_m, 1.0),
gens_out: [...new Set((k.gens_out || []).map(Number))].sort((a, b) => a - b),
branches_out: [...new Set((k.branches_out || []).map(Number))].sort((a, b) => a - b),
noise: Array.isArray(k.noise) && k.noise.length === 2 ? k.noise.map(Number) : null, // a stored hurricane draw: [seed, i]
dark: (k.dark || []).map(Number),
weather: (k.weather || []).map((p) => p.map(Number)),
heat_load_wind: k.heat_load_wind !== false,
};
}
// Throws if a result breaks the contract (used by the page in development and by tools/ui_test.py through the mock).
export function validateResult(r, dims, withLoops = false) {
const need = ["cost", "max_loading", "n_viol", "viol_detail", "feasible", "time_ms"];
for (const f of need) if (!(f in r)) throw new Error(`engine result lacks ${f}`);
const arr = [["line_loading", dims.nBranch], ["bus_vm", dims.nBus], ["gen_p", dims.nGen]];
for (const [f, len] of arr) {
if (!(r[f] instanceof Float32Array)) throw new Error(`${f} must be a Float32Array`);
if (r[f].length !== len) throw new Error(`${f} has ${r[f].length} entries, expected ${len}`);
}
if (withLoops && !Number.isInteger(r.loops)) throw new Error("check result lacks integer loops");
return r;
}
// exact() times (ENGINE_CONTRACT.md): time_ms is the solve only; build_ms the model build and package_ms the package load
// on this call (0 afterwards); total_ms the call's wall time. An engine that reports timing: {solve_s, build_s} instead
// is read the same way. time_ms is never adjusted by the build.
export function normalizeExact(r) {
if (!r || typeof r !== "object") return r;
const t = r.timing || {};
if (!Number.isFinite(r.build_ms) && Number.isFinite(t.build_s)) r.build_ms = t.build_s * 1000;
if (!Number.isFinite(r.time_ms)) {
if (Number.isFinite(t.solve_s)) r.time_ms = t.solve_s * 1000;
else if (Number.isFinite(r.total_ms)) r.time_ms = r.total_ms;
}
return r;
}
// An exact() result: a status always; when optimal, the cost and the three arrays as for a preview.
export function validateExact(r, dims) {
if (!r || !["optimal", "not_certified", "cancelled"].includes(r.status)) throw new Error("exact result lacks a valid status");
if (r.status === "optimal") {
for (const f of ["cost", "time_ms"]) if (!Number.isFinite(r[f])) throw new Error(`exact result lacks ${f}`);
for (const [f, len] of [["line_loading", dims.nBranch], ["bus_vm", dims.nBus], ["gen_p", dims.nGen]]) {
if (!(r[f] instanceof Float32Array) || r[f].length !== len) throw new Error(`exact ${f} must be a Float32Array(${len})`);
}
}
return r;
}
export class Engine {
constructor(url = WORKER_URL, type = WORKER_TYPE) {
this.worker = new Worker(signed(url), { type });
this.n = 0;
this.pending = new Map();
this.statusListeners = [];
this.ready = false;
this.iv = null; // the interrupt buffer: [cancel flag, cancellation counter]
this.slowOut = 0; // exact and check calls sent and not yet answered
this.worker.onmessage = (e) => this._onMessage(e.data);
this.worker.onerror = (e) => {
const err = new Error(`engine worker error: ${e.message || e}`);
for (const [, p] of this.pending) p.reject(err);
this.pending.clear();
this.statusListeners.forEach((cb) => cb({ type: "status", stage: "error", detail: err.message }));
};
}
onStatus(cb) { this.statusListeners.push(cb); }
_onMessage(m) {
if (m.type === "status") { this.statusListeners.forEach((cb) => cb(m)); return; }
const p = this.pending.get(m.id);
if (!p) return;
if (m.type === "progress") { if (p.onProgress) p.onProgress(m); return; } // {type: "progress", id, stage}
this.pending.delete(m.id);
// rt_ms: this request's round trip as the page saw it (queue, setup, the work, messaging); the result's own times
// say which part was the work
if (m.ok && m.result && typeof m.result === "object") m.result.rt_ms = performance.now() - p.t0;
if (m.ok) p.resolve(m.result); else p.reject(new Error(m.error || "engine error"));
}
_call(type, payload = {}, onProgress = null) {
const id = ++this.n;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject, onProgress, t0: performance.now() });
this.worker.postMessage({ id, type, ...payload });
});
}
async init(opts = {}) {
const r = await this._call("init", { opts });
this.ready = !!r.ready;
this.iv = r.interrupt_buffer ? new Int32Array(r.interrupt_buffer) : null;
return r;
}
// A newer request stops outstanding slow work (an exact; a check too where the engine supports it): bump the counter
// and raise the flag before posting it. A running solve polls the buffer at safe points; queued work whose gen is
// behind the counter is skipped. Writing the buffer when nothing reads it is harmless (ENGINE_CONTRACT.md).
_interrupt() { if (this.iv && this.slowOut > 0) { Atomics.add(this.iv, 1, 1); Atomics.store(this.iv, 0, 2); } }
_slow(type, payload, onProgress) {
const gen = this.iv ? Atomics.load(this.iv, 1) : 0;
this.slowOut += 1;
const done = () => { this.slowOut -= 1; };
const p = this._call(type, { ...payload, gen }, onProgress);
p.then(done, done);
return p;
}
preview(knobs) { this._interrupt(); return this._call("preview", { knobs: normalizeKnobs(knobs) }); }
// onProgress(m): called with {stage: "check" | "solve" | "setup"} when the work the result times starts
check(knobs, { onProgress = null } = {}) { this._interrupt(); return this._slow("check", { knobs: normalizeKnobs(knobs) }, onProgress); }
exact(knobs, opts = { start: "genco" }) {
const start = ["check", "genco", "flat", "mid"].includes(opts.start) ? opts.start : "genco";
const lim = Number.isFinite(opts.limit_s) ? { limit_s: opts.limit_s } : {}; // the solve's hard limit (engine default if absent)
return this._slow("exact", { knobs: normalizeKnobs(knobs), opts: { start, ...lim } }, opts.onProgress || null).then(normalizeExact);
}
// Stop a running exact solve (in practice the from-scratch one): it then resolves with {status: "cancelled", start}.
cancel() { this._interrupt(); return this._call("cancel", {}); }
}
// Transferable buffers of a result (the worker passes these to postMessage).
export function transferables(result) {
const out = [];
const walk = (o) => {
if (!o || typeof o !== "object") return;
if (ArrayBuffer.isView(o)) { out.push(o.buffer); return; }
for (const v of Object.values(o)) walk(v);
};
walk(result);
return [...new Set(out)];
}
|