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)];
}