@tellegen/engine 0.1.1 → 0.3.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 (46) hide show
  1. package/README.md +50 -6
  2. package/dist/generated/contracts.d.ts +35 -17
  3. package/dist/generated/contracts.d.ts.map +1 -1
  4. package/dist/generated/contracts.js +3 -3
  5. package/dist/generated/study-contracts.d.ts +426 -0
  6. package/dist/generated/study-contracts.d.ts.map +1 -0
  7. package/dist/generated/study-contracts.js +4 -0
  8. package/dist/generated/study-contracts.js.map +1 -0
  9. package/dist/generated/study-schema.d.ts +2 -0
  10. package/dist/generated/study-schema.d.ts.map +1 -0
  11. package/dist/generated/study-schema.js +3 -0
  12. package/dist/generated/study-schema.js.map +1 -0
  13. package/dist/host.d.ts +12 -0
  14. package/dist/host.d.ts.map +1 -1
  15. package/dist/host.js +71 -17
  16. package/dist/host.js.map +1 -1
  17. package/dist/index.d.ts +360 -96
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +407 -89
  20. package/dist/index.js.map +1 -1
  21. package/dist/input-limit.d.ts +8 -0
  22. package/dist/input-limit.d.ts.map +1 -0
  23. package/dist/input-limit.js +14 -0
  24. package/dist/input-limit.js.map +1 -0
  25. package/dist/module.d.ts +1 -0
  26. package/dist/module.d.ts.map +1 -1
  27. package/dist/module.js.map +1 -1
  28. package/dist/protocol.d.ts +105 -20
  29. package/dist/protocol.d.ts.map +1 -1
  30. package/dist/protocol.js +67 -18
  31. package/dist/protocol.js.map +1 -1
  32. package/dist/study-document.d.ts +46 -0
  33. package/dist/study-document.d.ts.map +1 -0
  34. package/dist/study-document.js +183 -0
  35. package/dist/study-document.js.map +1 -0
  36. package/dist/study-document.test.d.ts +2 -0
  37. package/dist/study-document.test.d.ts.map +1 -0
  38. package/dist/study-document.test.js +119 -0
  39. package/dist/study-document.test.js.map +1 -0
  40. package/dist/wasm-pkg/tellegen.d.ts +191 -55
  41. package/dist/wasm-pkg/tellegen.js +754 -97
  42. package/dist/wasm-pkg/tellegen_bg.wasm +0 -0
  43. package/dist/wasm-pkg/tellegen_bg.wasm.d.ts +30 -6
  44. package/dist/worker.js +27 -3
  45. package/dist/worker.js.map +1 -1
  46. package/package.json +26 -4
package/dist/index.js CHANGED
@@ -2,12 +2,31 @@
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 { engineHost, isolatedEngineHost } from "./host.js";
6
6
  import { isPermanentWasmLoadFailure } from "./errors.js";
7
+ import { assertEngineInputBytes, assertEngineInputLength, } from "./input-limit.js";
7
8
  export { CONTRACT_SOURCE_SHA256, CONTRACT_VERSION, FORMULATION_IDS, SOLVE_STATUSES, } from "./generated/contracts.js";
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
+ }
8
14
  export async function preloadEngine() {
9
15
  await engineHost().call({ op: "preload" });
10
16
  }
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. */
19
+ export async function classifyJson(bytes) {
20
+ assertEngineInputBytes(bytes);
21
+ return JSON.parse(expectText(await engineHost().call({ op: "classify_json", bytes })));
22
+ }
23
+ /** Classify and ingest one dropped JSON document in the same engine request.
24
+ * This avoids parsing a recognized package once to classify it and again to
25
+ * consume it. */
26
+ export async function ingestJsonDrop(bytes) {
27
+ assertEngineInputBytes(bytes);
28
+ return JSON.parse(expectText(await engineHost().call({ op: "ingest_json_drop", bytes })));
29
+ }
11
30
  /** Asserts a request that must carry a payload actually did. */
12
31
  function expectText(value) {
13
32
  if (value === null)
@@ -17,24 +36,45 @@ function expectText(value) {
17
36
  /** powerio format token from a file name; null for non-case files. */
18
37
  export function formatOf(name) {
19
38
  const ext = name.split(".").pop()?.toLowerCase();
20
- return ext === "m" || ext === "raw" || ext === "aux" ? ext : null;
39
+ switch (ext) {
40
+ case "m":
41
+ case "raw":
42
+ case "aux":
43
+ case "pwb":
44
+ return ext;
45
+ case "epc":
46
+ return "pslf";
47
+ default:
48
+ return null;
49
+ }
21
50
  }
22
- export async function ingestCase(text, format) {
23
- return JSON.parse(expectText(await engineHost().call({ op: "ingest_case", text, format })));
51
+ /** Parse a balanced case file for viewing. Takes the upload's bytes rather than
52
+ * decoded text: powerio refuses a text format whose bytes are not UTF-8, where
53
+ * `File.text()` would have replaced each offending byte with U+FFFD. */
54
+ export async function ingestCase(bytes, format) {
55
+ assertEngineInputBytes(bytes);
56
+ return JSON.parse(expectText(await engineHost().call({ op: "ingest_case", bytes, format })));
24
57
  }
25
58
  /** Parse a multiconductor distribution case for viewing. `format` is a
26
59
  * distribution reader token (`dss`, `bmopf`, `pmd`) or `pio` for a `.pio.json`
27
60
  * package carrying a multiconductor payload. Rejects on malformed input or a
28
61
  * balanced package (which the study-restore path handles). */
29
62
  export async function ingestDistCase(text, format) {
63
+ assertEngineInputLength(text.length);
30
64
  return JSON.parse(expectText(await engineHost().call({ op: "ingest_dist_case", text, format })));
31
65
  }
66
+ /** Byte entry point for a dropped distribution case or package. */
67
+ export async function ingestDistCaseBytes(bytes, format) {
68
+ assertEngineInputBytes(bytes);
69
+ return JSON.parse(expectText(await engineHost().call({ op: "ingest_dist_case_bytes", bytes, format })));
70
+ }
32
71
  /** True for binary display files (PowerWorld .pwd), read via parseDisplay.
33
72
  * Kept separate from formatOf: a .pwd is display data, not a case format. */
34
73
  export function isDisplayFile(name) {
35
74
  return name.split(".").pop()?.toLowerCase() === "pwd";
36
75
  }
37
76
  export async function parseDisplay(bytes) {
77
+ assertEngineInputBytes(bytes);
38
78
  return JSON.parse(expectText(await engineHost().call({ op: "parse_display", bytes, format: "pwd" })));
39
79
  }
40
80
  /** Parse a geographic sidecar (buscoords CSV, aliased CSV/JSON records,
@@ -42,53 +82,213 @@ export async function parseDisplay(bytes) {
42
82
  * is the file name (picks CSV against JSON; "" sniffs). Rejects input with
43
83
  * no usable coordinates. */
44
84
  export async function parseGeo(bytes, hint) {
85
+ assertEngineInputBytes(bytes);
45
86
  return JSON.parse(expectText(await engineHost().call({ op: "parse_geo", bytes, hint })));
46
87
  }
47
88
  /** Apply a parsed layer onto a case network (matching by uid, external id,
48
89
  * name, and the unordered branch endpoint pair). Rejects when nothing
49
90
  * matched. */
50
- export async function applyGeo(networkJson, layer) {
91
+ export async function applyGeo(moduleJson, layer) {
92
+ assertEngineInputLength(moduleJson.length);
51
93
  return JSON.parse(expectText(await engineHost().call({
52
94
  op: "apply_geo",
53
- network_json: networkJson,
95
+ module_json: moduleJson,
54
96
  layer,
55
97
  })));
56
98
  }
99
+ /** Apply geographic or drawing coordinates to a multiconductor case. */
100
+ export async function applyMcGeo(moduleJson, layer) {
101
+ assertEngineInputLength(moduleJson.length);
102
+ const result = JSON.parse(expectText(await engineHost().call({
103
+ op: "apply_geo",
104
+ module_json: moduleJson,
105
+ layer,
106
+ })));
107
+ if (result.model !== "multiconductor")
108
+ throw new Error("Expected a multiconductor case");
109
+ return result;
110
+ }
57
111
  /** Stamp a computed layout (bus id => [lon, lat]) onto a case network with
58
112
  * its provenance, so saves and exports carry it. */
59
- export async function applyLayout(networkJson, coords, kind) {
113
+ export async function applyLayout(moduleJson, coords, kind) {
114
+ assertEngineInputLength(moduleJson.length);
60
115
  return JSON.parse(expectText(await engineHost().call({
61
116
  op: "apply_layout",
62
- network_json: networkJson,
117
+ module_json: moduleJson,
63
118
  coords: JSON.stringify(coords),
64
119
  kind,
65
120
  })));
66
121
  }
67
122
  /** A case's coordinates as a canonical `.geo.json` document. Rejects when the
68
123
  * case carries none. */
69
- export async function extractGeo(networkJson) {
70
- return expectText(await engineHost().call({ op: "extract_geo", network_json: networkJson }));
124
+ export async function extractGeo(moduleJson) {
125
+ assertEngineInputLength(moduleJson.length);
126
+ return expectText(await engineHost().call({ op: "extract_geo", module_json: moduleJson }));
71
127
  }
72
128
  /** Fill case coordinates from a PowerWorld `.pwd` sibling: substation symbols
73
129
  * project to approximate longitude/latitude and join onto buses through the
74
130
  * `SubNum` extras key. Rejects when no bus joined. */
75
- export async function applyDisplayGeo(networkJson, bytes) {
131
+ export async function applyDisplayGeo(moduleJson, bytes) {
132
+ assertEngineInputLength(moduleJson.length);
133
+ assertEngineInputBytes(bytes);
76
134
  return JSON.parse(expectText(await engineHost().call({
77
135
  op: "apply_display_geo",
78
- network_json: networkJson,
136
+ module_json: moduleJson,
79
137
  bytes,
80
138
  })));
81
139
  }
82
140
  export async function capabilities() {
83
141
  return JSON.parse(expectText(await engineHost().call({ op: "capabilities" })));
84
142
  }
85
- export async function solveJson(networkJson, request = {}) {
143
+ export async function solveModule(moduleJson, request = {}) {
86
144
  return JSON.parse(expectText(await engineHost().call({
87
- op: "solve_json",
88
- network_json: networkJson,
145
+ op: "solve_module",
146
+ module_json: moduleJson,
89
147
  request: JSON.stringify(request),
90
148
  })));
91
149
  }
150
+ /** A live fixed-point current-injection solver retained in the shared engine
151
+ * worker. Load edits reuse its sparse LU and previous converged phasors. */
152
+ export class BrowserMcPfSession {
153
+ host;
154
+ handle;
155
+ constructor(host, handle) {
156
+ this.host = host;
157
+ this.handle = handle;
158
+ }
159
+ async result() {
160
+ return JSON.parse(expectText(await this.host.call({
161
+ op: "mc_pf_session_result",
162
+ session: this.handle,
163
+ })));
164
+ }
165
+ async loadBranches() {
166
+ return JSON.parse(expectText(await this.host.call({
167
+ op: "mc_pf_session_load_branches",
168
+ session: this.handle,
169
+ })));
170
+ }
171
+ async replaceLoadPowers(edits) {
172
+ return JSON.parse(expectText(await this.host.call({
173
+ op: "mc_pf_session_replace_load_powers",
174
+ session: this.handle,
175
+ edits: JSON.stringify(edits),
176
+ })));
177
+ }
178
+ async inputModule() {
179
+ return expectText(await this.host.call({
180
+ op: "mc_pf_session_input_module",
181
+ session: this.handle,
182
+ }));
183
+ }
184
+ async snapshot(studyId, studyTitle) {
185
+ return JSON.parse(expectText(await this.host.call({
186
+ op: "mc_pf_session_snapshot",
187
+ session: this.handle,
188
+ study_id: studyId,
189
+ study_title: studyTitle,
190
+ })));
191
+ }
192
+ free() {
193
+ void this.host
194
+ .call({ op: "mc_pf_session_free", session: this.handle })
195
+ .catch(() => { });
196
+ }
197
+ }
198
+ let mcPfSessionSeq = 0;
199
+ /** Parse, prepare, factor, and solve a persistent multiconductor PF session. */
200
+ export async function createMcPfSession(moduleJson, options = {}, signal) {
201
+ assertEngineInputLength(moduleJson.length);
202
+ signal?.throwIfAborted();
203
+ const host = engineHost();
204
+ const handle = ++mcPfSessionSeq;
205
+ await host.call({
206
+ op: "mc_pf_session_new",
207
+ session: handle,
208
+ module_json: moduleJson,
209
+ options: JSON.stringify(options),
210
+ });
211
+ const session = new BrowserMcPfSession(host, handle);
212
+ if (signal?.aborted) {
213
+ session.free();
214
+ signal.throwIfAborted();
215
+ }
216
+ return session;
217
+ }
218
+ /** Parse and solve a raw BMOPF multiconductor case in the wasm module. */
219
+ export async function solveMcBmopf(text, options = {}) {
220
+ assertEngineInputLength(text.length);
221
+ return JSON.parse(expectText(await engineHost().call({
222
+ op: "solve_mc_bmopf",
223
+ text,
224
+ options: JSON.stringify(options),
225
+ })));
226
+ }
227
+ /** Solve a stored PowerIO multiconductor module through the same PF engine. */
228
+ export async function solveMcModule(moduleJson, options = {}, signal) {
229
+ assertEngineInputLength(moduleJson.length);
230
+ signal?.throwIfAborted();
231
+ const host = isolatedEngineHost();
232
+ const abort = () => host.cancel?.(new Error("Multiconductor calculation cancelled"));
233
+ signal?.addEventListener("abort", abort, { once: true });
234
+ try {
235
+ const result = await host.call({
236
+ op: "solve_mc_module",
237
+ module_json: moduleJson,
238
+ options: JSON.stringify(options),
239
+ });
240
+ signal?.throwIfAborted();
241
+ return JSON.parse(expectText(result));
242
+ }
243
+ finally {
244
+ signal?.removeEventListener("abort", abort);
245
+ host.cancel?.(new Error("Multiconductor calculation finished"));
246
+ }
247
+ }
248
+ /** Solve a supported multiconductor Study input and return a replayable
249
+ * snapshot containing the typed input, PowerIO solution, and rich result. */
250
+ export async function solveMcStudy(moduleJson, studyId, studyTitle, options = {}, signal) {
251
+ assertEngineInputLength(moduleJson.length);
252
+ signal?.throwIfAborted();
253
+ const host = isolatedEngineHost();
254
+ const abort = () => host.cancel?.(new Error("Multiconductor calculation cancelled"));
255
+ signal?.addEventListener("abort", abort, { once: true });
256
+ try {
257
+ const result = await host.call({
258
+ op: "solve_mc_study",
259
+ module_json: moduleJson,
260
+ study_id: studyId,
261
+ study_title: studyTitle,
262
+ options: JSON.stringify(options),
263
+ });
264
+ signal?.throwIfAborted();
265
+ return JSON.parse(expectText(result));
266
+ }
267
+ finally {
268
+ signal?.removeEventListener("abort", abort);
269
+ host.cancel?.(new Error("Multiconductor calculation finished"));
270
+ }
271
+ }
272
+ /** Validate and canonicalize a saved multiconductor Study snapshot without
273
+ * running the solver again. */
274
+ export async function replayMcStudy(snapshotJson) {
275
+ assertEngineInputLength(snapshotJson.length);
276
+ return JSON.parse(expectText(await engineHost().call({
277
+ op: "replay_mc_study",
278
+ snapshot: snapshotJson,
279
+ })));
280
+ }
281
+ /** Update both saved modules with matching positions while retaining the solved values. */
282
+ export async function applyMcStudyGeo(snapshot, layer) {
283
+ const text = JSON.stringify(snapshot);
284
+ assertEngineInputLength(text.length);
285
+ assertEngineInputLength(layer.length);
286
+ return JSON.parse(expectText(await engineHost().call({
287
+ op: "apply_mc_study_geo",
288
+ snapshot: text,
289
+ layer,
290
+ })));
291
+ }
92
292
  export { errorText } from "./errors.js";
93
293
  /** True when the engine wasm module has failed to load in a way it can never
94
294
  * recover from in this browser. Nothing solves client side when this is true;
@@ -96,10 +296,13 @@ export { errorText } from "./errors.js";
96
296
  export function isPermanentEngineFailure(message) {
97
297
  return isPermanentWasmLoadFailure(message);
98
298
  }
99
- /** An all-digit record key is the numeric-id form (object keys are strings at
100
- * runtime); anything else is a powerio row uid, sent as a string. */
299
+ /** A safely representable all-digit key uses the numeric wire form. Larger IDs
300
+ * remain strings so serde can parse the exact integer without JS rounding. */
101
301
  function toElementKey(key) {
102
- return /^\d+$/.test(key) ? Number(key) : key;
302
+ if (!/^\d+$/.test(key))
303
+ return key;
304
+ const numeric = Number(key);
305
+ return Number.isSafeInteger(numeric) ? numeric : key;
103
306
  }
104
307
  /** A `NetworkEdit[]` for the wasm Study, dropping zero deltas so an unchanged
105
308
  * element is never sent. */
@@ -122,7 +325,7 @@ function toEdits(deltas, rates) {
122
325
  * ∂(price, active) / ∂(demand, active) for a bus target, or ∂(price, active) /
123
326
  * ∂(line limit) for a branch target. The *formulation* is no longer fixed here — it is
124
327
  * a parameter threaded from the UI's selector through `createStudy` (every formulation the
125
- * full wasm build carries returns LMP, so these columns apply to all of them). The
328
+ * full wasm build carries returns nodal marginal values, so these columns apply to all of them). The
126
329
  * operand/parameters stay centralized so `createStudy` and the Study's `commit`/`preview`
127
330
  * requests read one source. */
128
331
  const STUDY_CAPABILITY = {
@@ -137,6 +340,7 @@ const STUDY_CAPABILITY = {
137
340
  * is the menu order; `dcopf` is first and is the default. */
138
341
  export const FORMULATIONS = [
139
342
  { id: "dcopf", label: "DC OPF", hint: "DC optimal power flow (the default)" },
343
+ { id: "acpf", label: "AC power flow", hint: "Balanced AC power flow" },
140
344
  {
141
345
  id: "socwr",
142
346
  label: "SOCWR",
@@ -149,12 +353,12 @@ export const FORMULATIONS = [
149
353
  disabled: true,
150
354
  },
151
355
  ];
152
- /** The default formulation: DC OPF, preserving the prior fixed behavior byte-for-byte. */
356
+ /** DC OPF is the default calculation. */
153
357
  export const DEFAULT_FORMULATION = "dcopf";
154
- /** The `Operand[]` JSON `Study.preview` watches (the LMP column). */
358
+ /** The `Operand[]` JSON `Study.preview` watches (the active nodal value column). */
155
359
  const PREVIEW_OPERANDS_JSON = JSON.stringify([STUDY_CAPABILITY.operand]);
156
- /** The `SensRequest[]` JSON for `Study.commit`: the ∂LMP/∂demand column at a dense bus
157
- * index, or the ∂LMP/∂rating column at a dense branch index, or `[]` when there is no
360
+ /** The `SensRequest[]` JSON for `Study.commit`: the nodal-value/demand column at a dense bus
361
+ * index, or the nodal-value/rating column at a dense branch index, or `[]` when there is no
158
362
  * target. NOTE: `SensRequest.indices` are **dense positional** indices into the target's
159
363
  * axis (0-based), *not* external ids — `BrowserStudy` translates the selected external id
160
364
  * to its dense index before calling this. */
@@ -171,7 +375,7 @@ function sensitivitiesJson(target) {
171
375
  },
172
376
  ]);
173
377
  }
174
- /** Extract the ∂LMP/∂parameter column from the first requested `SensitivityMatrix` into
378
+ /** Extract the nodal-value/parameter column from the first requested `SensitivityMatrix` into
175
379
  * the `SensitivityColumn` the map and legend consume, the same shape the server serves.
176
380
  * Rows are Price operands per bus; the single column is the selected parameter, so the
177
381
  * column is `values[r][0]` keyed by each row's source bus id, and the source element is
@@ -186,7 +390,7 @@ function sensitivityColumn(caseId, matrices) {
186
390
  const values = m.rows
187
391
  .map((row, r) => ({ bus: row.element.Bus, value: m.values[r]?.[0] ?? 0 }))
188
392
  .filter((v) => v.bus !== undefined);
189
- const shared = { case: caseId, operand: "lmp", units: m.units, values };
393
+ const shared = { case: caseId, operand: "price", units: m.units, values };
190
394
  if (el.Bus !== undefined) {
191
395
  return { ...shared, parameter: "d", bus: el.Bus };
192
396
  }
@@ -197,8 +401,9 @@ function sensitivityColumn(caseId, matrices) {
197
401
  }
198
402
  function solveResponseToSolution(out) {
199
403
  return {
200
- objective: out.objective ?? 0,
201
- lmp: (out.lmp ?? []).map((e) => ({ bus: e.bus, usd_per_mwh: e.value })),
404
+ objective: out.objective ?? null,
405
+ prices: (out.lmp ?? []).map((e) => ({ bus: e.bus, value: e.value })),
406
+ vm: out.vm ?? undefined,
202
407
  va: out.va ?? [],
203
408
  w: out.w ?? [],
204
409
  flows: (out.flows ?? []).map((f) => ({
@@ -206,11 +411,15 @@ function solveResponseToSolution(out) {
206
411
  mw: f.pf,
207
412
  loading: f.loading,
208
413
  })),
209
- dispatch: (out.dispatch ?? []).map((d) => ({ gen: d.gen, mw: d.pg })),
414
+ dispatch: (out.dispatch ?? []).map((d) => ({
415
+ gen: d.gen,
416
+ bus: d.bus,
417
+ mw: d.pg,
418
+ })),
210
419
  };
211
420
  }
212
- /** Build-once browser transport for the reactive demand drag. The network is
213
- * parsed and the model built when the Study is created; `commit` solves exactly
421
+ /** Browser transport for the reactive demand drag. The retained PowerIO module
422
+ * is parsed and the model built when the Study is created; `commit` solves exactly
214
423
  * at the UI's absolute demand delta state and `preview` returns a first-order
215
424
  * linearization toward an absolute demand delta state, neither re-parsing the
216
425
  * network. The wasm Study lives behind the engine host (a dedicated worker
@@ -219,36 +428,56 @@ export class BrowserStudy {
219
428
  #host;
220
429
  /** The caller-allocated handle naming the wasm Study on the host. */
221
430
  #handle;
431
+ #formulation;
432
+ #planningHost;
222
433
  /** External bus id -> dense positional bus index, built once from the committed solution's
223
- * LMP ordering (each `lmp[i].bus` sits at dense index `i`). The engine keys `SensRequest`
434
+ * nodal value ordering (each wire `lmp[i].bus` sits at dense index `i`). The engine keys `SensRequest`
224
435
  * by this dense index, not the external bus id, so the selected bus must be translated
225
436
  * before a sensitivity request. Null until first needed; the bus set is solve-invariant. */
226
437
  #busToIndex = null;
227
438
  /** External branch id -> dense positional branch index, from the committed solution's
228
439
  * flows ordering — the branch-axis counterpart of `#busToIndex`. */
229
440
  #branchToIndex = null;
230
- constructor(host, handle) {
441
+ constructor(host, handle, formulation = DEFAULT_FORMULATION, planningHost = isolatedEngineHost) {
231
442
  this.#host = host;
232
443
  this.#handle = handle;
444
+ this.#formulation = formulation;
445
+ this.#planningHost = planningHost;
233
446
  }
234
447
  async #solution() {
235
448
  return JSON.parse(expectText(await this.#host.call({ op: "study_solution", study: this.#handle })));
236
449
  }
237
- async #replaceEdits(deltas, rates, target) {
450
+ async #replaceEdits(deltas, rates, target, signal) {
238
451
  const sensitivities = sensitivitiesJson(await this.#senseTarget(target));
239
- return JSON.parse(expectText(await this.#host.call({
240
- op: "study_replace_edits",
241
- study: this.#handle,
242
- edits: JSON.stringify(toEdits(deltas, rates)),
243
- sensitivities,
244
- })));
452
+ signal?.throwIfAborted();
453
+ const cancel = () => {
454
+ this.#host.cancel?.(new DOMException("solve cancelled", "AbortError"));
455
+ };
456
+ signal?.addEventListener("abort", cancel, { once: true });
457
+ try {
458
+ const result = await this.#host.call({
459
+ op: "study_replace_edits",
460
+ study: this.#handle,
461
+ edits: JSON.stringify(toEdits(deltas, rates)),
462
+ sensitivities,
463
+ });
464
+ signal?.throwIfAborted();
465
+ return JSON.parse(expectText(result));
466
+ }
467
+ catch (error) {
468
+ signal?.throwIfAborted();
469
+ throw error;
470
+ }
471
+ finally {
472
+ signal?.removeEventListener("abort", cancel);
473
+ }
245
474
  }
246
475
  /** The dense-axis sensitivity target the engine expects for a selection (external
247
476
  * ids), or null when nothing is selected or the id is unknown. Memoizes the
248
- * id->index maps from the committed solution's LMP / flows order — the same axis
477
+ * id->index maps from the committed solution's nodal value and flow order — the same axis
249
478
  * orders the engine's dense sensitivity columns use. */
250
479
  async #senseTarget(target) {
251
- if (target === null)
480
+ if (target === null || this.#formulation === "acpf")
252
481
  return null;
253
482
  if ("bus" in target) {
254
483
  if (!this.#busToIndex) {
@@ -266,57 +495,135 @@ export class BrowserStudy {
266
495
  return index === undefined ? null : { kind: "branch", index };
267
496
  }
268
497
  /** Exact solve at demand = base + `deltas` and ratings = base + `rates`, replacing
269
- * the committed point. When `target` names a bus or branch, its ∂LMP/∂parameter
498
+ * the committed point. When `target` names a bus or branch, its nodal-value/parameter
270
499
  * column is computed in the *same* solve and returned (no second round-trip);
271
500
  * otherwise `sensitivity` is null. `caseId` labels the returned column. Returns the
272
501
  * UI Solution, the solver iterates, and the column. */
273
- async commit(caseId, deltas, rates, target) {
274
- const out = await this.#replaceEdits(deltas, rates, target);
502
+ async commit(caseId, deltas, rates, target, signal) {
503
+ const out = await this.#replaceEdits(deltas, rates, target, signal);
275
504
  return {
276
505
  solution: solveResponseToSolution(out.solution),
277
506
  iterations: out.iterations ?? [],
278
507
  sensitivity: target === null ? null : sensitivityColumn(caseId, out.sensitivities),
279
508
  };
280
509
  }
281
- /** The ∂LMP/∂parameter column at `target` for this study's formulation, computed by
510
+ /** The nodal-value/parameter column at `target` for this study's formulation, computed by
282
511
  * an exact re-solve at the edited point (the same one-call path `commit` uses, but
283
512
  * returning only the column). Used when a bus or branch is selected so the overlay
284
513
  * matches the active formulation rather than always the DC sensitivity. `caseId`
285
514
  * labels the column; returns null when no recognized column comes back. */
286
515
  async sensitivity(caseId, deltas, rates, target) {
516
+ if (this.#formulation === "acpf")
517
+ return null;
287
518
  const out = await this.#replaceEdits(deltas, rates, target);
288
519
  return sensitivityColumn(caseId, out.sensitivities);
289
520
  }
290
- /** First-order LMP preview for replacing the committed point with
521
+ /** First order nodal value preview for replacing the committed point with
291
522
  * demand = base + `deltas` and ratings = base + `rates`, with no re-solve:
292
- * predicted per-bus ΔLMP and the predicted Δobjective. */
523
+ * predicted per-bus nodal value changes and the predicted objective change. */
293
524
  async preview(deltas, rates = {}) {
525
+ if (this.#formulation === "acpf") {
526
+ return { prices: [], objectiveDelta: null, units: null };
527
+ }
294
528
  const out = JSON.parse(expectText(await this.#host.call({
295
529
  op: "study_preview",
296
530
  study: this.#handle,
297
531
  edits: JSON.stringify(toEdits(deltas, rates)),
298
532
  operands: PREVIEW_OPERANDS_JSON,
299
533
  })));
300
- const lmp = (out.operands[0]?.values ?? [])
534
+ const prices = (out.operands[0]?.values ?? [])
301
535
  .filter((v) => v.element.Bus !== undefined)
302
- .map((v) => ({ bus: v.element.Bus, usd_per_mwh: v.value }));
303
- return { lmp, objectiveDelta: out.objective_delta };
536
+ .map((v) => ({ bus: v.element.Bus, value: v.value }));
537
+ return {
538
+ prices,
539
+ objectiveDelta: out.objective_delta,
540
+ units: out.operands[0]?.units ?? null,
541
+ };
304
542
  }
305
543
  /** The Study's current exact solution. Called immediately after Study creation
306
544
  * to cache the base point for formulation comparisons. */
545
+ async currentView() {
546
+ return JSON.parse(expectText(await this.#host.call({ op: "study_solution", study: this.#handle })));
547
+ }
307
548
  async currentSolution() {
308
549
  return solveResponseToSolution(await this.#solution());
309
550
  }
310
- /** Serialize this study as a `.pio.json` package: the base network payload, the
311
- * edit log as the study block, and the formulation and solve options under
312
- * `study.app["tellegen"]`. The returned text is ready to download or hand to
313
- * `exportStudy`. */
314
- async savePackage() {
315
- return expectText(await this.#host.call({ op: "study_save_package", study: this.#handle }));
551
+ /** Materialize the current case as generation 2 PowerIO IR. */
552
+ async saveModule() {
553
+ return expectText(await this.#host.call({ op: "study_save_module", study: this.#handle }));
554
+ }
555
+ /** Preserve the materialized instance's inner objective and constraints in PowerIO IR. */
556
+ async saveInstanceModule() {
557
+ return expectText(await this.#host.call({
558
+ op: "study_save_instance_module",
559
+ study: this.#handle,
560
+ }));
561
+ }
562
+ /** Serialize the current exact DC OPF result as a PowerIO solution module.
563
+ * Its embedded problem instance contains the materialized case that was
564
+ * solved. */
565
+ async saveSolutionModule() {
566
+ return expectText(await this.#host.call({
567
+ op: "study_save_solution_module",
568
+ study: this.#handle,
569
+ }));
570
+ }
571
+ /** Write the current materialized case to one PowerIO target format. */
572
+ async export(format) {
573
+ return JSON.parse(expectText(await this.#host.call({
574
+ op: "study_export",
575
+ study: this.#handle,
576
+ format,
577
+ })));
578
+ }
579
+ /** Run a bounded differentiable planning search over the committed
580
+ * operating point. Read only: the committed case, edits, and revision are
581
+ * untouched; the returned outcome carries the unapplied proposal and the
582
+ * search trace (gradient, per step prediction, exact result, first
583
+ * order error, accept or reject reason). Rejects on a non-DC formulation
584
+ * or an unknown element key. Worker hosts stop the solve on cancellation;
585
+ * the synchronous fallback finishes the wasm call but rejects its result. */
586
+ async plan(spec, signal) {
587
+ signal?.throwIfAborted();
588
+ const moduleJson = await this.saveModule();
589
+ signal?.throwIfAborted();
590
+ const planningStudy = await createStudyOnHost(moduleJson, this.#formulation, this.#planningHost(), signal);
591
+ try {
592
+ return await planningStudy.#planInPlace(spec, signal);
593
+ }
594
+ finally {
595
+ planningStudy.free();
596
+ }
597
+ }
598
+ /** Run planning on this disposable Study. Public callers go through
599
+ * `plan`, which first clones the committed PowerIO module onto an isolated
600
+ * host so cancellation cannot invalidate the interactive Study. */
601
+ async #planInPlace(spec, signal) {
602
+ signal?.throwIfAborted();
603
+ const cancel = () => {
604
+ this.#host.cancel?.(new DOMException("planning cancelled", "AbortError"));
605
+ };
606
+ signal?.addEventListener("abort", cancel, { once: true });
607
+ try {
608
+ const result = await this.#host.call({
609
+ op: "study_plan",
610
+ study: this.#handle,
611
+ spec: JSON.stringify(spec),
612
+ });
613
+ signal?.throwIfAborted();
614
+ return JSON.parse(expectText(result));
615
+ }
616
+ catch (error) {
617
+ signal?.throwIfAborted();
618
+ throw error;
619
+ }
620
+ finally {
621
+ signal?.removeEventListener("abort", cancel);
622
+ }
316
623
  }
317
624
  /** Apply a parsed geographic layer (the canonical `.geo.json` from
318
625
  * `parseGeo` or `applyLayout`) onto this study's base network, so the next
319
- * `savePackage` or export carries the coordinates on screen. Locations are
626
+ * `saveModule` or export carries the coordinates on screen. Locations are
320
627
  * metadata the model never reads; nothing re-solves. */
321
628
  async applyGeoLayer(layer) {
322
629
  return JSON.parse(expectText(await this.#host.call({
@@ -326,7 +633,7 @@ export class BrowserStudy {
326
633
  })));
327
634
  }
328
635
  /** Release the wasm Study; call when discarding it (e.g. the case's
329
- * networkJson changed, or the case was removed). Fire and forget: the
636
+ * module input changed, or the case was removed). Fire and forget: the
330
637
  * handle is invalid from this call on. */
331
638
  free() {
332
639
  void this.#host
@@ -334,60 +641,71 @@ export class BrowserStudy {
334
641
  .catch(() => { });
335
642
  }
336
643
  }
337
- /** Construct a build-once Study over `networkJson` for `formulation`, parsing the network
338
- * and solving the base case once. `formulation` is a `Problem` tag (`dcopf`/`acopf`/`socwr`,
644
+ /** Construct a Study from a retained PowerIO module for `formulation`, parsing its
645
+ * problem instance and solving the base case once. `formulation` is a `Problem` tag (`dcopf`/`acopf`/`socwr`,
339
646
  * defaulting to DC OPF); the full wasm build solves every one entirely in the browser.
340
647
  * Throws if the engine module can't load (or the formulation is unknown/not built); the
341
648
  * caller must catch and fall back (see `isPermanentEngineFailure`). */
342
649
  let studySeq = 0;
343
- export async function createStudy(networkJson, formulation = DEFAULT_FORMULATION) {
344
- const host = engineHost();
650
+ export async function createStudy(moduleJson, formulation = DEFAULT_FORMULATION, options = {}) {
651
+ const host = options.isolated ? isolatedEngineHost() : engineHost();
652
+ return createStudyOnHost(moduleJson, formulation, host, options.signal);
653
+ }
654
+ async function createStudyOnHost(moduleJson, formulation, host, signal) {
345
655
  // Handles are allocated here, not by the host, so a pending build replayed
346
656
  // onto the fallback host keeps naming the same study.
347
657
  const handle = ++studySeq;
348
- await host.call({
349
- op: "study_new",
350
- study: handle,
351
- network_json: networkJson,
352
- formulation,
353
- });
354
- return new BrowserStudy(host, handle);
355
- }
356
- /** Restore a study saved by `BrowserStudy.savePackage`: the case, edit sliders,
357
- * formulation, and solve options in one step. Rejects on a malformed package or one
358
- * that is not a tellegen study (the engine fails the load closed). */
359
- export async function loadPackage(text) {
360
- return JSON.parse(expectText(await engineHost().call({ op: "load_package", text })));
361
- }
362
- /** Export a saved study package at commit `commit` to a powerio `format` (`matpower`,
363
- * `psse`, `powerio-json`, ...). Returns the serialized case text, the writer's fidelity
364
- * warnings, and the format token and file extension. */
365
- export async function exportStudy(packageJson, commit, format) {
366
- return JSON.parse(expectText(await engineHost().call({
367
- op: "export_study",
368
- package_json: packageJson,
369
- commit,
370
- format,
371
- })));
658
+ signal?.throwIfAborted();
659
+ const cancel = () => {
660
+ host.cancel?.(new DOMException("study creation cancelled", "AbortError"));
661
+ };
662
+ signal?.addEventListener("abort", cancel, { once: true });
663
+ try {
664
+ await host.call({
665
+ op: "study_new",
666
+ study: handle,
667
+ module_json: moduleJson,
668
+ formulation,
669
+ });
670
+ signal?.throwIfAborted();
671
+ }
672
+ catch (error) {
673
+ signal?.throwIfAborted();
674
+ throw error;
675
+ }
676
+ finally {
677
+ signal?.removeEventListener("abort", cancel);
678
+ }
679
+ return new BrowserStudy(host, handle, formulation);
372
680
  }
373
681
  export const browserWasmTransport = {
374
682
  preloadEngine,
683
+ classifyJson,
684
+ ingestJsonDrop,
375
685
  ingestCase,
376
686
  ingestDistCase,
687
+ ingestDistCaseBytes,
377
688
  parseDisplay,
378
689
  parseGeo,
379
690
  applyGeo,
691
+ applyMcGeo,
380
692
  applyLayout,
381
693
  extractGeo,
382
694
  applyDisplayGeo,
383
695
  capabilities,
384
- solveJson,
696
+ solveModule,
697
+ solveMcBmopf,
698
+ solveMcModule,
699
+ solveMcStudy,
700
+ createMcPfSession,
701
+ replayMcStudy,
702
+ applyMcStudyGeo,
385
703
  createStudy,
386
- loadPackage,
387
- exportStudy,
388
704
  };
389
705
  export function createBrowserWasmTransport() {
390
706
  return browserWasmTransport;
391
707
  }
392
708
  export { BrowserStudy as Study };
709
+ export { IndexedDbStudyStore, StudyDocumentController, studyBackend, } from "./study-document.js";
710
+ export { studySchema } from "./generated/study-schema.js";
393
711
  //# sourceMappingURL=index.js.map