@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.
Files changed (60) hide show
  1. package/README.md +71 -20
  2. package/dist/data/populated_places.json +1 -0
  3. package/dist/generated/{contracts.d.ts → api-types.d.ts} +26 -18
  4. package/dist/generated/api-types.d.ts.map +1 -0
  5. package/dist/generated/api-types.js +8 -0
  6. package/dist/generated/api-types.js.map +1 -0
  7. package/dist/generated/study-schema.d.ts +2 -0
  8. package/dist/generated/study-schema.d.ts.map +1 -0
  9. package/dist/generated/study-schema.js +3 -0
  10. package/dist/generated/study-schema.js.map +1 -0
  11. package/dist/generated/study-types.d.ts +426 -0
  12. package/dist/generated/study-types.d.ts.map +1 -0
  13. package/dist/generated/study-types.js +4 -0
  14. package/dist/generated/study-types.js.map +1 -0
  15. package/dist/generated/synth-schema.d.ts +2 -0
  16. package/dist/generated/synth-schema.d.ts.map +1 -0
  17. package/dist/generated/synth-schema.js +3 -0
  18. package/dist/generated/synth-schema.js.map +1 -0
  19. package/dist/generated/synth-types.d.ts +482 -0
  20. package/dist/generated/synth-types.d.ts.map +1 -0
  21. package/dist/generated/synth-types.js +4 -0
  22. package/dist/generated/synth-types.js.map +1 -0
  23. package/dist/host.d.ts +35 -2
  24. package/dist/host.d.ts.map +1 -1
  25. package/dist/host.js +103 -17
  26. package/dist/host.js.map +1 -1
  27. package/dist/index.d.ts +549 -122
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +780 -109
  30. package/dist/index.js.map +1 -1
  31. package/dist/input-limit.d.ts +1 -1
  32. package/dist/input-limit.js +1 -1
  33. package/dist/module.d.ts +2 -0
  34. package/dist/module.d.ts.map +1 -1
  35. package/dist/module.js.map +1 -1
  36. package/dist/protocol.d.ts +182 -31
  37. package/dist/protocol.d.ts.map +1 -1
  38. package/dist/protocol.js +153 -26
  39. package/dist/protocol.js.map +1 -1
  40. package/dist/study-document.d.ts +46 -0
  41. package/dist/study-document.d.ts.map +1 -0
  42. package/dist/study-document.js +183 -0
  43. package/dist/study-document.js.map +1 -0
  44. package/dist/study-document.test.d.ts +2 -0
  45. package/dist/study-document.test.d.ts.map +1 -0
  46. package/dist/study-document.test.js +119 -0
  47. package/dist/study-document.test.js.map +1 -0
  48. package/dist/wasm-pkg/LICENSE +17 -4
  49. package/dist/wasm-pkg/tellegen.d.ts +332 -85
  50. package/dist/wasm-pkg/tellegen.js +1324 -250
  51. package/dist/wasm-pkg/tellegen_bg.wasm +0 -0
  52. package/dist/wasm-pkg/tellegen_bg.wasm.d.ts +57 -9
  53. package/dist/worker.js +20 -5
  54. package/dist/worker.js.map +1 -1
  55. package/package.json +32 -11
  56. package/dist/generated/contracts.d.ts.map +0 -1
  57. package/dist/generated/contracts.js +0 -8
  58. package/dist/generated/contracts.js.map +0 -1
  59. package/dist/wasm-pkg/LICENSE-APACHE +0 -201
  60. 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 { CONTRACT_SOURCE_SHA256, CONTRACT_VERSION, FORMULATION_IDS, SOLVE_STATUSES, } from "./generated/contracts.js";
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. Package markers
14
- * also pass the strict package reader so the payload family is authoritative. */
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 === null)
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(networkJson, layer) {
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
- network_json: networkJson,
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(networkJson, coords, kind) {
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
- network_json: networkJson,
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(networkJson) {
122
- return expectText(await engineHost().call({ op: "extract_geo", network_json: networkJson }));
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(networkJson, bytes) {
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
- network_json: networkJson,
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 solveJson(networkJson, request = {}) {
149
+ export async function solveModule(moduleJson, request = {}) {
139
150
  return JSON.parse(expectText(await engineHost().call({
140
- op: "solve_json",
141
- network_json: networkJson,
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 LMP, so these columns apply to all of them). The
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
- /** The default formulation: DC OPF, preserving the prior fixed behavior byte-for-byte. */
494
+ /** DC OPF is the default calculation. */
209
495
  export const DEFAULT_FORMULATION = "dcopf";
210
- /** The `Operand[]` JSON `Study.preview` watches (the LMP column). */
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 ∂LMP/∂demand column at a dense bus
213
- * index, or the ∂LMP/∂rating column at a dense branch index, or `[]` when there is no
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 ∂LMP/∂parameter column from the first requested `SensitivityMatrix` into
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: "lmp", units: m.units, values };
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 ?? 0,
257
- lmp: (out.lmp ?? []).map((e) => ({ bus: e.bus, usd_per_mwh: e.value })),
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) => ({ gen: d.gen, mw: d.pg })),
552
+ dispatch: (out.dispatch ?? []).map((d) => ({
553
+ gen: d.gen,
554
+ bus: d.bus,
555
+ mw: d.pg,
556
+ })),
266
557
  };
267
558
  }
268
- /** Build-once browser transport for the reactive demand drag. The network is
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
- * LMP ordering (each `lmp[i].bus` sits at dense index `i`). The engine keys `SensRequest`
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
- return JSON.parse(expectText(await this.#host.call({
296
- op: "study_replace_edits",
297
- study: this.#handle,
298
- edits: JSON.stringify(toEdits(deltas, rates)),
299
- sensitivities,
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 LMP / flows order — the same axis
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 ∂LMP/∂parameter
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 ∂LMP/∂parameter column at `target` for this study's formulation, computed by
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-order LMP preview for replacing the committed point with
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 ΔLMP and the predicted Δobjective. */
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 lmp = (out.operands[0]?.values ?? [])
672
+ const prices = (out.operands[0]?.values ?? [])
357
673
  .filter((v) => v.element.Bus !== undefined)
358
- .map((v) => ({ bus: v.element.Bus, usd_per_mwh: v.value }));
359
- return { lmp, objectiveDelta: out.objective_delta };
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
- /** Serialize this study as a `.pio.json` package: the base network payload, the
367
- * edit log as the study block, and the formulation and solve options under
368
- * `study.app["tellegen"]`. The returned text is ready to download or hand to
369
- * `exportStudy`. */
370
- async savePackage() {
371
- return expectText(await this.#host.call({ op: "study_save_package", study: this.#handle }));
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
- * `savePackage` or export carries the coordinates on screen. Locations are
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
- * networkJson changed, or the case was removed). Fire and forget: the
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 build-once Study over `networkJson` for `formulation`, parsing the network
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(networkJson, formulation = DEFAULT_FORMULATION) {
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
- await host.call({
405
- op: "study_new",
406
- study: handle,
407
- network_json: networkJson,
408
- formulation,
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 new BrowserStudy(host, handle);
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
- /** Restore a study saved by `BrowserStudy.savePackage`: the case, edit sliders,
413
- * formulation, and solve options in one step. Rejects on a malformed package or one
414
- * that is not a tellegen study (the engine fails the load closed). */
415
- export async function loadPackage(text) {
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 engineHost().call({ op: "load_package", text })));
901
+ return JSON.parse(expectText(await callSynth(synthEngineHost(), { op: "synth_overpass_queries", request: text })));
418
902
  }
419
- /** Restore a dropped package without decoding it in JavaScript. */
420
- export async function loadPackageBytes(bytes) {
421
- assertEngineInputBytes(bytes);
422
- return JSON.parse(expectText(await engineHost().call({ op: "load_package_bytes", bytes })));
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
- /** Export a saved study package at commit `commit` to a powerio `format` (`matpower`,
425
- * `psse`, `model-json`, ...). Returns the serialized case text, the writer's fidelity
426
- * warnings, and the format token and file extension. */
427
- export async function exportStudy(packageJson, commit, format) {
428
- assertEngineInputLength(packageJson.length);
429
- return JSON.parse(expectText(await engineHost().call({
430
- op: "export_study",
431
- package_json: packageJson,
432
- commit,
433
- format,
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
- solveJson,
1113
+ solveModule,
1114
+ solveMcBmopf,
1115
+ solveMcModule,
1116
+ solveMcStudy,
1117
+ createMcPfSession,
1118
+ replayMcStudy,
1119
+ applyMcStudyGeo,
453
1120
  createStudy,
454
- loadPackage,
455
- loadPackageBytes,
456
- exportStudy,
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