@tellegen/engine 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -20
- package/dist/data/populated_places.json +1 -0
- package/dist/generated/{contracts.d.ts → api-types.d.ts} +26 -18
- package/dist/generated/api-types.d.ts.map +1 -0
- package/dist/generated/api-types.js +8 -0
- package/dist/generated/api-types.js.map +1 -0
- package/dist/generated/study-schema.d.ts +2 -0
- package/dist/generated/study-schema.d.ts.map +1 -0
- package/dist/generated/study-schema.js +3 -0
- package/dist/generated/study-schema.js.map +1 -0
- package/dist/generated/study-types.d.ts +426 -0
- package/dist/generated/study-types.d.ts.map +1 -0
- package/dist/generated/study-types.js +4 -0
- package/dist/generated/study-types.js.map +1 -0
- package/dist/generated/synth-schema.d.ts +2 -0
- package/dist/generated/synth-schema.d.ts.map +1 -0
- package/dist/generated/synth-schema.js +3 -0
- package/dist/generated/synth-schema.js.map +1 -0
- package/dist/generated/synth-types.d.ts +482 -0
- package/dist/generated/synth-types.d.ts.map +1 -0
- package/dist/generated/synth-types.js +4 -0
- package/dist/generated/synth-types.js.map +1 -0
- package/dist/host.d.ts +35 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +103 -17
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +549 -122
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +780 -109
- package/dist/index.js.map +1 -1
- package/dist/input-limit.d.ts +1 -1
- package/dist/input-limit.js +1 -1
- package/dist/module.d.ts +2 -0
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js.map +1 -1
- package/dist/protocol.d.ts +182 -31
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +153 -26
- package/dist/protocol.js.map +1 -1
- package/dist/study-document.d.ts +46 -0
- package/dist/study-document.d.ts.map +1 -0
- package/dist/study-document.js +183 -0
- package/dist/study-document.js.map +1 -0
- package/dist/study-document.test.d.ts +2 -0
- package/dist/study-document.test.d.ts.map +1 -0
- package/dist/study-document.test.js +119 -0
- package/dist/study-document.test.js.map +1 -0
- package/dist/wasm-pkg/LICENSE +17 -4
- package/dist/wasm-pkg/tellegen.d.ts +332 -85
- package/dist/wasm-pkg/tellegen.js +1324 -250
- package/dist/wasm-pkg/tellegen_bg.wasm +0 -0
- package/dist/wasm-pkg/tellegen_bg.wasm.d.ts +57 -9
- package/dist/worker.js +20 -5
- package/dist/worker.js.map +1 -1
- package/package.json +32 -11
- package/dist/generated/contracts.d.ts.map +0 -1
- package/dist/generated/contracts.js +0 -8
- package/dist/generated/contracts.js.map +0 -1
- package/dist/wasm-pkg/LICENSE-APACHE +0 -201
- package/dist/wasm-pkg/LICENSE-MIT +0 -19
package/dist/index.js
CHANGED
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
* browser allows one — a solve never blocks the page — and on the calling
|
|
3
3
|
* thread otherwise (see host.ts). Nothing downloads until the first engine
|
|
4
4
|
* call; dropped files are parsed locally and never leave the machine. */
|
|
5
|
-
import { engineHost } from "./host.js";
|
|
5
|
+
import { callTimed, engineHost, isolatedEngineHost, synthEngineHost, } from "./host.js";
|
|
6
6
|
import { isPermanentWasmLoadFailure } from "./errors.js";
|
|
7
7
|
import { assertEngineInputBytes, assertEngineInputLength, } from "./input-limit.js";
|
|
8
|
-
export {
|
|
8
|
+
export { API_SOURCE_SHA256, ENGINE_VERSION, FORMULATION_IDS, SOLVE_STATUSES, } from "./generated/api-types.js";
|
|
9
9
|
export { MAX_ENGINE_INPUT_BYTES } from "./input-limit.js";
|
|
10
|
+
/** Compact display spelling for a structured PowerIO finding. */
|
|
11
|
+
export function formatPowerIoDiagnostic(diagnostic) {
|
|
12
|
+
return `${diagnostic.code}: ${diagnostic.message}`;
|
|
13
|
+
}
|
|
10
14
|
export async function preloadEngine() {
|
|
11
15
|
await engineHost().call({ op: "preload" });
|
|
12
16
|
}
|
|
13
|
-
/** Classify JSON bytes through powerio's Rust routing table.
|
|
14
|
-
*
|
|
17
|
+
/** Classify JSON bytes through powerio's Rust routing table. A stored module
|
|
18
|
+
* passes the strict module reader so the payload family is authoritative. */
|
|
15
19
|
export async function classifyJson(bytes) {
|
|
16
20
|
assertEngineInputBytes(bytes);
|
|
17
21
|
return JSON.parse(expectText(await engineHost().call({ op: "classify_json", bytes })));
|
|
@@ -23,10 +27,16 @@ export async function ingestJsonDrop(bytes) {
|
|
|
23
27
|
assertEngineInputBytes(bytes);
|
|
24
28
|
return JSON.parse(expectText(await engineHost().call({ op: "ingest_json_drop", bytes })));
|
|
25
29
|
}
|
|
26
|
-
/** Asserts a request that must carry a payload actually did. */
|
|
30
|
+
/** Asserts a request that must carry a text payload actually did. */
|
|
27
31
|
function expectText(value) {
|
|
28
|
-
if (value
|
|
29
|
-
throw new Error("engine returned no payload");
|
|
32
|
+
if (typeof value !== "string")
|
|
33
|
+
throw new Error("engine returned no text payload");
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
/** Asserts a request that must carry a numeric array actually did. */
|
|
37
|
+
function expectFloat64(value) {
|
|
38
|
+
if (!(value instanceof Float64Array))
|
|
39
|
+
throw new Error("engine returned no numeric array");
|
|
30
40
|
return value;
|
|
31
41
|
}
|
|
32
42
|
/** powerio format token from a file name; null for non-case files. */
|
|
@@ -51,21 +61,6 @@ export async function ingestCase(bytes, format) {
|
|
|
51
61
|
assertEngineInputBytes(bytes);
|
|
52
62
|
return JSON.parse(expectText(await engineHost().call({ op: "ingest_case", bytes, format })));
|
|
53
63
|
}
|
|
54
|
-
/** Parse powerio's own model JSON (the `model-json` export, or the payload a
|
|
55
|
-
* package carries) for viewing. It is not a case format, so it does not go
|
|
56
|
-
* through `ingestCase`. */
|
|
57
|
-
export async function ingestModelJson(networkJson) {
|
|
58
|
-
assertEngineInputLength(networkJson.length);
|
|
59
|
-
return JSON.parse(expectText(await engineHost().call({
|
|
60
|
-
op: "ingest_model_json",
|
|
61
|
-
network_json: networkJson,
|
|
62
|
-
})));
|
|
63
|
-
}
|
|
64
|
-
/** Byte entry point for a dropped model JSON document. */
|
|
65
|
-
export async function ingestModelJsonBytes(bytes) {
|
|
66
|
-
assertEngineInputBytes(bytes);
|
|
67
|
-
return JSON.parse(expectText(await engineHost().call({ op: "ingest_model_json_bytes", bytes })));
|
|
68
|
-
}
|
|
69
64
|
/** Parse a multiconductor distribution case for viewing. `format` is a
|
|
70
65
|
* distribution reader token (`dss`, `bmopf`, `pmd`) or `pio` for a `.pio.json`
|
|
71
66
|
* package carrying a multiconductor payload. Rejects on malformed input or a
|
|
@@ -99,49 +94,339 @@ export async function parseGeo(bytes, hint) {
|
|
|
99
94
|
/** Apply a parsed layer onto a case network (matching by uid, external id,
|
|
100
95
|
* name, and the unordered branch endpoint pair). Rejects when nothing
|
|
101
96
|
* matched. */
|
|
102
|
-
export async function applyGeo(
|
|
97
|
+
export async function applyGeo(moduleJson, layer) {
|
|
98
|
+
assertEngineInputLength(moduleJson.length);
|
|
103
99
|
return JSON.parse(expectText(await engineHost().call({
|
|
104
100
|
op: "apply_geo",
|
|
105
|
-
|
|
101
|
+
module_json: moduleJson,
|
|
106
102
|
layer,
|
|
107
103
|
})));
|
|
108
104
|
}
|
|
105
|
+
/** Apply geographic or drawing coordinates to a multiconductor case. */
|
|
106
|
+
export async function applyMcGeo(moduleJson, layer) {
|
|
107
|
+
assertEngineInputLength(moduleJson.length);
|
|
108
|
+
const result = JSON.parse(expectText(await engineHost().call({
|
|
109
|
+
op: "apply_geo",
|
|
110
|
+
module_json: moduleJson,
|
|
111
|
+
layer,
|
|
112
|
+
})));
|
|
113
|
+
if (result.model !== "multiconductor")
|
|
114
|
+
throw new Error("Expected a multiconductor case");
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
109
117
|
/** Stamp a computed layout (bus id => [lon, lat]) onto a case network with
|
|
110
118
|
* its provenance, so saves and exports carry it. */
|
|
111
|
-
export async function applyLayout(
|
|
119
|
+
export async function applyLayout(moduleJson, coords, kind) {
|
|
120
|
+
assertEngineInputLength(moduleJson.length);
|
|
112
121
|
return JSON.parse(expectText(await engineHost().call({
|
|
113
122
|
op: "apply_layout",
|
|
114
|
-
|
|
123
|
+
module_json: moduleJson,
|
|
115
124
|
coords: JSON.stringify(coords),
|
|
116
125
|
kind,
|
|
117
126
|
})));
|
|
118
127
|
}
|
|
119
128
|
/** A case's coordinates as a canonical `.geo.json` document. Rejects when the
|
|
120
129
|
* case carries none. */
|
|
121
|
-
export async function extractGeo(
|
|
122
|
-
|
|
130
|
+
export async function extractGeo(moduleJson) {
|
|
131
|
+
assertEngineInputLength(moduleJson.length);
|
|
132
|
+
return expectText(await engineHost().call({ op: "extract_geo", module_json: moduleJson }));
|
|
123
133
|
}
|
|
124
134
|
/** Fill case coordinates from a PowerWorld `.pwd` sibling: substation symbols
|
|
125
135
|
* project to approximate longitude/latitude and join onto buses through the
|
|
126
136
|
* `SubNum` extras key. Rejects when no bus joined. */
|
|
127
|
-
export async function applyDisplayGeo(
|
|
137
|
+
export async function applyDisplayGeo(moduleJson, bytes) {
|
|
138
|
+
assertEngineInputLength(moduleJson.length);
|
|
128
139
|
assertEngineInputBytes(bytes);
|
|
129
140
|
return JSON.parse(expectText(await engineHost().call({
|
|
130
141
|
op: "apply_display_geo",
|
|
131
|
-
|
|
142
|
+
module_json: moduleJson,
|
|
132
143
|
bytes,
|
|
133
144
|
})));
|
|
134
145
|
}
|
|
135
146
|
export async function capabilities() {
|
|
136
147
|
return JSON.parse(expectText(await engineHost().call({ op: "capabilities" })));
|
|
137
148
|
}
|
|
138
|
-
export async function
|
|
149
|
+
export async function solveModule(moduleJson, request = {}) {
|
|
139
150
|
return JSON.parse(expectText(await engineHost().call({
|
|
140
|
-
op: "
|
|
141
|
-
|
|
151
|
+
op: "solve_module",
|
|
152
|
+
module_json: moduleJson,
|
|
142
153
|
request: JSON.stringify(request),
|
|
143
154
|
})));
|
|
144
155
|
}
|
|
156
|
+
/** Reduce a stored full result to the summary an interactive edit returns.
|
|
157
|
+
* Sums run in result order, matching the engine's own aggregation. */
|
|
158
|
+
export function summarizeMcPfResult(result, solveCount = 0) {
|
|
159
|
+
const sourcePower = { re: 0, im: 0 };
|
|
160
|
+
for (const reaction of result.source_reactions) {
|
|
161
|
+
sourcePower.re += reaction.power_into_network.re;
|
|
162
|
+
sourcePower.im += reaction.power_into_network.im;
|
|
163
|
+
}
|
|
164
|
+
const passiveLoss = { re: 0, im: 0 };
|
|
165
|
+
for (const port of result.element_ports) {
|
|
166
|
+
if (port.kind === "load" || port.kind === "generator" || port.kind === "ibr")
|
|
167
|
+
continue;
|
|
168
|
+
passiveLoss.re += port.power_into_element.re;
|
|
169
|
+
passiveLoss.im += port.power_into_element.im;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
converged: result.converged,
|
|
173
|
+
voltage_valid: result.voltage_valid,
|
|
174
|
+
min_voltage_pu: result.min_voltage_pu,
|
|
175
|
+
max_voltage_pu: result.max_voltage_pu,
|
|
176
|
+
voltage_violation_count: result.voltage_violations.length,
|
|
177
|
+
iterations: result.iterations,
|
|
178
|
+
factorization_count: result.factorization_count,
|
|
179
|
+
matrix_dimension: result.matrix_dimension,
|
|
180
|
+
matrix_nonzeros: result.matrix_nonzeros,
|
|
181
|
+
voltage_change: result.voltage_change,
|
|
182
|
+
physical_kcl_residual: result.physical_kcl_residual,
|
|
183
|
+
scaled_kcl_residual: result.scaled_kcl_residual,
|
|
184
|
+
terminal_count: result.terminals.length,
|
|
185
|
+
element_port_count: result.element_ports.length,
|
|
186
|
+
source_power_into_network: sourcePower,
|
|
187
|
+
passive_loss: passiveLoss,
|
|
188
|
+
solve_count: solveCount,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** The detail page {@link BrowserMcPfSession.detail} would return, read from
|
|
192
|
+
* a stored full result (a saved Study, or a solve without a session). */
|
|
193
|
+
export function mcPfResultDetail(result, query, solveCount = 0) {
|
|
194
|
+
const limit = query.port_limit || 20;
|
|
195
|
+
const offset = query.port_offset ?? 0;
|
|
196
|
+
const ports = query.element
|
|
197
|
+
? result.element_ports.filter((port) => port.element === query.element)
|
|
198
|
+
: result.element_ports;
|
|
199
|
+
return {
|
|
200
|
+
solve_count: solveCount,
|
|
201
|
+
terminals: query.bus
|
|
202
|
+
? result.terminals.filter((terminal) => terminal.bus === query.bus)
|
|
203
|
+
: [],
|
|
204
|
+
element_ports: ports.slice(offset, offset + limit),
|
|
205
|
+
element_port_total: ports.length,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** A live fixed-point current-injection solver retained in the shared engine
|
|
209
|
+
* worker. Load edits reuse its sparse LU and previous converged phasors and
|
|
210
|
+
* answer with the constant-size {@link McPfSummary}; terminal and equipment
|
|
211
|
+
* detail is fetched on demand, as bounded pages or numeric arrays. */
|
|
212
|
+
export class BrowserMcPfSession {
|
|
213
|
+
host;
|
|
214
|
+
handle;
|
|
215
|
+
initialSummary;
|
|
216
|
+
#terminalIds = null;
|
|
217
|
+
/** Breakdown of the most recent {@link replaceLoadPowers} call. */
|
|
218
|
+
lastTiming = null;
|
|
219
|
+
constructor(host, handle,
|
|
220
|
+
/** Summary of the operating point solved when the session was created. */
|
|
221
|
+
initialSummary) {
|
|
222
|
+
this.host = host;
|
|
223
|
+
this.handle = handle;
|
|
224
|
+
this.initialSummary = initialSummary;
|
|
225
|
+
}
|
|
226
|
+
async summary() {
|
|
227
|
+
return JSON.parse(expectText(await this.host.call({ op: "mc_pf_session_summary", session: this.handle })));
|
|
228
|
+
}
|
|
229
|
+
/** The complete terminal and equipment result. Its size grows with the
|
|
230
|
+
* network, so interactive views use {@link detail} instead. */
|
|
231
|
+
async result() {
|
|
232
|
+
return JSON.parse(expectText(await this.host.call({
|
|
233
|
+
op: "mc_pf_session_result",
|
|
234
|
+
session: this.handle,
|
|
235
|
+
})));
|
|
236
|
+
}
|
|
237
|
+
async loadBranches() {
|
|
238
|
+
return JSON.parse(expectText(await this.host.call({
|
|
239
|
+
op: "mc_pf_session_load_branches",
|
|
240
|
+
session: this.handle,
|
|
241
|
+
})));
|
|
242
|
+
}
|
|
243
|
+
/** Replace the absolute edit set and re-solve warm. Returns the summary
|
|
244
|
+
* and records its latency breakdown in {@link lastTiming}. */
|
|
245
|
+
async replaceLoadPowers(edits) {
|
|
246
|
+
const started = performance.now();
|
|
247
|
+
const answer = await callTimed(this.host, {
|
|
248
|
+
op: "mc_pf_session_replace_load_powers",
|
|
249
|
+
session: this.handle,
|
|
250
|
+
edits: JSON.stringify(edits),
|
|
251
|
+
});
|
|
252
|
+
const received = performance.now();
|
|
253
|
+
const text = expectText(answer.value);
|
|
254
|
+
const summary = JSON.parse(text);
|
|
255
|
+
this.lastTiming = {
|
|
256
|
+
engine_ms: answer.engineMs,
|
|
257
|
+
round_trip_ms: received - started,
|
|
258
|
+
parse_ms: performance.now() - received,
|
|
259
|
+
payload_chars: text.length,
|
|
260
|
+
};
|
|
261
|
+
return summary;
|
|
262
|
+
}
|
|
263
|
+
/** One bus's terminals and a page of equipment ports. */
|
|
264
|
+
async detail(query) {
|
|
265
|
+
return JSON.parse(expectText(await this.host.call({
|
|
266
|
+
op: "mc_pf_session_detail",
|
|
267
|
+
session: this.handle,
|
|
268
|
+
query: JSON.stringify(query),
|
|
269
|
+
})));
|
|
270
|
+
}
|
|
271
|
+
/** `[bus, terminal]` identities in the order of the terminal arrays. They
|
|
272
|
+
* are fixed for the session, so they are fetched once. */
|
|
273
|
+
terminalIds() {
|
|
274
|
+
this.#terminalIds ??= this.host
|
|
275
|
+
.call({ op: "mc_pf_session_terminal_ids", session: this.handle })
|
|
276
|
+
.then((value) => JSON.parse(expectText(value)));
|
|
277
|
+
return this.#terminalIds;
|
|
278
|
+
}
|
|
279
|
+
/** Terminal voltages as interleaved `[re, im]` volts. */
|
|
280
|
+
async terminalVoltages() {
|
|
281
|
+
return expectFloat64(await this.host.call({
|
|
282
|
+
op: "mc_pf_session_terminal_voltages",
|
|
283
|
+
session: this.handle,
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
/** Currents injected into the network as interleaved `[re, im]` amperes. */
|
|
287
|
+
async terminalCurrents() {
|
|
288
|
+
return expectFloat64(await this.host.call({
|
|
289
|
+
op: "mc_pf_session_terminal_currents",
|
|
290
|
+
session: this.handle,
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
/** Engine phase timings of the latest solve, or of the cold start. */
|
|
294
|
+
async profile(cold = false) {
|
|
295
|
+
return JSON.parse(expectText(await this.host.call({
|
|
296
|
+
op: "mc_pf_session_profile",
|
|
297
|
+
session: this.handle,
|
|
298
|
+
cold,
|
|
299
|
+
})));
|
|
300
|
+
}
|
|
301
|
+
/** Edited networks built for portable output; ordinary edits build none. */
|
|
302
|
+
async materializationCount() {
|
|
303
|
+
return Number(expectText(await this.host.call({
|
|
304
|
+
op: "mc_pf_session_materialization_count",
|
|
305
|
+
session: this.handle,
|
|
306
|
+
})));
|
|
307
|
+
}
|
|
308
|
+
async inputModule() {
|
|
309
|
+
return expectText(await this.host.call({
|
|
310
|
+
op: "mc_pf_session_input_module",
|
|
311
|
+
session: this.handle,
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
async snapshot(studyId, studyTitle) {
|
|
315
|
+
return JSON.parse(expectText(await this.host.call({
|
|
316
|
+
op: "mc_pf_session_snapshot",
|
|
317
|
+
session: this.handle,
|
|
318
|
+
study_id: studyId,
|
|
319
|
+
study_title: studyTitle,
|
|
320
|
+
})));
|
|
321
|
+
}
|
|
322
|
+
free() {
|
|
323
|
+
void this.host
|
|
324
|
+
.call({ op: "mc_pf_session_free", session: this.handle })
|
|
325
|
+
.catch(() => { });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
let mcPfSessionSeq = 0;
|
|
329
|
+
/** Parse, prepare, factor, and solve a persistent multiconductor PF session. */
|
|
330
|
+
export async function createMcPfSession(moduleJson, options = {}, signal) {
|
|
331
|
+
assertEngineInputLength(moduleJson.length);
|
|
332
|
+
signal?.throwIfAborted();
|
|
333
|
+
const host = engineHost();
|
|
334
|
+
const handle = ++mcPfSessionSeq;
|
|
335
|
+
const summary = JSON.parse(expectText(await host.call({
|
|
336
|
+
op: "mc_pf_session_new",
|
|
337
|
+
session: handle,
|
|
338
|
+
module_json: moduleJson,
|
|
339
|
+
options: JSON.stringify(options),
|
|
340
|
+
})));
|
|
341
|
+
const session = new BrowserMcPfSession(host, handle, summary);
|
|
342
|
+
if (signal?.aborted) {
|
|
343
|
+
session.free();
|
|
344
|
+
signal.throwIfAborted();
|
|
345
|
+
}
|
|
346
|
+
return session;
|
|
347
|
+
}
|
|
348
|
+
/** Live and peak heap of the shared engine, with its linear memory size. */
|
|
349
|
+
export async function engineMemoryStats() {
|
|
350
|
+
return JSON.parse(expectText(await engineHost().call({ op: "memory_stats" })));
|
|
351
|
+
}
|
|
352
|
+
/** Start a new peak-heap window in the shared engine. */
|
|
353
|
+
export async function resetEngineMemoryPeak() {
|
|
354
|
+
await engineHost().call({ op: "reset_peak_memory" });
|
|
355
|
+
}
|
|
356
|
+
/** Parse and solve a raw BMOPF multiconductor case in the wasm module. */
|
|
357
|
+
export async function solveMcBmopf(text, options = {}) {
|
|
358
|
+
assertEngineInputLength(text.length);
|
|
359
|
+
return JSON.parse(expectText(await engineHost().call({
|
|
360
|
+
op: "solve_mc_bmopf",
|
|
361
|
+
text,
|
|
362
|
+
options: JSON.stringify(options),
|
|
363
|
+
})));
|
|
364
|
+
}
|
|
365
|
+
/** Solve a stored PowerIO multiconductor module through the same PF engine. */
|
|
366
|
+
export async function solveMcModule(moduleJson, options = {}, signal) {
|
|
367
|
+
assertEngineInputLength(moduleJson.length);
|
|
368
|
+
signal?.throwIfAborted();
|
|
369
|
+
const host = isolatedEngineHost();
|
|
370
|
+
const abort = () => host.cancel?.(new Error("Multiconductor calculation cancelled"));
|
|
371
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
372
|
+
try {
|
|
373
|
+
const result = await host.call({
|
|
374
|
+
op: "solve_mc_module",
|
|
375
|
+
module_json: moduleJson,
|
|
376
|
+
options: JSON.stringify(options),
|
|
377
|
+
});
|
|
378
|
+
signal?.throwIfAborted();
|
|
379
|
+
return JSON.parse(expectText(result));
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
signal?.removeEventListener("abort", abort);
|
|
383
|
+
host.cancel?.(new Error("Multiconductor calculation finished"));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** Solve a supported multiconductor Study input and return a replayable
|
|
387
|
+
* snapshot containing the typed input, PowerIO solution, and rich result. */
|
|
388
|
+
export async function solveMcStudy(moduleJson, studyId, studyTitle, options = {}, signal) {
|
|
389
|
+
assertEngineInputLength(moduleJson.length);
|
|
390
|
+
signal?.throwIfAborted();
|
|
391
|
+
const host = isolatedEngineHost();
|
|
392
|
+
const abort = () => host.cancel?.(new Error("Multiconductor calculation cancelled"));
|
|
393
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
394
|
+
try {
|
|
395
|
+
const result = await host.call({
|
|
396
|
+
op: "solve_mc_study",
|
|
397
|
+
module_json: moduleJson,
|
|
398
|
+
study_id: studyId,
|
|
399
|
+
study_title: studyTitle,
|
|
400
|
+
options: JSON.stringify(options),
|
|
401
|
+
});
|
|
402
|
+
signal?.throwIfAborted();
|
|
403
|
+
return JSON.parse(expectText(result));
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
signal?.removeEventListener("abort", abort);
|
|
407
|
+
host.cancel?.(new Error("Multiconductor calculation finished"));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** Validate and canonicalize a saved multiconductor Study snapshot without
|
|
411
|
+
* running the solver again. */
|
|
412
|
+
export async function replayMcStudy(snapshotJson) {
|
|
413
|
+
assertEngineInputLength(snapshotJson.length);
|
|
414
|
+
return JSON.parse(expectText(await engineHost().call({
|
|
415
|
+
op: "replay_mc_study",
|
|
416
|
+
snapshot: snapshotJson,
|
|
417
|
+
})));
|
|
418
|
+
}
|
|
419
|
+
/** Update both saved modules with matching positions while retaining the solved values. */
|
|
420
|
+
export async function applyMcStudyGeo(snapshot, layer) {
|
|
421
|
+
const text = JSON.stringify(snapshot);
|
|
422
|
+
assertEngineInputLength(text.length);
|
|
423
|
+
assertEngineInputLength(layer.length);
|
|
424
|
+
return JSON.parse(expectText(await engineHost().call({
|
|
425
|
+
op: "apply_mc_study_geo",
|
|
426
|
+
snapshot: text,
|
|
427
|
+
layer,
|
|
428
|
+
})));
|
|
429
|
+
}
|
|
145
430
|
export { errorText } from "./errors.js";
|
|
146
431
|
/** True when the engine wasm module has failed to load in a way it can never
|
|
147
432
|
* recover from in this browser. Nothing solves client side when this is true;
|
|
@@ -178,7 +463,7 @@ function toEdits(deltas, rates) {
|
|
|
178
463
|
* ∂(price, active) / ∂(demand, active) for a bus target, or ∂(price, active) /
|
|
179
464
|
* ∂(line limit) for a branch target. The *formulation* is no longer fixed here — it is
|
|
180
465
|
* a parameter threaded from the UI's selector through `createStudy` (every formulation the
|
|
181
|
-
* full wasm build carries returns
|
|
466
|
+
* full wasm build carries returns nodal marginal values, so these columns apply to all of them). The
|
|
182
467
|
* operand/parameters stay centralized so `createStudy` and the Study's `commit`/`preview`
|
|
183
468
|
* requests read one source. */
|
|
184
469
|
const STUDY_CAPABILITY = {
|
|
@@ -193,6 +478,7 @@ const STUDY_CAPABILITY = {
|
|
|
193
478
|
* is the menu order; `dcopf` is first and is the default. */
|
|
194
479
|
export const FORMULATIONS = [
|
|
195
480
|
{ id: "dcopf", label: "DC OPF", hint: "DC optimal power flow (the default)" },
|
|
481
|
+
{ id: "acpf", label: "AC power flow", hint: "Balanced AC power flow" },
|
|
196
482
|
{
|
|
197
483
|
id: "socwr",
|
|
198
484
|
label: "SOCWR",
|
|
@@ -205,12 +491,12 @@ export const FORMULATIONS = [
|
|
|
205
491
|
disabled: true,
|
|
206
492
|
},
|
|
207
493
|
];
|
|
208
|
-
/**
|
|
494
|
+
/** DC OPF is the default calculation. */
|
|
209
495
|
export const DEFAULT_FORMULATION = "dcopf";
|
|
210
|
-
/** The `Operand[]` JSON `Study.preview` watches (the
|
|
496
|
+
/** The `Operand[]` JSON `Study.preview` watches (the active nodal value column). */
|
|
211
497
|
const PREVIEW_OPERANDS_JSON = JSON.stringify([STUDY_CAPABILITY.operand]);
|
|
212
|
-
/** The `SensRequest[]` JSON for `Study.commit`: the
|
|
213
|
-
* index, or the
|
|
498
|
+
/** The `SensRequest[]` JSON for `Study.commit`: the nodal-value/demand column at a dense bus
|
|
499
|
+
* index, or the nodal-value/rating column at a dense branch index, or `[]` when there is no
|
|
214
500
|
* target. NOTE: `SensRequest.indices` are **dense positional** indices into the target's
|
|
215
501
|
* axis (0-based), *not* external ids — `BrowserStudy` translates the selected external id
|
|
216
502
|
* to its dense index before calling this. */
|
|
@@ -227,7 +513,7 @@ function sensitivitiesJson(target) {
|
|
|
227
513
|
},
|
|
228
514
|
]);
|
|
229
515
|
}
|
|
230
|
-
/** Extract the
|
|
516
|
+
/** Extract the nodal-value/parameter column from the first requested `SensitivityMatrix` into
|
|
231
517
|
* the `SensitivityColumn` the map and legend consume, the same shape the server serves.
|
|
232
518
|
* Rows are Price operands per bus; the single column is the selected parameter, so the
|
|
233
519
|
* column is `values[r][0]` keyed by each row's source bus id, and the source element is
|
|
@@ -242,7 +528,7 @@ function sensitivityColumn(caseId, matrices) {
|
|
|
242
528
|
const values = m.rows
|
|
243
529
|
.map((row, r) => ({ bus: row.element.Bus, value: m.values[r]?.[0] ?? 0 }))
|
|
244
530
|
.filter((v) => v.bus !== undefined);
|
|
245
|
-
const shared = { case: caseId, operand: "
|
|
531
|
+
const shared = { case: caseId, operand: "price", units: m.units, values };
|
|
246
532
|
if (el.Bus !== undefined) {
|
|
247
533
|
return { ...shared, parameter: "d", bus: el.Bus };
|
|
248
534
|
}
|
|
@@ -253,8 +539,9 @@ function sensitivityColumn(caseId, matrices) {
|
|
|
253
539
|
}
|
|
254
540
|
function solveResponseToSolution(out) {
|
|
255
541
|
return {
|
|
256
|
-
objective: out.objective ??
|
|
257
|
-
|
|
542
|
+
objective: out.objective ?? null,
|
|
543
|
+
prices: (out.lmp ?? []).map((e) => ({ bus: e.bus, value: e.value })),
|
|
544
|
+
vm: out.vm ?? undefined,
|
|
258
545
|
va: out.va ?? [],
|
|
259
546
|
w: out.w ?? [],
|
|
260
547
|
flows: (out.flows ?? []).map((f) => ({
|
|
@@ -262,11 +549,15 @@ function solveResponseToSolution(out) {
|
|
|
262
549
|
mw: f.pf,
|
|
263
550
|
loading: f.loading,
|
|
264
551
|
})),
|
|
265
|
-
dispatch: (out.dispatch ?? []).map((d) => ({
|
|
552
|
+
dispatch: (out.dispatch ?? []).map((d) => ({
|
|
553
|
+
gen: d.gen,
|
|
554
|
+
bus: d.bus,
|
|
555
|
+
mw: d.pg,
|
|
556
|
+
})),
|
|
266
557
|
};
|
|
267
558
|
}
|
|
268
|
-
/**
|
|
269
|
-
* parsed and the model built when the Study is created; `commit` solves exactly
|
|
559
|
+
/** Browser transport for the reactive demand drag. The retained PowerIO module
|
|
560
|
+
* is parsed and the model built when the Study is created; `commit` solves exactly
|
|
270
561
|
* at the UI's absolute demand delta state and `preview` returns a first-order
|
|
271
562
|
* linearization toward an absolute demand delta state, neither re-parsing the
|
|
272
563
|
* network. The wasm Study lives behind the engine host (a dedicated worker
|
|
@@ -275,36 +566,56 @@ export class BrowserStudy {
|
|
|
275
566
|
#host;
|
|
276
567
|
/** The caller-allocated handle naming the wasm Study on the host. */
|
|
277
568
|
#handle;
|
|
569
|
+
#formulation;
|
|
570
|
+
#planningHost;
|
|
278
571
|
/** External bus id -> dense positional bus index, built once from the committed solution's
|
|
279
|
-
*
|
|
572
|
+
* nodal value ordering (each wire `lmp[i].bus` sits at dense index `i`). The engine keys `SensRequest`
|
|
280
573
|
* by this dense index, not the external bus id, so the selected bus must be translated
|
|
281
574
|
* before a sensitivity request. Null until first needed; the bus set is solve-invariant. */
|
|
282
575
|
#busToIndex = null;
|
|
283
576
|
/** External branch id -> dense positional branch index, from the committed solution's
|
|
284
577
|
* flows ordering — the branch-axis counterpart of `#busToIndex`. */
|
|
285
578
|
#branchToIndex = null;
|
|
286
|
-
constructor(host, handle) {
|
|
579
|
+
constructor(host, handle, formulation = DEFAULT_FORMULATION, planningHost = isolatedEngineHost) {
|
|
287
580
|
this.#host = host;
|
|
288
581
|
this.#handle = handle;
|
|
582
|
+
this.#formulation = formulation;
|
|
583
|
+
this.#planningHost = planningHost;
|
|
289
584
|
}
|
|
290
585
|
async #solution() {
|
|
291
586
|
return JSON.parse(expectText(await this.#host.call({ op: "study_solution", study: this.#handle })));
|
|
292
587
|
}
|
|
293
|
-
async #replaceEdits(deltas, rates, target) {
|
|
588
|
+
async #replaceEdits(deltas, rates, target, signal) {
|
|
294
589
|
const sensitivities = sensitivitiesJson(await this.#senseTarget(target));
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
590
|
+
signal?.throwIfAborted();
|
|
591
|
+
const cancel = () => {
|
|
592
|
+
this.#host.cancel?.(new DOMException("solve cancelled", "AbortError"));
|
|
593
|
+
};
|
|
594
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
595
|
+
try {
|
|
596
|
+
const result = await this.#host.call({
|
|
597
|
+
op: "study_replace_edits",
|
|
598
|
+
study: this.#handle,
|
|
599
|
+
edits: JSON.stringify(toEdits(deltas, rates)),
|
|
600
|
+
sensitivities,
|
|
601
|
+
});
|
|
602
|
+
signal?.throwIfAborted();
|
|
603
|
+
return JSON.parse(expectText(result));
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
signal?.throwIfAborted();
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
signal?.removeEventListener("abort", cancel);
|
|
611
|
+
}
|
|
301
612
|
}
|
|
302
613
|
/** The dense-axis sensitivity target the engine expects for a selection (external
|
|
303
614
|
* ids), or null when nothing is selected or the id is unknown. Memoizes the
|
|
304
|
-
* id->index maps from the committed solution's
|
|
615
|
+
* id->index maps from the committed solution's nodal value and flow order — the same axis
|
|
305
616
|
* orders the engine's dense sensitivity columns use. */
|
|
306
617
|
async #senseTarget(target) {
|
|
307
|
-
if (target === null)
|
|
618
|
+
if (target === null || this.#formulation === "acpf")
|
|
308
619
|
return null;
|
|
309
620
|
if ("bus" in target) {
|
|
310
621
|
if (!this.#busToIndex) {
|
|
@@ -322,57 +633,135 @@ export class BrowserStudy {
|
|
|
322
633
|
return index === undefined ? null : { kind: "branch", index };
|
|
323
634
|
}
|
|
324
635
|
/** Exact solve at demand = base + `deltas` and ratings = base + `rates`, replacing
|
|
325
|
-
* the committed point. When `target` names a bus or branch, its
|
|
636
|
+
* the committed point. When `target` names a bus or branch, its nodal-value/parameter
|
|
326
637
|
* column is computed in the *same* solve and returned (no second round-trip);
|
|
327
638
|
* otherwise `sensitivity` is null. `caseId` labels the returned column. Returns the
|
|
328
639
|
* UI Solution, the solver iterates, and the column. */
|
|
329
|
-
async commit(caseId, deltas, rates, target) {
|
|
330
|
-
const out = await this.#replaceEdits(deltas, rates, target);
|
|
640
|
+
async commit(caseId, deltas, rates, target, signal) {
|
|
641
|
+
const out = await this.#replaceEdits(deltas, rates, target, signal);
|
|
331
642
|
return {
|
|
332
643
|
solution: solveResponseToSolution(out.solution),
|
|
333
644
|
iterations: out.iterations ?? [],
|
|
334
645
|
sensitivity: target === null ? null : sensitivityColumn(caseId, out.sensitivities),
|
|
335
646
|
};
|
|
336
647
|
}
|
|
337
|
-
/** The
|
|
648
|
+
/** The nodal-value/parameter column at `target` for this study's formulation, computed by
|
|
338
649
|
* an exact re-solve at the edited point (the same one-call path `commit` uses, but
|
|
339
650
|
* returning only the column). Used when a bus or branch is selected so the overlay
|
|
340
651
|
* matches the active formulation rather than always the DC sensitivity. `caseId`
|
|
341
652
|
* labels the column; returns null when no recognized column comes back. */
|
|
342
653
|
async sensitivity(caseId, deltas, rates, target) {
|
|
654
|
+
if (this.#formulation === "acpf")
|
|
655
|
+
return null;
|
|
343
656
|
const out = await this.#replaceEdits(deltas, rates, target);
|
|
344
657
|
return sensitivityColumn(caseId, out.sensitivities);
|
|
345
658
|
}
|
|
346
|
-
/** First
|
|
659
|
+
/** First order nodal value preview for replacing the committed point with
|
|
347
660
|
* demand = base + `deltas` and ratings = base + `rates`, with no re-solve:
|
|
348
|
-
* predicted per-bus
|
|
661
|
+
* predicted per-bus nodal value changes and the predicted objective change. */
|
|
349
662
|
async preview(deltas, rates = {}) {
|
|
663
|
+
if (this.#formulation === "acpf") {
|
|
664
|
+
return { prices: [], objectiveDelta: null, units: null };
|
|
665
|
+
}
|
|
350
666
|
const out = JSON.parse(expectText(await this.#host.call({
|
|
351
667
|
op: "study_preview",
|
|
352
668
|
study: this.#handle,
|
|
353
669
|
edits: JSON.stringify(toEdits(deltas, rates)),
|
|
354
670
|
operands: PREVIEW_OPERANDS_JSON,
|
|
355
671
|
})));
|
|
356
|
-
const
|
|
672
|
+
const prices = (out.operands[0]?.values ?? [])
|
|
357
673
|
.filter((v) => v.element.Bus !== undefined)
|
|
358
|
-
.map((v) => ({ bus: v.element.Bus,
|
|
359
|
-
return {
|
|
674
|
+
.map((v) => ({ bus: v.element.Bus, value: v.value }));
|
|
675
|
+
return {
|
|
676
|
+
prices,
|
|
677
|
+
objectiveDelta: out.objective_delta,
|
|
678
|
+
units: out.operands[0]?.units ?? null,
|
|
679
|
+
};
|
|
360
680
|
}
|
|
361
681
|
/** The Study's current exact solution. Called immediately after Study creation
|
|
362
682
|
* to cache the base point for formulation comparisons. */
|
|
683
|
+
async currentView() {
|
|
684
|
+
return JSON.parse(expectText(await this.#host.call({ op: "study_solution", study: this.#handle })));
|
|
685
|
+
}
|
|
363
686
|
async currentSolution() {
|
|
364
687
|
return solveResponseToSolution(await this.#solution());
|
|
365
688
|
}
|
|
366
|
-
/**
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
689
|
+
/** Materialize the current case as generation 2 PowerIO IR. */
|
|
690
|
+
async saveModule() {
|
|
691
|
+
return expectText(await this.#host.call({ op: "study_save_module", study: this.#handle }));
|
|
692
|
+
}
|
|
693
|
+
/** Preserve the materialized instance's inner objective and constraints in PowerIO IR. */
|
|
694
|
+
async saveInstanceModule() {
|
|
695
|
+
return expectText(await this.#host.call({
|
|
696
|
+
op: "study_save_instance_module",
|
|
697
|
+
study: this.#handle,
|
|
698
|
+
}));
|
|
699
|
+
}
|
|
700
|
+
/** Serialize the current exact DC OPF result as a PowerIO solution module.
|
|
701
|
+
* Its embedded problem instance contains the materialized case that was
|
|
702
|
+
* solved. */
|
|
703
|
+
async saveSolutionModule() {
|
|
704
|
+
return expectText(await this.#host.call({
|
|
705
|
+
op: "study_save_solution_module",
|
|
706
|
+
study: this.#handle,
|
|
707
|
+
}));
|
|
708
|
+
}
|
|
709
|
+
/** Write the current materialized case to one PowerIO target format. */
|
|
710
|
+
async export(format) {
|
|
711
|
+
return JSON.parse(expectText(await this.#host.call({
|
|
712
|
+
op: "study_export",
|
|
713
|
+
study: this.#handle,
|
|
714
|
+
format,
|
|
715
|
+
})));
|
|
716
|
+
}
|
|
717
|
+
/** Run a bounded differentiable planning search over the committed
|
|
718
|
+
* operating point. Read only: the committed case, edits, and revision are
|
|
719
|
+
* untouched; the returned outcome carries the unapplied proposal and the
|
|
720
|
+
* search trace (gradient, per step prediction, exact result, first
|
|
721
|
+
* order error, accept or reject reason). Rejects on a non-DC formulation
|
|
722
|
+
* or an unknown element key. Worker hosts stop the solve on cancellation;
|
|
723
|
+
* the synchronous fallback finishes the wasm call but rejects its result. */
|
|
724
|
+
async plan(spec, signal) {
|
|
725
|
+
signal?.throwIfAborted();
|
|
726
|
+
const moduleJson = await this.saveModule();
|
|
727
|
+
signal?.throwIfAborted();
|
|
728
|
+
const planningStudy = await createStudyOnHost(moduleJson, this.#formulation, this.#planningHost(), signal);
|
|
729
|
+
try {
|
|
730
|
+
return await planningStudy.#planInPlace(spec, signal);
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
planningStudy.free();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/** Run planning on this disposable Study. Public callers go through
|
|
737
|
+
* `plan`, which first clones the committed PowerIO module onto an isolated
|
|
738
|
+
* host so cancellation cannot invalidate the interactive Study. */
|
|
739
|
+
async #planInPlace(spec, signal) {
|
|
740
|
+
signal?.throwIfAborted();
|
|
741
|
+
const cancel = () => {
|
|
742
|
+
this.#host.cancel?.(new DOMException("planning cancelled", "AbortError"));
|
|
743
|
+
};
|
|
744
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
745
|
+
try {
|
|
746
|
+
const result = await this.#host.call({
|
|
747
|
+
op: "study_plan",
|
|
748
|
+
study: this.#handle,
|
|
749
|
+
spec: JSON.stringify(spec),
|
|
750
|
+
});
|
|
751
|
+
signal?.throwIfAborted();
|
|
752
|
+
return JSON.parse(expectText(result));
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
signal?.throwIfAborted();
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
finally {
|
|
759
|
+
signal?.removeEventListener("abort", cancel);
|
|
760
|
+
}
|
|
372
761
|
}
|
|
373
762
|
/** Apply a parsed geographic layer (the canonical `.geo.json` from
|
|
374
763
|
* `parseGeo` or `applyLayout`) onto this study's base network, so the next
|
|
375
|
-
* `
|
|
764
|
+
* `saveModule` or export carries the coordinates on screen. Locations are
|
|
376
765
|
* metadata the model never reads; nothing re-solves. */
|
|
377
766
|
async applyGeoLayer(layer) {
|
|
378
767
|
return JSON.parse(expectText(await this.#host.call({
|
|
@@ -382,7 +771,7 @@ export class BrowserStudy {
|
|
|
382
771
|
})));
|
|
383
772
|
}
|
|
384
773
|
/** Release the wasm Study; call when discarding it (e.g. the case's
|
|
385
|
-
*
|
|
774
|
+
* module input changed, or the case was removed). Fire and forget: the
|
|
386
775
|
* handle is invalid from this call on. */
|
|
387
776
|
free() {
|
|
388
777
|
void this.#host
|
|
@@ -390,73 +779,355 @@ export class BrowserStudy {
|
|
|
390
779
|
.catch(() => { });
|
|
391
780
|
}
|
|
392
781
|
}
|
|
393
|
-
/** Construct a
|
|
394
|
-
* and solving the base case once. `formulation` is a `Problem` tag (`dcopf`/`acopf`/`socwr`,
|
|
782
|
+
/** Construct a Study from a retained PowerIO module for `formulation`, parsing its
|
|
783
|
+
* problem instance and solving the base case once. `formulation` is a `Problem` tag (`dcopf`/`acopf`/`socwr`,
|
|
395
784
|
* defaulting to DC OPF); the full wasm build solves every one entirely in the browser.
|
|
396
785
|
* Throws if the engine module can't load (or the formulation is unknown/not built); the
|
|
397
786
|
* caller must catch and fall back (see `isPermanentEngineFailure`). */
|
|
398
787
|
let studySeq = 0;
|
|
399
|
-
export async function createStudy(
|
|
400
|
-
const host = engineHost();
|
|
788
|
+
export async function createStudy(moduleJson, formulation = DEFAULT_FORMULATION, options = {}) {
|
|
789
|
+
const host = options.isolated ? isolatedEngineHost() : engineHost();
|
|
790
|
+
return createStudyOnHost(moduleJson, formulation, host, options.signal);
|
|
791
|
+
}
|
|
792
|
+
async function createStudyOnHost(moduleJson, formulation, host, signal) {
|
|
401
793
|
// Handles are allocated here, not by the host, so a pending build replayed
|
|
402
794
|
// onto the fallback host keeps naming the same study.
|
|
403
795
|
const handle = ++studySeq;
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
study
|
|
407
|
-
|
|
408
|
-
|
|
796
|
+
signal?.throwIfAborted();
|
|
797
|
+
const cancel = () => {
|
|
798
|
+
host.cancel?.(new DOMException("study creation cancelled", "AbortError"));
|
|
799
|
+
};
|
|
800
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
801
|
+
try {
|
|
802
|
+
await host.call({
|
|
803
|
+
op: "study_new",
|
|
804
|
+
study: handle,
|
|
805
|
+
module_json: moduleJson,
|
|
806
|
+
formulation,
|
|
807
|
+
});
|
|
808
|
+
signal?.throwIfAborted();
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
signal?.throwIfAborted();
|
|
812
|
+
throw error;
|
|
813
|
+
}
|
|
814
|
+
finally {
|
|
815
|
+
signal?.removeEventListener("abort", cancel);
|
|
816
|
+
}
|
|
817
|
+
return new BrowserStudy(host, handle, formulation);
|
|
818
|
+
}
|
|
819
|
+
/** A refusal from the generator, with the code a caller switches on:
|
|
820
|
+
* `invalid_request`, `region`, `places`, `osm`, `osm_insufficient` (an
|
|
821
|
+
* Overpass document too thin to build from, with its bus and branch counts),
|
|
822
|
+
* `build`, `engine`, `state`, `cancelled`. */
|
|
823
|
+
export class SynthEngineError extends Error {
|
|
824
|
+
code;
|
|
825
|
+
buses;
|
|
826
|
+
branches;
|
|
827
|
+
constructor(payload) {
|
|
828
|
+
super(payload.message);
|
|
829
|
+
this.name = "SynthEngineError";
|
|
830
|
+
this.code = payload.code;
|
|
831
|
+
if (payload.buses != null)
|
|
832
|
+
this.buses = payload.buses;
|
|
833
|
+
if (payload.branches != null)
|
|
834
|
+
this.branches = payload.branches;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/** The engine reports generator refusals as their JSON payload. */
|
|
838
|
+
function synthError(error) {
|
|
839
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
840
|
+
try {
|
|
841
|
+
const payload = JSON.parse(message);
|
|
842
|
+
if (typeof payload?.code === "string" && typeof payload.message === "string") {
|
|
843
|
+
return new SynthEngineError(payload);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
// Not a generator payload: a host or transport failure.
|
|
848
|
+
}
|
|
849
|
+
return error;
|
|
850
|
+
}
|
|
851
|
+
async function callSynth(host, req) {
|
|
852
|
+
try {
|
|
853
|
+
return await host.call(req);
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
throw synthError(error);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/** URL of the places table shipped beside this module (Natural Earth
|
|
860
|
+
* populated places, public domain). Resolved on use, like the wasm asset. */
|
|
861
|
+
export function syntheticPlacesUrl() {
|
|
862
|
+
return new URL("./data/populated_places.json", import.meta.url).href;
|
|
863
|
+
}
|
|
864
|
+
let placesBytes = null;
|
|
865
|
+
/** Fetch the shipped places table, once per page. */
|
|
866
|
+
export function fetchSyntheticPlaces() {
|
|
867
|
+
placesBytes ??= fetch(syntheticPlacesUrl())
|
|
868
|
+
.then(async (response) => {
|
|
869
|
+
if (!response.ok)
|
|
870
|
+
throw new Error(`places table: HTTP ${response.status}`);
|
|
871
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
872
|
+
})
|
|
873
|
+
.catch((error) => {
|
|
874
|
+
// A failed fetch must not disable generation for the whole session.
|
|
875
|
+
placesBytes = null;
|
|
876
|
+
throw error;
|
|
409
877
|
});
|
|
410
|
-
return
|
|
878
|
+
return placesBytes;
|
|
879
|
+
}
|
|
880
|
+
/** Hosts holding a places table, with the thread kind they loaded it on (a
|
|
881
|
+
* worker that fell back to the page needs it loaded again there). */
|
|
882
|
+
const placesLoaded = new WeakMap();
|
|
883
|
+
async function loadPlacesOn(host, bytes) {
|
|
884
|
+
assertEngineInputBytes(bytes);
|
|
885
|
+
const info = JSON.parse(expectText(await callSynth(host, { op: "synth_load_places", bytes })));
|
|
886
|
+
placesLoaded.set(host, host.kind);
|
|
887
|
+
return info;
|
|
411
888
|
}
|
|
412
|
-
/**
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
export async function
|
|
889
|
+
/** Load a places table onto the synthetic generation host. Sessions load the
|
|
890
|
+
* shipped table on their own; call this to warm the host up or to use
|
|
891
|
+
* another table of `{n, lo, la, p, c}` rows. */
|
|
892
|
+
export async function loadSyntheticPlaces(bytes) {
|
|
893
|
+
return loadPlacesOn(synthEngineHost(), bytes);
|
|
894
|
+
}
|
|
895
|
+
/** The Overpass QL queries for a request's region, one per bounding box (two
|
|
896
|
+
* when it crosses the antimeridian). The caller fetches them and passes the
|
|
897
|
+
* documents as the `osm` option. */
|
|
898
|
+
export async function syntheticOverpassQueries(request) {
|
|
899
|
+
const text = JSON.stringify(request);
|
|
416
900
|
assertEngineInputLength(text.length);
|
|
417
|
-
return JSON.parse(expectText(await
|
|
901
|
+
return JSON.parse(expectText(await callSynth(synthEngineHost(), { op: "synth_overpass_queries", request: text })));
|
|
418
902
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
903
|
+
function expectSynthPreview(value) {
|
|
904
|
+
if (value === null || typeof value !== "object" || value instanceof Float64Array || value.kind !== "synth_preview") {
|
|
905
|
+
throw new Error("engine returned no synthetic preview");
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
busPositions: value.busPositions,
|
|
909
|
+
busKinds: value.busKinds,
|
|
910
|
+
busKv: value.busKv,
|
|
911
|
+
branchPositions: value.branchPositions,
|
|
912
|
+
branchStartIndices: value.branchStartIndices,
|
|
913
|
+
branchKv: value.branchKv,
|
|
914
|
+
};
|
|
423
915
|
}
|
|
424
|
-
/**
|
|
425
|
-
* `
|
|
426
|
-
*
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
916
|
+
/** A staged synthetic grid generation on the dedicated synthetic host. Each
|
|
917
|
+
* `advance()` runs one bounded stage (`context`, `site_buses`, `topology`,
|
|
918
|
+
* `electrify`, `dc_opf`, `calibrate` once per iteration, `ac_pf`, `finalize`),
|
|
919
|
+
* so a caller can show progress and a map preview between stages. */
|
|
920
|
+
export class BrowserSynthSession {
|
|
921
|
+
host;
|
|
922
|
+
handle;
|
|
923
|
+
#done = false;
|
|
924
|
+
#freed = false;
|
|
925
|
+
#stopped = null;
|
|
926
|
+
constructor(host, handle) {
|
|
927
|
+
this.host = host;
|
|
928
|
+
this.handle = handle;
|
|
929
|
+
}
|
|
930
|
+
/** Where the session runs: `"direct"` means the page's own thread, which
|
|
931
|
+
* can pause while a stage runs. */
|
|
932
|
+
get hostKind() {
|
|
933
|
+
return this.host.kind;
|
|
934
|
+
}
|
|
935
|
+
/** True once `finalize` has run. */
|
|
936
|
+
get done() {
|
|
937
|
+
return this.#done;
|
|
938
|
+
}
|
|
939
|
+
#check() {
|
|
940
|
+
if (this.#stopped !== null)
|
|
941
|
+
throw this.#stopped;
|
|
942
|
+
if (this.#freed)
|
|
943
|
+
throw new Error("synthetic generation session is freed");
|
|
944
|
+
}
|
|
945
|
+
/** Run one stage. With `withPreview`, a stage that changed the preview
|
|
946
|
+
* also returns it. */
|
|
947
|
+
async advance(options = {}) {
|
|
948
|
+
this.#check();
|
|
949
|
+
const outcome = JSON.parse(expectText(await callSynth(this.host, { op: "synth_session_advance", session: this.handle })));
|
|
950
|
+
this.#done = outcome.done;
|
|
951
|
+
if (options.withPreview && outcome.preview_changed) {
|
|
952
|
+
return { ...outcome, preview: await this.preview() };
|
|
953
|
+
}
|
|
954
|
+
return outcome;
|
|
955
|
+
}
|
|
956
|
+
/** Advance to the end, calling `onStep` after each stage (with the preview
|
|
957
|
+
* when it changed). An abort cancels the session; the promise rejects with
|
|
958
|
+
* the abort reason. */
|
|
959
|
+
async run(onStep, signal) {
|
|
960
|
+
signal?.throwIfAborted();
|
|
961
|
+
const abort = () => this.cancel(signal?.reason);
|
|
962
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
963
|
+
try {
|
|
964
|
+
while (!this.#done) {
|
|
965
|
+
const { preview, ...outcome } = await this.advance({ withPreview: onStep !== undefined });
|
|
966
|
+
signal?.throwIfAborted();
|
|
967
|
+
onStep?.(outcome, preview);
|
|
968
|
+
}
|
|
969
|
+
return await this.report();
|
|
970
|
+
}
|
|
971
|
+
catch (error) {
|
|
972
|
+
signal?.throwIfAborted();
|
|
973
|
+
throw error;
|
|
974
|
+
}
|
|
975
|
+
finally {
|
|
976
|
+
signal?.removeEventListener("abort", abort);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
async preview() {
|
|
980
|
+
this.#check();
|
|
981
|
+
return expectSynthPreview(await callSynth(this.host, { op: "synth_session_preview", session: this.handle }));
|
|
982
|
+
}
|
|
983
|
+
/** The generation report, partial until the session is done. */
|
|
984
|
+
async report() {
|
|
985
|
+
this.#check();
|
|
986
|
+
return JSON.parse(expectText(await callSynth(this.host, { op: "synth_session_report", session: this.handle })));
|
|
987
|
+
}
|
|
988
|
+
/** The module as generation 2 PowerIO IR text, with bus locations and
|
|
989
|
+
* branch routes. Available from `electrify` on; does not free the session. */
|
|
990
|
+
async takeModule() {
|
|
991
|
+
this.#check();
|
|
992
|
+
return expectText(await callSynth(this.host, { op: "synth_session_module", session: this.handle }));
|
|
993
|
+
}
|
|
994
|
+
/** The module's drop-panel payload, the same {@link IngestedCase} a
|
|
995
|
+
* dropped `.pio.json` produces, so it opens without a second parse. */
|
|
996
|
+
async ingest() {
|
|
997
|
+
this.#check();
|
|
998
|
+
return JSON.parse(expectText(await callSynth(this.host, { op: "synth_session_ingest", session: this.handle })));
|
|
999
|
+
}
|
|
1000
|
+
/** The network as GridFM graphkit JSON: per-unit bus, branch and
|
|
1001
|
+
* generator feature matrices on a 0-based bus index, with the edge index
|
|
1002
|
+
* in both directions. Available from `electrify` on. */
|
|
1003
|
+
async exportGridfm() {
|
|
1004
|
+
this.#check();
|
|
1005
|
+
return expectText(await callSynth(this.host, { op: "synth_session_export_gridfm", session: this.handle }));
|
|
1006
|
+
}
|
|
1007
|
+
/** Stop the generation. On a worker host this terminates the synthetic
|
|
1008
|
+
* generation worker: interactive studies live on another worker and are
|
|
1009
|
+
* untouched, and the next session starts a fresh one. On the page's own
|
|
1010
|
+
* thread it takes effect before the next stage. Later calls reject with
|
|
1011
|
+
* `reason`. */
|
|
1012
|
+
cancel(reason = new DOMException("synthetic generation cancelled", "AbortError")) {
|
|
1013
|
+
if (this.#stopped !== null || this.#freed)
|
|
1014
|
+
return;
|
|
1015
|
+
this.#stopped = reason;
|
|
1016
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
1017
|
+
if (this.host.cancel?.(error)) {
|
|
1018
|
+
this.#freed = true; // the worker and its sessions are gone
|
|
1019
|
+
}
|
|
1020
|
+
else {
|
|
1021
|
+
this.free();
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/** Release the session's engine state. */
|
|
1025
|
+
free() {
|
|
1026
|
+
if (this.#freed)
|
|
1027
|
+
return;
|
|
1028
|
+
this.#freed = true;
|
|
1029
|
+
void this.host
|
|
1030
|
+
.call({ op: "synth_session_free", session: this.handle })
|
|
1031
|
+
.catch(() => { });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
let synthSessionSeq = 0;
|
|
1035
|
+
function osmText(osm) {
|
|
1036
|
+
if (osm === undefined)
|
|
1037
|
+
return null;
|
|
1038
|
+
const text = typeof osm === "string" ? osm : JSON.stringify(osm);
|
|
1039
|
+
assertEngineInputLength(text.length);
|
|
1040
|
+
return text;
|
|
1041
|
+
}
|
|
1042
|
+
/** Open a staged generation on the synthetic host, loading the places table
|
|
1043
|
+
* there first when the host has none. */
|
|
1044
|
+
export async function createSynthSession(request, options = {}) {
|
|
1045
|
+
const { signal } = options;
|
|
1046
|
+
signal?.throwIfAborted();
|
|
1047
|
+
const requestText = JSON.stringify(request);
|
|
1048
|
+
assertEngineInputLength(requestText.length);
|
|
1049
|
+
const osm = osmText(options.osm);
|
|
1050
|
+
const host = synthEngineHost();
|
|
1051
|
+
if (options.places)
|
|
1052
|
+
await loadPlacesOn(host, options.places);
|
|
1053
|
+
else if (placesLoaded.get(host) !== host.kind)
|
|
1054
|
+
await loadPlacesOn(host, await fetchSyntheticPlaces());
|
|
1055
|
+
signal?.throwIfAborted();
|
|
1056
|
+
// Handles are allocated here, so a pending request replayed onto the
|
|
1057
|
+
// fallback host keeps naming the same session.
|
|
1058
|
+
const handle = ++synthSessionSeq;
|
|
1059
|
+
await callSynth(host, { op: "synth_session_new", session: handle, request: requestText, osm });
|
|
1060
|
+
const session = new BrowserSynthSession(host, handle);
|
|
1061
|
+
if (signal?.aborted) {
|
|
1062
|
+
session.free();
|
|
1063
|
+
signal.throwIfAborted();
|
|
1064
|
+
}
|
|
1065
|
+
return session;
|
|
1066
|
+
}
|
|
1067
|
+
/** Run every stage in one engine request and return the module, the report
|
|
1068
|
+
* and the drop-panel payload. An abort cancels the run. */
|
|
1069
|
+
export async function generateSynthetic(request, options = {}) {
|
|
1070
|
+
const { signal } = options;
|
|
1071
|
+
signal?.throwIfAborted();
|
|
1072
|
+
const requestText = JSON.stringify(request);
|
|
1073
|
+
assertEngineInputLength(requestText.length);
|
|
1074
|
+
const osm = osmText(options.osm);
|
|
1075
|
+
const host = synthEngineHost();
|
|
1076
|
+
if (options.places)
|
|
1077
|
+
await loadPlacesOn(host, options.places);
|
|
1078
|
+
else if (placesLoaded.get(host) !== host.kind)
|
|
1079
|
+
await loadPlacesOn(host, await fetchSyntheticPlaces());
|
|
1080
|
+
signal?.throwIfAborted();
|
|
1081
|
+
const cancel = () => {
|
|
1082
|
+
host.cancel?.(new DOMException("synthetic generation cancelled", "AbortError"));
|
|
1083
|
+
};
|
|
1084
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
1085
|
+
try {
|
|
1086
|
+
const out = JSON.parse(expectText(await callSynth(host, { op: "synth_generate", request: requestText, osm })));
|
|
1087
|
+
signal?.throwIfAborted();
|
|
1088
|
+
return { moduleJson: out.module_json, report: out.report, ingest: out.ingest };
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
signal?.throwIfAborted();
|
|
1092
|
+
throw error;
|
|
1093
|
+
}
|
|
1094
|
+
finally {
|
|
1095
|
+
signal?.removeEventListener("abort", cancel);
|
|
1096
|
+
}
|
|
435
1097
|
}
|
|
436
1098
|
export const browserWasmTransport = {
|
|
437
1099
|
preloadEngine,
|
|
438
1100
|
classifyJson,
|
|
439
1101
|
ingestJsonDrop,
|
|
440
1102
|
ingestCase,
|
|
441
|
-
ingestModelJson,
|
|
442
|
-
ingestModelJsonBytes,
|
|
443
1103
|
ingestDistCase,
|
|
444
1104
|
ingestDistCaseBytes,
|
|
445
1105
|
parseDisplay,
|
|
446
1106
|
parseGeo,
|
|
447
1107
|
applyGeo,
|
|
1108
|
+
applyMcGeo,
|
|
448
1109
|
applyLayout,
|
|
449
1110
|
extractGeo,
|
|
450
1111
|
applyDisplayGeo,
|
|
451
1112
|
capabilities,
|
|
452
|
-
|
|
1113
|
+
solveModule,
|
|
1114
|
+
solveMcBmopf,
|
|
1115
|
+
solveMcModule,
|
|
1116
|
+
solveMcStudy,
|
|
1117
|
+
createMcPfSession,
|
|
1118
|
+
replayMcStudy,
|
|
1119
|
+
applyMcStudyGeo,
|
|
453
1120
|
createStudy,
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
1121
|
+
loadSyntheticPlaces,
|
|
1122
|
+
syntheticOverpassQueries,
|
|
1123
|
+
createSynthSession,
|
|
1124
|
+
generateSynthetic,
|
|
457
1125
|
};
|
|
458
1126
|
export function createBrowserWasmTransport() {
|
|
459
1127
|
return browserWasmTransport;
|
|
460
1128
|
}
|
|
461
1129
|
export { BrowserStudy as Study };
|
|
1130
|
+
export { IndexedDbStudyStore, StudyDocumentController, studyBackend, } from "./study-document.js";
|
|
1131
|
+
export { studySchema } from "./generated/study-schema.js";
|
|
1132
|
+
export { synthSchema } from "./generated/synth-schema.js";
|
|
462
1133
|
//# sourceMappingURL=index.js.map
|