@tellegen/svelte 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 (88) hide show
  1. package/README.md +269 -5
  2. package/dist/Sparkline.svelte +2 -2
  3. package/dist/TellegenMap.svelte +518 -202
  4. package/dist/TellegenMap.svelte.d.ts +45 -0
  5. package/dist/TellegenProvider.svelte +40 -4
  6. package/dist/TellegenShell.svelte +24 -46
  7. package/dist/TellegenViewer.svelte +12 -2
  8. package/dist/api.d.ts +18 -3
  9. package/dist/api.js +8 -2
  10. package/dist/basemap.d.ts +192 -0
  11. package/dist/basemap.js +259 -0
  12. package/dist/colors.d.ts +25 -7
  13. package/dist/colors.js +39 -13
  14. package/dist/components/AppFooter.svelte +3 -2
  15. package/dist/components/AppHeader.svelte +44 -19
  16. package/dist/components/BindingLines.svelte +4 -7
  17. package/dist/components/BusPicker.svelte +11 -5
  18. package/dist/components/ControlPanel.svelte +106 -343
  19. package/dist/components/DemandSlider.svelte +36 -29
  20. package/dist/components/DiagramCanvas.svelte +486 -0
  21. package/dist/components/DiagramCanvas.svelte.d.ts +13 -0
  22. package/dist/components/DisplayControls.svelte +8 -2
  23. package/dist/components/DropZone.svelte +1 -1
  24. package/dist/components/FormulationSelector.svelte +9 -4
  25. package/dist/components/LocalCaseDetails.svelte +61 -48
  26. package/dist/components/McResults.svelte +428 -0
  27. package/dist/components/McResults.svelte.d.ts +17 -0
  28. package/dist/components/ModelDetails.svelte +230 -0
  29. package/dist/components/ModelDetails.svelte.d.ts +7 -0
  30. package/dist/components/MulticonductorDetails.svelte +232 -83
  31. package/dist/components/NetworkStats.svelte +20 -18
  32. package/dist/components/NotificationCenter.svelte +245 -0
  33. package/dist/components/NotificationCenter.svelte.d.ts +3 -0
  34. package/dist/components/PanelCard.svelte +384 -0
  35. package/dist/components/PanelCard.svelte.d.ts +9 -0
  36. package/dist/components/PanelFrame.svelte +46 -0
  37. package/dist/components/PanelFrame.svelte.d.ts +16 -0
  38. package/dist/components/PanelHost.svelte +237 -0
  39. package/dist/components/PanelHost.svelte.d.ts +6 -0
  40. package/dist/components/PlacementCue.svelte +1 -1
  41. package/dist/components/PowerFlowDetails.svelte +99 -0
  42. package/dist/components/PowerFlowDetails.svelte.d.ts +3 -0
  43. package/dist/components/RatingSlider.svelte +19 -14
  44. package/dist/components/RestoreDefaultsButton.svelte +1 -1
  45. package/dist/components/SavedNetworkDetails.svelte +214 -0
  46. package/dist/components/SavedNetworkDetails.svelte.d.ts +3 -0
  47. package/dist/components/SensitivityReadout.svelte +26 -18
  48. package/dist/components/ShellEnvironment.svelte +51 -0
  49. package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
  50. package/dist/components/SizeLegend.svelte +2 -2
  51. package/dist/components/SolveCard.svelte +48 -51
  52. package/dist/components/TopMovers.svelte +9 -4
  53. package/dist/components/TopMovers.svelte.d.ts +2 -17
  54. package/dist/components.d.ts +5 -0
  55. package/dist/components.js +5 -0
  56. package/dist/context.svelte.d.ts +8 -1
  57. package/dist/context.svelte.js +8 -3
  58. package/dist/controller.svelte.d.ts +117 -36
  59. package/dist/controller.svelte.js +1133 -253
  60. package/dist/diagram-labels.d.ts +9 -0
  61. package/dist/diagram-labels.js +40 -0
  62. package/dist/display.d.ts +3 -7
  63. package/dist/display.js +31 -27
  64. package/dist/format.d.ts +9 -0
  65. package/dist/format.js +17 -4
  66. package/dist/index.d.ts +15 -5
  67. package/dist/index.js +9 -2
  68. package/dist/map.d.ts +4 -2
  69. package/dist/map.js +2 -1
  70. package/dist/maplibre-worker.d.ts +5 -0
  71. package/dist/maplibre-worker.dev.d.ts +11 -0
  72. package/dist/maplibre-worker.dev.js +15 -0
  73. package/dist/maplibre-worker.js +8 -0
  74. package/dist/multiconductor.d.ts +11 -2
  75. package/dist/multiconductor.js +107 -14
  76. package/dist/notices.svelte.d.ts +29 -0
  77. package/dist/notices.svelte.js +86 -0
  78. package/dist/panels.svelte.d.ts +71 -0
  79. package/dist/panels.svelte.js +245 -0
  80. package/dist/slider.d.ts +12 -0
  81. package/dist/slider.js +31 -0
  82. package/dist/state.svelte.d.ts +107 -23
  83. package/dist/state.svelte.js +106 -20
  84. package/dist/styles.css +59 -6
  85. package/dist/synthetic-layout.js +1 -0
  86. package/package.json +29 -11
  87. package/dist/study-package.d.ts +0 -8
  88. package/dist/study-package.js +0 -17
@@ -3,15 +3,20 @@ import { previewScaleFor, scalarDomain, sensFlatColor, sensitivityDomain } from
3
3
  import { caseDeltas as sharedCaseDeltas, caseRatings as sharedCaseRatings, displayMetaFor, displaySeriesFor } from './display.js';
4
4
  import { CaseState, LocalCase, MulticonductorCase } from './state.svelte.js';
5
5
  import { placeSyntheticTopology } from './synthetic-layout.js';
6
- import { studyCommitIndex } from './study-package.js';
7
6
  import { distExtensionFormat, isGeoFileName } from './drop-classify.js';
8
7
  import { DropBatchGate, readDropFileBytes, validateDropBatch } from './drop-limits.js';
9
- import { buildGeographicView, placeMultiView } from './multiconductor.js';
10
- import { applyDisplayGeo, applyGeo, applyLayout, createStudy, exportStudy, extractGeo, FORMULATIONS, formatOf, ingestCase, ingestDistCaseBytes, ingestJsonDrop, isDisplayFile, isPermanentEngineFailure, parseDisplay, parseGeo } from '@tellegen/engine';
11
- import { errorText, extent, formulationLabel, rgbaCss } from './format.js';
8
+ import { buildGeographicView, buildDiagramView, placeMultiView } from './multiconductor.js';
9
+ import { browserWasmTransport, applyDisplayGeo, applyGeo, applyLayout, createStudy, extractGeo, FORMULATIONS, formatPowerIoDiagnostic, formatOf, ingestCase, ingestDistCaseBytes, ingestJsonDrop, isDisplayFile, isPermanentEngineFailure, parseDisplay, parseGeo, mcPfResultDetail, summarizeMcPfResult } from '@tellegen/engine';
10
+ import { browserSolveFailure, errorText, extent, formulationLabel, rgbaCss } from './format.js';
11
+ import { DEAD_ZONE_MW } from './slider.js';
12
12
  const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
13
13
  // Open on South Carolina by default: it has the most interesting price action.
14
14
  const DEFAULT_CASE_ID = 'case500';
15
+ /** Whether a local case renders or will render a bus with this id. */
16
+ function localHasBus(c, busId) {
17
+ const buses = c.view?.buses ?? c.diagram?.view.buses ?? c.topology?.buses ?? [];
18
+ return buses.some((b) => b.id === busId);
19
+ }
15
20
  /** Terminal copy when nothing can solve. Two facts decide the wording: whether
16
21
  * this browser's engine failure is known permanent, and that the server
17
22
  * declines compute. Server compute exists in the codebase but ships disabled;
@@ -22,6 +27,11 @@ const COMPUTE_OFF_NOTICE = 'solving in this browser did not complete, and server
22
27
  * starts anyway: a missing map or a cue that never appears must not stall the
23
28
  * selection. */
24
29
  const FOCUS_SETTLE_CAP_MS = 1200;
30
+ function previewUnits(sensitivityUnits) {
31
+ const numerator = sensitivityUnits.replace(/\/(?:MW|MVA)$/, '').trim();
32
+ const unwrapped = numerator.startsWith('(') && numerator.endsWith(')') ? numerator.slice(1, -1) : numerator;
33
+ return unwrapped.replaceAll('objective_unit', 'objective units');
34
+ }
25
35
  /** A lowered analysis row is view-only only when a current producer says so;
26
36
  * absence keeps payloads from older compatible engine/server versions editable. */
27
37
  export function isDisplayOnlyElement(element) {
@@ -60,7 +70,11 @@ function downloadText(text, filename, mime) {
60
70
  }
61
71
  export class Controller {
62
72
  app;
73
+ /** `null` when the controller was created with `apiBase: null`; every
74
+ * server path then reports "compute off" instead of fetching. */
63
75
  api;
76
+ loadDefaultCases;
77
+ mcTransport;
64
78
  abort = null;
65
79
  // While set (epoch ms), the server sensitivity fallback is rate limited: skip
66
80
  // the request and show the rate-limit copy instead of burning the budget on a
@@ -70,11 +84,12 @@ export class Controller {
70
84
  // Latched once any server fallback answers 403: compute is disabled on this
71
85
  // deploy, so later fallbacks show the notice instead of firing doomed requests.
72
86
  serverComputeOff = false;
73
- // Build-once browser Study per case: the network is parsed and the model built
87
+ // Build-once browser Study per case: the retained PowerIO module is parsed and the model built
74
88
  // when the Study is created, so a drag re-solves (commit) and previews without
75
89
  // re-parsing. Kept in a WeakMap off the reactive/raw case payloads — the wasm
76
90
  // handle is neither serialized nor part of any $state.
77
91
  caseStudies = new WeakMap();
92
+ detachedCaseCaptures = new WeakMap();
78
93
  // Latch a permanent sensitivity-module failure per case so we don't retry
79
94
  // createStudy — and the same permanent error — on every drag. Transient
80
95
  // failures are not latched.
@@ -113,6 +128,8 @@ export class Controller {
113
128
  constructor(app, options = {}) {
114
129
  this.app = app;
115
130
  this.api = options.api ?? createApiClient({ apiBase: options.apiBase });
131
+ this.loadDefaultCases = options.loadDefaultCases ?? true;
132
+ this.mcTransport = options.mcTransport ?? browserWasmTransport;
116
133
  }
117
134
  // ===== helpers =====
118
135
  isBackendCase(c) {
@@ -140,6 +157,9 @@ export class Controller {
140
157
  isPerturbed(c) {
141
158
  return c?.perturbed ?? false;
142
159
  }
160
+ bumpRevision(c) {
161
+ c.revisionGeneration += 1;
162
+ }
143
163
  setNearbyRangeAnchor(c, bus, delta = this.caseDeltas(c)[bus] ?? 0) {
144
164
  this.nearbyRangeAnchor = { caseId: c.id, bus, delta };
145
165
  }
@@ -149,6 +169,7 @@ export class Controller {
149
169
  // the sensitivity seq the caller guards its async writes with.
150
170
  beginBusSelection(c, busId) {
151
171
  this.abort?.abort();
172
+ this.bumpRevision(c);
152
173
  const ac = new AbortController();
153
174
  this.abort = ac;
154
175
  const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
@@ -170,6 +191,7 @@ export class Controller {
170
191
  // but the selection lands on a branch and the demand preview fields are cleared.
171
192
  beginBranchSelection(c, branchId) {
172
193
  this.abort?.abort();
194
+ this.bumpRevision(c);
173
195
  const ac = new AbortController();
174
196
  this.abort = ac;
175
197
  const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
@@ -219,45 +241,50 @@ export class Controller {
219
241
  this.hiddenDefaults = new Set(hidden);
220
242
  }
221
243
  localNetwork(c) {
222
- if (!c.summary || !c.view)
244
+ const view = c.displayMode === 'diagram' ? c.diagram?.view : c.view;
245
+ if (!c.summary || !view)
223
246
  return null;
224
247
  return {
225
248
  id: c.id,
226
249
  name: c.label,
227
250
  base_mva: c.summary.base_mva,
228
251
  synthetic_coords: c.coordsKind !== 'file' && c.coordsKind !== 'geofile',
229
- buses: c.view.buses,
230
- branches: c.view.branches
252
+ ...(c.network?.model_details ? { model_details: c.network.model_details } : {}),
253
+ coordinate_space: view.coordinate_space,
254
+ buses: view.buses,
255
+ branches: view.branches
231
256
  };
232
257
  }
233
258
  maybeStartLocalSolve(id) {
234
259
  const c = this.app.localCases.find((lc) => lc.id === id);
235
- if (!c?.networkJson || !c.view || !c.summary)
260
+ if (!c?.studyInputJson || (!c.view && !c.diagram) || !c.summary)
236
261
  return;
237
262
  c.network = this.localNetwork(c) ?? c.network ?? null;
238
- if (c.networkJson && c.network && !c.solution)
263
+ if (c.network && !c.solution)
239
264
  this.runSolve(c, null);
240
265
  }
241
- // The case's Study, building it once for `(networkJson, formulation)` and rebuilding
266
+ // The case's Study, building it once for `(studyInputJson, formulation)` and rebuilding
242
267
  // (after free) if either changed — so picking a new formulation re-parses and re-solves
243
268
  // under that formulation. Returns null when the sens module can't load; the caller then
244
269
  // falls back to the server, surfacing solveFallbackReason.
245
- async getStudy(c, networkJson) {
270
+ async getStudy(c, studyInputJson) {
246
271
  const latched = this.studyUnavailable.get(c);
247
272
  if (latched) {
248
273
  c.solveFallbackReason ??= latched;
249
274
  return null;
250
275
  }
251
276
  const cached = this.caseStudies.get(c);
252
- if (cached && cached.networkJson === networkJson && cached.formulation === c.formulation)
277
+ if (cached && cached.studyInputJson === studyInputJson && cached.formulation === c.formulation)
253
278
  return cached.study;
254
- // Coalesce concurrent builds for the same (case, networkJson, formulation): a
279
+ // Coalesce concurrent builds for the same (case, module, formulation): a
255
280
  // bus-select sensitivity fetch and a solve can both reach here before either
256
281
  // createStudy resolves. Without this each builds its own wasm Study and the
257
282
  // later set() orphans the earlier one (a leaked handle), and the cache can end
258
283
  // up holding an instance the live solve never committed on.
259
284
  const inflight = this.studyBuilds.get(c);
260
- if (inflight && inflight.networkJson === networkJson && inflight.formulation === c.formulation)
285
+ if (inflight &&
286
+ inflight.studyInputJson === studyInputJson &&
287
+ inflight.formulation === c.formulation)
261
288
  return inflight.promise;
262
289
  // Different params, or no build pending: drop any stale Study before rebuilding.
263
290
  if (cached) {
@@ -270,7 +297,7 @@ export class Controller {
270
297
  const token = {};
271
298
  const build = (async () => {
272
299
  try {
273
- const study = await createStudy(networkJson, formulation);
300
+ const study = await createStudy(studyInputJson, formulation);
274
301
  const baseSolution = await study.currentSolution();
275
302
  // The case may have switched formulation or been disposed (removed, or its
276
303
  // list reloaded) while building; don't cache a now-unwanted Study, and free
@@ -281,7 +308,7 @@ export class Controller {
281
308
  }
282
309
  this.caseStudies.set(c, {
283
310
  study,
284
- networkJson,
311
+ studyInputJson,
285
312
  formulation,
286
313
  baseSolution
287
314
  });
@@ -303,21 +330,21 @@ export class Controller {
303
330
  }
304
331
  })();
305
332
  this.studyBuilds.set(c, {
306
- networkJson,
333
+ studyInputJson,
307
334
  formulation,
308
335
  token,
309
336
  promise: build
310
337
  });
311
338
  return build;
312
339
  }
313
- // The ∂LMP/∂parameter column at `target` for the case's active formulation, solved in
340
+ // The nodal-value/parameter column at `target` for the case's active formulation, solved in
314
341
  // the browser. Every column, bus or branch, any formulation, comes from the Study's
315
342
  // exact re-solve at the case's operating point. The column is null (with the reason on
316
343
  // `solveFallbackReason`) when the Study can't be built; the selection paths then
317
344
  // reconcile via the server where a DC endpoint exists. Throws only on a hard browser
318
345
  // error.
319
- async browserSensitivity(c, networkJson, target) {
320
- const study = await this.getStudy(c, networkJson);
346
+ async browserSensitivity(c, studyInputJson, target) {
347
+ const study = await this.getStudy(c, studyInputJson);
321
348
  return study ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target) : null;
322
349
  }
323
350
  // Release a case's Study (if any) when the case is removed.
@@ -360,21 +387,26 @@ export class Controller {
360
387
  this.app.previewDeltaMw = null;
361
388
  this.app.previewRatingMw = null;
362
389
  // The committed solution supersedes the live engine preview.
363
- this.app.previewLmp = null;
390
+ this.app.previewPrices = null;
364
391
  this.previewObjective = null;
365
392
  }
366
393
  }
367
- // Fetch and cache the raw powerio Network JSON for the browser solver.
368
- // Returns null when it can't be loaded, so callers fall back to the server.
369
- async ensureNetworkJson(c) {
394
+ // Fetch and cache the retained PowerIO module used by every browser Study.
395
+ // Returns null when it cannot be loaded, so callers can report or fall back.
396
+ async ensureStudyInputJson(c) {
397
+ if (c.studyInputJson)
398
+ return c.studyInputJson;
370
399
  if (!this.isBackendCase(c))
371
- return c.networkJson ?? null;
372
- if (c.networkJson)
373
- return c.networkJson;
400
+ return null;
401
+ const api = this.api;
402
+ if (!api) {
403
+ c.solveFallbackReason = 'no API configured';
404
+ return null;
405
+ }
374
406
  try {
375
- const json = await this.api.getCaseNetworkJson(c.id);
376
- c.networkJson = json;
377
- return json;
407
+ const moduleJson = await api.getCaseModuleJson(c.id);
408
+ c.studyInputJson = moduleJson;
409
+ return moduleJson;
378
410
  }
379
411
  catch (e) {
380
412
  c.solveFallbackReason = `case fetch failed: ${errorText(e)}`;
@@ -386,7 +418,7 @@ export class Controller {
386
418
  // reads as "no change at this bus".
387
419
  previewDeltas(c, bus, value) {
388
420
  const deltas = { ...this.caseDeltas(c) };
389
- if (Math.abs(value) < 0.25)
421
+ if (Math.abs(value) < DEAD_ZONE_MW)
390
422
  delete deltas[bus];
391
423
  else
392
424
  deltas[bus] = value;
@@ -424,8 +456,12 @@ export class Controller {
424
456
  buses: c.network.buses.length,
425
457
  branches: c.network.branches.length,
426
458
  objective: c.solution?.objective ?? null,
427
- deltaObjective: c.solution && c.baseSolution ? c.solution.objective - c.baseSolution.objective : null,
428
- binding: c.solution ? c.solution.flows.filter((f) => f.loading >= 0.999).length : null
459
+ deltaObjective: c.solution?.objective != null && c.baseSolution?.objective != null
460
+ ? c.solution.objective - c.baseSolution.objective
461
+ : null,
462
+ binding: c.solution && c.formulation !== 'acpf'
463
+ ? c.solution.flows.filter((f) => f.loading >= 0.999).length
464
+ : null
429
465
  };
430
466
  });
431
467
  displayOptions = $derived(displayMetaFor(this.activeSolvable));
@@ -437,7 +473,7 @@ export class Controller {
437
473
  return null;
438
474
  // Drive the values off the resolved mode, not app.displayMode, so the
439
475
  // displayOptions[0] fallback holds while displayMode is stale relative to the
440
- // formulation (e.g. leaving SOCWR before +page resets it to 'lmp').
476
+ // formulation (e.g. leaving SOCWR before +page resets it to price).
441
477
  return {
442
478
  ...activeMeta,
443
479
  values: displaySeriesFor(this.activeSolvable, activeMeta.mode)
@@ -451,7 +487,7 @@ export class Controller {
451
487
  return null;
452
488
  const domain = scalarDomain(this.activeDisplay.mode, values);
453
489
  const { min, max } = extent(values);
454
- const flatThreshold = this.activeDisplay.mode === 'lmp' ? 1 : 1e-5;
490
+ const flatThreshold = this.activeDisplay.mode === 'price' ? 1 : 1e-5;
455
491
  return {
456
492
  lo: { value: domain.lo, clamped: min < domain.lo - flatThreshold / 20 },
457
493
  hi: { value: domain.hi, clamped: max > domain.hi + flatThreshold / 20 },
@@ -470,8 +506,8 @@ export class Controller {
470
506
  return null;
471
507
  return c.network.branches.find((b) => b.id === this.app.selectedBranch) ?? null;
472
508
  });
473
- // The sensitivity target of the current selection: the selected bus (∂LMP/∂d),
474
- // else the selected branch (∂LMP/∂rating), else null. Selections are mutually
509
+ // The sensitivity target of the current selection: the selected bus (price/demand),
510
+ // else the selected branch (price/rating), else null. Selections are mutually
475
511
  // exclusive, so bus-first ordering is only a tiebreak for impossible states.
476
512
  selectionTarget = $derived.by(() => this.app.selectedBus !== null
477
513
  ? { bus: this.app.selectedBus }
@@ -545,9 +581,9 @@ export class Controller {
545
581
  const c = this.activeSolvable;
546
582
  if (!c?.solution || this.app.selectedBus === null)
547
583
  return null;
548
- return c.solution.lmp.find((e) => e.bus === this.app.selectedBus)?.usd_per_mwh ?? null;
584
+ return c.solution.prices.find((e) => e.bus === this.app.selectedBus)?.value ?? null;
549
585
  });
550
- // Self-sensitivity ∂LMP_bb/∂d at the selected bus: the curvature term the
586
+ // Self-sensitivity of the selected bus value to demand: the curvature term the
551
587
  // first-order fallback uses for a second-order objective estimate. Zero when no
552
588
  // sensitivity column is loaded for the bus.
553
589
  selfSens = $derived.by(() => {
@@ -559,13 +595,13 @@ export class Controller {
559
595
  // engine preview (Study.preview at the committed point, plus the committed part);
560
596
  // fall back to a second-order gradient estimate when no Study preview is available
561
597
  // (server-only cases, or a browser that can't load the sensitivity module): the
562
- // exact committed part plus lmp·step + S_bb·step²/2 along the gradient. The
563
- // gradient fallback is demand-only — a rating step has no LMP-at-bus analogue —
598
+ // exact committed part plus price·step + S_bb·step²/2 along the gradient. The
599
+ // gradient fallback is demand-only — a rating step has no corresponding bus term —
564
600
  // so a branch selection without an engine slope shows no prediction.
565
601
  predictedDeltaObj = $derived.by(() => {
566
602
  const c = this.activeSolvable;
567
603
  const target = this.selectionTarget;
568
- if (!c?.solution || !c.baseSolution || target === null)
604
+ if (c?.solution?.objective == null || c.baseSolution?.objective == null || target === null)
569
605
  return null;
570
606
  const committedPart = c.solution.objective - c.baseSolution.objective;
571
607
  if (this.previewObjective &&
@@ -580,7 +616,10 @@ export class Controller {
580
616
  });
581
617
  gradientScore = $derived.by(() => {
582
618
  const c = this.activeSolvable;
583
- if (!c?.solution || !c.baseSolution || c.predictedObjective == null || c.solving)
619
+ if (c?.solution?.objective == null ||
620
+ c.baseSolution?.objective == null ||
621
+ c.predictedObjective == null ||
622
+ c.solving)
584
623
  return null;
585
624
  const exact = c.solution.objective - c.baseSolution.objective;
586
625
  return { pred: c.predictedObjective, exact };
@@ -597,11 +636,20 @@ export class Controller {
597
636
  previewing = $derived.by(() => Boolean(this.activeSolvable?.solving ||
598
637
  this.app.previewActive ||
599
638
  (this.app.previewDeltaMw !== null &&
600
- Math.abs(this.sliderValue - this.committedDelta) >= 0.25) ||
639
+ Math.abs(this.sliderValue - this.committedDelta) >= DEAD_ZONE_MW) ||
601
640
  (this.app.previewRatingMw !== null &&
602
- Math.abs(this.ratingSliderValue - this.committedRating) >= 0.25)));
641
+ Math.abs(this.ratingSliderValue - this.committedRating) >= DEAD_ZONE_MW)));
603
642
  // ===== actions =====
604
643
  load = () => {
644
+ // Default cases off, or no backend at all: nothing to fetch. The shell
645
+ // still needs its loaded state and a framed map, and a host that opens
646
+ // its own cases must never see a request go out.
647
+ const api = this.api;
648
+ if (!this.loadDefaultCases || api === null) {
649
+ this.casesLoaded = true;
650
+ void this.app.requestFrame('all');
651
+ return Promise.resolve();
652
+ }
605
653
  // A remount can call load() again before the first getCases() resolves; dedupe
606
654
  // so two concurrent loads can't double-fetch the case list and double-fit the map.
607
655
  if (this.loading)
@@ -611,14 +659,14 @@ export class Controller {
611
659
  // copy and skip doomed requests (the SSE stream cannot see a 403). On
612
660
  // failure assume enabled; the 403 latch in fetchServerColumn still
613
661
  // catches a disabled deploy.
614
- void this.api
662
+ void api
615
663
  .getComputeStatus()
616
664
  .then((s) => {
617
665
  this.serverComputeOff = !s.enabled;
618
666
  })
619
667
  .catch(() => { });
620
668
  try {
621
- const summaries = await this.api.getCases();
669
+ const summaries = await api.getCases();
622
670
  const hidden = this.readHiddenDefaultCases();
623
671
  this.hiddenDefaults = new Set(hidden);
624
672
  // Tear down the cases being replaced so their wasm Studies are freed and any
@@ -634,7 +682,9 @@ export class Controller {
634
682
  this.app.activeLocalId = null;
635
683
  this.app.placingLocalId = null;
636
684
  this.app.activeCaseId =
637
- this.app.cases.find((c) => c.id === DEFAULT_CASE_ID)?.id ?? this.app.cases[0]?.id ?? null;
685
+ this.app.cases.find((c) => c.id === DEFAULT_CASE_ID && !c.unavailableReason)?.id ??
686
+ this.app.cases.find((c) => !c.unavailableReason)?.id ??
687
+ null;
638
688
  const active = this.app.active;
639
689
  if (active)
640
690
  await this.loadBackendCase(active, true);
@@ -654,20 +704,29 @@ export class Controller {
654
704
  return this.loading;
655
705
  };
656
706
  loadBackendCase = async (c, frame = false) => {
707
+ if (c.unavailableReason) {
708
+ this.fail(`${c.name}: ${c.unavailableReason}`);
709
+ return;
710
+ }
657
711
  if (c.network && c.solution && c.baseSolution) {
658
712
  if (frame && this.app.activeCaseId === c.id)
659
713
  this.app.requestFrame(c.id);
660
714
  return;
661
715
  }
716
+ const api = this.api;
717
+ if (!api) {
718
+ this.fail(`${c.name}: no API configured`);
719
+ return;
720
+ }
662
721
  this.loadingBackendCase = c.id;
663
722
  const requestedFormulation = c.formulation;
664
723
  try {
665
724
  const [network, dcBaseSolution] = await Promise.all([
666
- c.network ? Promise.resolve(c.network) : this.api.getNetwork(c.id),
725
+ c.network ? Promise.resolve(c.network) : api.getNetwork(c.id),
667
726
  // The server caches only the DC OPF base solution. Other formulations
668
- // must hydrate from the browser Study so their cost, prices, flows, and
727
+ // must hydrate from the browser Study so their objective, nodal values, flows, and
669
728
  // voltage fields all come from the selected formulation.
670
- requestedFormulation === 'dcopf' ? this.api.getSolution(c.id) : Promise.resolve(null)
729
+ requestedFormulation === 'dcopf' ? api.getSolution(c.id) : Promise.resolve(null)
671
730
  ]);
672
731
  if (!this.app.byId(c.id))
673
732
  return;
@@ -700,10 +759,19 @@ export class Controller {
700
759
  this.app.placingMultiId = null;
701
760
  }
702
761
  activateCase = async (id) => {
762
+ const candidate = this.app.byId(id);
763
+ if (!candidate)
764
+ return;
765
+ if (candidate.unavailableReason) {
766
+ this.fail(`${candidate.name}: ${candidate.unavailableReason}`);
767
+ return;
768
+ }
769
+ const inspectedSavedState = this.app.studyView !== null;
770
+ this.app.studyView = null;
703
771
  this.app.activeLocalId = null;
704
772
  this.app.placingLocalId = null;
705
773
  this.leaveMulti();
706
- if (this.app.activeCaseId !== id) {
774
+ if (inspectedSavedState || this.app.activeCaseId !== id) {
707
775
  this.clearSelection();
708
776
  this.app.activeCaseId = id;
709
777
  }
@@ -739,6 +807,7 @@ export class Controller {
739
807
  await this.hydrateFallback(this.app.removeCase(c.id));
740
808
  };
741
809
  activateLocal = (c) => {
810
+ this.app.studyView = null;
742
811
  this.clearSelection();
743
812
  // Mirror activateCase's reset: a local and a backend case are mutually
744
813
  // exclusive, so drop the backend selection. Otherwise app.active (derived
@@ -747,8 +816,10 @@ export class Controller {
747
816
  this.app.activeCaseId = null;
748
817
  this.leaveMulti();
749
818
  this.app.activeLocalId = c.id;
750
- this.app.placingLocalId = c.coordsKind === 'synthetic_pending' ? c.id : null;
751
- if (c.view || c.substations)
819
+ if (c.formulation === 'acpf' && this.app.displayMode === 'price')
820
+ this.app.displayMode = 'voltage';
821
+ this.app.placingLocalId = c.coordsKind === 'synthetic_pending' && !c.diagram ? c.id : null;
822
+ if (c.view || c.diagram || c.substations)
752
823
  this.app.requestFrame(c.id);
753
824
  this.maybeStartLocalSolve(c.id);
754
825
  };
@@ -763,12 +834,19 @@ export class Controller {
763
834
  this.disposeStudy(c);
764
835
  await this.hydrateFallback(this.app.removeLocal(c.id));
765
836
  };
766
- addAndActivateLocal = (c) => {
837
+ /** Add a prepared local case, make it active, frame it (unless `frame` is
838
+ * false) and start its browser solve. */
839
+ addAndActivateLocal = (c, options = {}) => {
840
+ this.app.studyView = null;
767
841
  this.clearSelection();
768
842
  this.app.activeCaseId = null;
769
843
  this.leaveMulti();
770
844
  this.app.addLocal(c);
771
- if (c.view || c.substations)
845
+ if (c.formulation === 'acpf' && this.app.displayMode === 'price')
846
+ this.app.displayMode = 'voltage';
847
+ if (c.diagram)
848
+ this.app.placingLocalId = null;
849
+ if ((options.frame ?? true) && (c.view || c.diagram || c.substations))
772
850
  this.app.requestFrame(c.id);
773
851
  this.maybeStartLocalSolve(c.id);
774
852
  };
@@ -790,18 +868,18 @@ export class Controller {
790
868
  };
791
869
  /** Write the case's on-screen layout into its network payload
792
870
  * (`Bus.location` with `synthetic` provenance — the engine also accepts
793
- * `manual`, which round trips through restored packages), so saves,
871
+ * `manual`, which round trips through saved PowerIO modules), so saves,
794
872
  * exports, and the `.geo.json` download carry it. Best effort: on an
795
873
  * engine failure the view still renders, only the layout's persistence is
796
874
  * lost. */
797
875
  stampLayout = async (c) => {
798
- if (!c.networkJson || !c.view)
876
+ if (!c.studyInputJson || !c.view)
799
877
  return;
800
878
  try {
801
879
  const coords = Object.fromEntries(c.view.buses.map((b) => [b.id, [b.lon, b.lat]]));
802
- const stamped = await applyLayout(c.networkJson, coords, 'synthetic');
803
- c.networkJson = stamped.network_json;
804
- await this.syncCachedStudy(c, stamped.network_json, [stamped.layer]);
880
+ const stamped = await applyLayout(c.studyInputJson, coords, 'synthetic');
881
+ c.studyInputJson = stamped.module_json;
882
+ await this.syncStudyGeo(c, [stamped.layer]);
805
883
  }
806
884
  catch {
807
885
  // The on-screen view is unaffected; only persistence was lost.
@@ -811,10 +889,12 @@ export class Controller {
811
889
  * GeoJSON with provenance stamped (`synthetic`/`manual` for layouts), the
812
890
  * document powerio and tellegen both read back. */
813
891
  downloadGeoLayer = async (c) => {
814
- if (!c.networkJson)
892
+ if (!c.studyInputJson)
815
893
  return;
816
894
  try {
817
- downloadText(await extractGeo(c.networkJson), `${this.caseFileStem(c)}.geo.json`, 'application/geo+json');
895
+ downloadText(c.displayMode === 'diagram' && c.diagram
896
+ ? c.diagram.layer
897
+ : await extractGeo(c.studyInputJson), `${this.caseFileStem(c)}.geo.json`, 'application/geo+json');
818
898
  this.app.error = null;
819
899
  }
820
900
  catch (e) {
@@ -830,23 +910,46 @@ export class Controller {
830
910
  * wins on shared elements). The engine owns matching and the network becomes
831
911
  * the single owner of the coordinates: matched points land in
832
912
  * `Bus.location`, routes in `Branch.route`, and the refreshed
833
- * `network_json` replaces the case payload — so a saved package or export
913
+ * `module_json` replaces the case IR — so a saved module or export
834
914
  * carries exactly what is on screen. A live Study is synced the same way.
835
915
  * Throws when a layer matches nothing. */
836
916
  applyGeoLayers = async (c, layers) => {
837
- if (!c.networkJson || layers.length === 0)
917
+ if (!c.studyInputJson || layers.length === 0)
838
918
  return;
839
919
  let payload = null;
840
- let networkJson = c.networkJson;
920
+ const geographic = [];
921
+ for (const entry of layers) {
922
+ const document = JSON.parse(entry.layer);
923
+ const space = document.powerio_geo?.space;
924
+ if (space === 'diagram' || space?.kind === 'diagram') {
925
+ const payload = await applyGeo(c.studyInputJson, entry.layer);
926
+ if (payload.view) {
927
+ c.diagram = {
928
+ view: payload.view,
929
+ layer: entry.layer,
930
+ name: entry.name,
931
+ warnings: payload.report.notes
932
+ };
933
+ if (!c.view)
934
+ c.displayMode = 'diagram';
935
+ }
936
+ }
937
+ else
938
+ geographic.push(entry);
939
+ }
940
+ layers = geographic;
941
+ if (!layers.length)
942
+ return;
943
+ let moduleJson = c.studyInputJson;
841
944
  const warnings = [];
842
945
  let routes = 0;
843
946
  let unmatched = 0;
844
947
  for (const l of layers) {
845
- payload = await applyGeo(networkJson, l.layer);
846
- networkJson = payload.network_json;
948
+ payload = await applyGeo(moduleJson, l.layer);
949
+ moduleJson = payload.module_json;
847
950
  routes += payload.report.matched_branches;
848
951
  unmatched += payload.report.unmatched_features;
849
- warnings.push(...l.warnings.map((w) => `${l.name}: ${w}`), ...payload.report.notes.map((n) => `${l.name}: ${n}`));
952
+ warnings.push(...l.diagnostics.map((d) => `${l.name}: ${formatPowerIoDiagnostic(d)}`), ...payload.report.notes.map((n) => `${l.name}: ${n}`));
850
953
  }
851
954
  if (!payload)
852
955
  return;
@@ -856,15 +959,16 @@ export class Controller {
856
959
  ...(unmatched > 0 ? [`${unmatched} feature(s) matched no case element`] : []),
857
960
  ...warnings
858
961
  ]);
859
- await this.syncCachedStudy(c, networkJson, layers.map((l) => l.layer));
962
+ await this.syncStudyGeo(c, layers.map((l) => l.layer));
860
963
  };
861
964
  /** Adopt an engine geo apply onto the case: the refreshed payload becomes
862
965
  * the case's network and view, provenance flips to the geographic source,
863
966
  * and the warnings panel gets the placement summary. */
864
967
  adoptGeoPayload(c, payload, sourceLabel, placedFrom, extraWarnings) {
865
- c.networkJson = payload.network_json;
968
+ c.studyInputJson = payload.module_json;
866
969
  c.view = payload.view;
867
970
  c.coordsKind = 'geofile';
971
+ c.displayMode = payload.view?.coordinate_space === 'diagram' ? 'diagram' : 'geographic';
868
972
  c.syntheticCenter = undefined;
869
973
  c.geoSource = sourceLabel;
870
974
  const placedCanonicalBuses = payload.view?.buses.filter((bus) => bus.editable !== false).length ?? 0;
@@ -874,39 +978,239 @@ export class Controller {
874
978
  ...payload.warnings
875
979
  ];
876
980
  }
877
- /** Keep a live Study's base network in step with a geo change (locations
878
- * are metadata the model never reads, so nothing re-solves), and re-key its
879
- * cache entry so the next solve reuses it instead of rebuilding. */
880
- syncCachedStudy = async (c, networkJson, layers) => {
981
+ /** Update an existing numerical Study after the base input receives geographic data. */
982
+ syncStudyGeo = async (c, layers) => {
881
983
  const cached = this.caseStudies.get(c);
882
984
  if (!cached)
883
985
  return;
884
- cached.networkJson = networkJson;
885
- for (const layer of layers)
886
- await cached.study.applyGeoLayer(layer);
986
+ if (cached.formulation !== c.formulation) {
987
+ this.disposeStudy(c);
988
+ return;
989
+ }
990
+ try {
991
+ for (const layer of layers)
992
+ await cached.study.applyGeoLayer(layer);
993
+ cached.studyInputJson = c.studyInputJson;
994
+ }
995
+ catch (error) {
996
+ this.disposeStudy(c);
997
+ throw error;
998
+ }
887
999
  };
888
- applyGeoLayersToExisting = async (layers) => {
889
- const target = (this.app.activeLocal?.networkJson ? this.app.activeLocal : null) ??
890
- this.app.localCases.find((c) => c.coordsKind === 'synthetic_pending' && c.networkJson) ??
891
- [...this.app.localCases].reverse().find((c) => c.networkJson);
892
- if (!target?.networkJson) {
893
- this.app.error =
894
- 'drop a case file with the geographic file, or select a parsed local case first';
1000
+ caseEditKey(c) {
1001
+ return JSON.stringify([c.formulation, this.caseDeltas(c), this.caseRatings(c)]);
1002
+ }
1003
+ async localSidecarCopy(source) {
1004
+ if (source.solving)
1005
+ throw new Error('Wait for the current solve before attaching coordinates');
1006
+ const revision = source.revisionGeneration;
1007
+ const captured = await this.captureSavedCase(source);
1008
+ const base = await this.ensureStudyInputJson(source);
1009
+ if (!base)
1010
+ throw new Error('The selected case input is unavailable');
1011
+ const parsed = await ingestJsonDrop(new TextEncoder().encode(base));
1012
+ if (!parsed.payload || !('topology' in parsed.payload))
1013
+ throw new Error('The selected case has no balanced network data');
1014
+ const copy = this.localFromBalancedPayload(`${source.name}.pio.json`, {
1015
+ ...parsed.payload,
1016
+ name: `${source.name} (local copy)`
1017
+ });
1018
+ copy.formulation = source.formulation;
1019
+ copy.deltas = { ...source.deltas };
1020
+ copy.ratings = { ...source.ratings };
1021
+ copy.solution = source.solution;
1022
+ copy.baseSolution = source.baseSolution;
1023
+ copy.iterations = source.iterations;
1024
+ copy.solveMs = source.solveMs;
1025
+ copy.solveBackend = source.solveBackend;
1026
+ copy.network = source.network;
1027
+ if (source.network) {
1028
+ copy.view = {
1029
+ buses: source.network.buses,
1030
+ branches: source.network.branches,
1031
+ coordinate_space: source.network.coordinate_space
1032
+ };
1033
+ copy.coordsKind = source.network.synthetic_coords ? 'synthetic' : 'file';
1034
+ if (!source.network.synthetic_coords && source.network.coordinate_space !== 'diagram') {
1035
+ const layer = JSON.stringify({
1036
+ type: 'FeatureCollection',
1037
+ powerio_geo: { space: 'geographic' },
1038
+ features: [
1039
+ ...source.network.buses.map((bus) => ({
1040
+ type: 'Feature',
1041
+ properties: { target: 'bus', id: String(bus.id) },
1042
+ geometry: { type: 'Point', coordinates: [bus.lon, bus.lat] }
1043
+ })),
1044
+ ...source.network.branches
1045
+ .filter((branch) => branch.path.length > 1)
1046
+ .map((branch) => ({
1047
+ type: 'Feature',
1048
+ properties: {
1049
+ target: 'branch',
1050
+ branch_id: String(branch.id),
1051
+ from: String(branch.from),
1052
+ to: String(branch.to)
1053
+ },
1054
+ geometry: { type: 'LineString', coordinates: branch.path }
1055
+ }))
1056
+ ]
1057
+ });
1058
+ const placed = await applyGeo(copy.studyInputJson, layer);
1059
+ copy.studyInputJson = placed.module_json;
1060
+ copy.view = placed.view;
1061
+ }
1062
+ }
1063
+ if (source.revisionGeneration !== revision || source.solving)
1064
+ throw new Error('The selected case changed while loading. Attach the coordinates again');
1065
+ this.detachedCaseCaptures.set(copy, {
1066
+ baseInput: copy.studyInputJson,
1067
+ edits: this.caseEditKey(copy),
1068
+ solution: copy.solution,
1069
+ captured
1070
+ });
1071
+ return copy;
1072
+ }
1073
+ stageLocalSidecars(source) {
1074
+ const copy = new LocalCase({
1075
+ id: source.id,
1076
+ label: source.label,
1077
+ fileName: source.fileName,
1078
+ summary: source.summary,
1079
+ studyInputJson: source.studyInputJson,
1080
+ topology: source.topology,
1081
+ coordsKind: source.coordsKind,
1082
+ view: source.view,
1083
+ substations: source.substations
1084
+ });
1085
+ for (const key of [
1086
+ 'diagram',
1087
+ 'displayMode',
1088
+ 'geoSource',
1089
+ 'geoWarnings',
1090
+ 'syntheticCenter',
1091
+ 'formulation',
1092
+ 'deltas',
1093
+ 'ratings',
1094
+ 'solution',
1095
+ 'baseSolution',
1096
+ 'iterations',
1097
+ 'solveMs',
1098
+ 'solveBackend'
1099
+ ])
1100
+ Object.assign(copy, { [key]: source[key] });
1101
+ const captured = this.detachedCaseCaptures.get(source);
1102
+ if (captured)
1103
+ this.detachedCaseCaptures.set(copy, { ...captured });
1104
+ return copy;
1105
+ }
1106
+ async attachSidecarsToSelected(layers, displays = [], expected) {
1107
+ if (this.app.studyView) {
1108
+ this.app.error = 'Return to the live case before attaching coordinates';
895
1109
  return;
896
1110
  }
1111
+ const source = this.app.activeMulti ?? this.app.activeLocal ?? this.app.active;
1112
+ if (expected !== undefined && source !== expected) {
1113
+ this.app.error = 'The selected case changed. Attach the coordinates again';
1114
+ return;
1115
+ }
1116
+ if (!source) {
1117
+ this.app.error = 'Select the matching case before attaching coordinates';
1118
+ return;
1119
+ }
1120
+ if (source instanceof MulticonductorCase) {
1121
+ try {
1122
+ await this.applyMultiGeoLayers(source, layers);
1123
+ }
1124
+ catch (error) {
1125
+ this.app.error = errorText(error);
1126
+ }
1127
+ if (displays.length)
1128
+ this.app.error = 'Attach a GeoJSON or CSV drawing to this multiconductor case';
1129
+ return;
1130
+ }
1131
+ const selectedId = source.id;
1132
+ const revision = source.revisionGeneration;
1133
+ let target = null;
897
1134
  try {
898
- await this.applyGeoLayers(target, layers);
1135
+ target =
1136
+ source instanceof CaseState
1137
+ ? await this.localSidecarCopy(source)
1138
+ : this.stageLocalSidecars(source);
1139
+ if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId || this.app.studyView)
1140
+ throw new Error('The selected case changed. Attach the coordinates again');
1141
+ const placement = await this.prepareDroppedLocal(target, layers, displays);
1142
+ const applied = placement.geoLayersConsumed || displays.some((display) => display.consumed);
1143
+ if (!applied)
1144
+ throw new Error(placement.error ?? 'No coordinates matched the selected case');
1145
+ if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId ||
1146
+ this.app.studyView ||
1147
+ source.revisionGeneration !== revision)
1148
+ throw new Error('The selected case changed. Attach the coordinates again');
1149
+ const saved = this.detachedCaseCaptures.get(target);
1150
+ if (saved)
1151
+ saved.baseInput = target.studyInputJson;
1152
+ if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId ||
1153
+ this.app.studyView ||
1154
+ source.revisionGeneration !== revision ||
1155
+ source.solving)
1156
+ throw new Error('The selected case changed. Attach the coordinates again');
1157
+ if (source instanceof LocalCase) {
1158
+ const cached = this.caseStudies.get(source);
1159
+ if (cached && target.studyInputJson && target.view) {
1160
+ const layer = await extractGeo(target.studyInputJson);
1161
+ try {
1162
+ await cached.study.applyGeoLayer(layer);
1163
+ }
1164
+ catch (error) {
1165
+ this.disposeStudy(source);
1166
+ throw error;
1167
+ }
1168
+ if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId ||
1169
+ this.app.studyView ||
1170
+ source.revisionGeneration !== revision ||
1171
+ source.solving) {
1172
+ this.disposeStudy(source);
1173
+ throw new Error('The selected case changed. Attach the coordinates again');
1174
+ }
1175
+ cached.studyInputJson = target.studyInputJson;
1176
+ }
1177
+ for (const key of [
1178
+ 'studyInputJson',
1179
+ 'view',
1180
+ 'diagram',
1181
+ 'displayMode',
1182
+ 'coordsKind',
1183
+ 'geoSource',
1184
+ 'geoWarnings',
1185
+ 'syntheticCenter'
1186
+ ])
1187
+ Object.assign(source, { [key]: target[key] });
1188
+ if (saved)
1189
+ this.detachedCaseCaptures.set(source, saved);
1190
+ target = source;
1191
+ }
1192
+ this.app.studyView = null;
1193
+ this.clearSelection();
899
1194
  this.app.activeCaseId = null;
900
1195
  this.leaveMulti();
1196
+ if (target !== source)
1197
+ this.app.addLocal(target);
901
1198
  this.app.activeLocalId = target.id;
902
1199
  this.app.placingLocalId = null;
1200
+ target.network = this.localNetwork(target);
1201
+ this.bumpRevision(target);
903
1202
  this.app.requestFrame(target.id);
904
1203
  this.maybeStartLocalSolve(target.id);
905
- this.app.error = null;
1204
+ this.app.error = placement.error;
906
1205
  }
907
- catch (e) {
908
- this.app.error = `${layers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`;
1206
+ catch (error) {
1207
+ if (target && target !== source)
1208
+ this.disposeStudy(target);
1209
+ this.app.error = errorText(error);
909
1210
  }
1211
+ }
1212
+ applyGeoLayersToExisting = async (layers) => {
1213
+ await this.attachSidecarsToSelected(layers);
910
1214
  };
911
1215
  /** Ask the map to frame the selected branch and wait for the camera to land,
912
1216
  * capped by FOCUS_SETTLE_CAP_MS and cut short by the selection's abort. */
@@ -946,13 +1250,15 @@ export class Controller {
946
1250
  if (ac.signal.aborted)
947
1251
  return;
948
1252
  }
1253
+ if (c.formulation === 'acpf')
1254
+ return;
949
1255
  try {
950
1256
  // The column from the browser Study (under the case's formulation). DC OPF
951
1257
  // may reconcile a null column via the server; AC OPF / SOCWR are browser
952
1258
  // only, so a null column there is terminal.
953
- const networkJson = await this.ensureNetworkJson(c);
954
- if (networkJson) {
955
- const sensitivity = await this.browserSensitivity(c, networkJson, target);
1259
+ const studyInputJson = await this.ensureStudyInputJson(c);
1260
+ if (studyInputJson) {
1261
+ const sensitivity = await this.browserSensitivity(c, studyInputJson, target);
956
1262
  if (!ac.signal.aborted && sensitivity) {
957
1263
  this.acceptSensitivity(c, sensitivity, target, sensitivitySeq);
958
1264
  handled = true;
@@ -1005,7 +1311,7 @@ export class Controller {
1005
1311
  computeOffNotice(c) {
1006
1312
  return this.studyUnavailable.has(c) ? WASM_REQUIRED_NOTICE : COMPUTE_OFF_NOTICE;
1007
1313
  }
1008
- /** The one server ∂LMP/∂parameter request a selection may make: the
1314
+ /** The one server nodal-value/parameter request a selection may make: the
1009
1315
  * reconciliation path when the browser Study produced no column for a DC OPF
1010
1316
  * case. Never retries on its own: a second request inside the same rate limit
1011
1317
  * window is a guaranteed rejection, and the retry button covers the user
@@ -1016,7 +1322,8 @@ export class Controller {
1016
1322
  const retryOp = 'bus' in target
1017
1323
  ? () => this.selectBus(c.id, target.bus)
1018
1324
  : () => this.selectBranch(c.id, target.branch);
1019
- if (this.serverComputeOff) {
1325
+ const api = this.api;
1326
+ if (this.serverComputeOff || !api) {
1020
1327
  this.fail(this.computeOffNotice(c), retryOp);
1021
1328
  return;
1022
1329
  }
@@ -1026,8 +1333,8 @@ export class Controller {
1026
1333
  }
1027
1334
  try {
1028
1335
  const col = 'bus' in target
1029
- ? await this.api.getSensitivity(c.id, target.bus, c.deltas, ac.signal)
1030
- : await this.api.getBranchSensitivity(c.id, target.branch, c.deltas, ac.signal);
1336
+ ? await api.getSensitivity(c.id, target.bus, c.deltas, ac.signal)
1337
+ : await api.getBranchSensitivity(c.id, target.branch, c.deltas, ac.signal);
1031
1338
  if (!ac.signal.aborted) {
1032
1339
  // The solution on screen came from the browser or the served base
1033
1340
  // solve; the column came from the server. Label the mix.
@@ -1071,7 +1378,7 @@ export class Controller {
1071
1378
  };
1072
1379
  selectLocalBus = async (localId, busId) => {
1073
1380
  const c = this.app.localCases.find((lc) => lc.id === localId);
1074
- if (!c?.networkJson || !c.network)
1381
+ if (!c?.studyInputJson || !c.network)
1075
1382
  return;
1076
1383
  this.app.activeCaseId = null;
1077
1384
  this.leaveMulti();
@@ -1079,16 +1386,20 @@ export class Controller {
1079
1386
  this.app.placingLocalId = null;
1080
1387
  const { ac, sensitivitySeq } = this.beginBusSelection(c, busId);
1081
1388
  try {
1082
- const sensitivity = await this.browserSensitivity(c, c.networkJson, {
1389
+ if (c.formulation === 'acpf')
1390
+ return;
1391
+ const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1083
1392
  bus: busId
1084
1393
  });
1085
1394
  if (!ac.signal.aborted)
1086
1395
  this.acceptSensitivity(c, sensitivity, { bus: busId }, sensitivitySeq);
1087
1396
  if (!ac.signal.aborted && !sensitivity) {
1088
- // A null column means the solve ran but produced no dLMP/dd for this bus (or the
1397
+ // A null column means the solve ran but produced no price/demand column for this bus (or the
1089
1398
  // Study could not be built); local cases have no server fallback, so say so
1090
- // instead of leaving the panel in LMP view with no explanation.
1091
- this.app.error = `${c.label}: ${formulationLabel(c.formulation)} sensitivity unavailable in the browser${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ' (no dLMP/dd column for this bus)'}`;
1399
+ // instead of leaving the panel in nodal value view with no explanation.
1400
+ this.app.error = `${c.label}: ${formulationLabel(c.formulation)} sensitivity unavailable in the browser${c.solveFallbackReason
1401
+ ? `: ${c.solveFallbackReason}`
1402
+ : ' (no price sensitivity column for this bus)'}`;
1092
1403
  }
1093
1404
  }
1094
1405
  catch (e) {
@@ -1112,7 +1423,7 @@ export class Controller {
1112
1423
  failColumnUnavailable(c, target, retry) {
1113
1424
  const what = 'bus' in target
1114
1425
  ? `${formulationLabel(c.formulation)} sensitivity`
1115
- : '∂LMP/∂rating sensitivity';
1426
+ : 'price/rating sensitivity';
1116
1427
  this.fail(`${this.caseName(c)}: ${what} unavailable${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`, retry);
1117
1428
  }
1118
1429
  /** `focus` frames the selected line on the map first; the sensitivity solve
@@ -1120,7 +1431,7 @@ export class Controller {
1120
1431
  selectBranch = (caseId, branchId, opts = {}) => this.selectTarget(caseId, { branch: branchId }, opts.focus ?? false);
1121
1432
  selectLocalBranch = async (localId, branchId, opts = {}) => {
1122
1433
  const c = this.app.localCases.find((lc) => lc.id === localId);
1123
- if (!c?.networkJson || !c.network)
1434
+ if (!c?.studyInputJson || !c.network)
1124
1435
  return;
1125
1436
  this.app.activeCaseId = null;
1126
1437
  this.leaveMulti();
@@ -1134,7 +1445,9 @@ export class Controller {
1134
1445
  if (ac.signal.aborted)
1135
1446
  return;
1136
1447
  }
1137
- const sensitivity = await this.browserSensitivity(c, c.networkJson, {
1448
+ if (c.formulation === 'acpf')
1449
+ return;
1450
+ const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1138
1451
  branch: branchId
1139
1452
  });
1140
1453
  if (ac.signal.aborted)
@@ -1160,11 +1473,13 @@ export class Controller {
1160
1473
  this.abort?.abort();
1161
1474
  const c = this.app.active;
1162
1475
  if (c) {
1476
+ this.bumpRevision(c);
1163
1477
  c.sensitivitySeq++;
1164
1478
  c.sensitivity = null;
1165
1479
  }
1166
1480
  const lc = this.app.activeLocal;
1167
1481
  if (lc) {
1482
+ this.bumpRevision(lc);
1168
1483
  lc.sensitivitySeq = (lc.sensitivitySeq ?? 0) + 1;
1169
1484
  lc.sensitivity = null;
1170
1485
  }
@@ -1180,7 +1495,7 @@ export class Controller {
1180
1495
  this.app.previewDeltaMw = null;
1181
1496
  this.app.previewRatingMw = null;
1182
1497
  this.app.previewActive = false;
1183
- this.app.previewLmp = null;
1498
+ this.app.previewPrices = null;
1184
1499
  this.previewObjective = null;
1185
1500
  this.ratingSlope = null;
1186
1501
  this.app.demandRangeMode = 'local';
@@ -1188,12 +1503,14 @@ export class Controller {
1188
1503
  this.app.sensitivityLoading = false;
1189
1504
  };
1190
1505
  // Exact solve in the browser (wasm). The build-once Study commits the new
1191
- // operating point without re-parsing, returning the ∂LMP/∂parameter column for
1192
- // the selection target in the same solve; on a Study failure or missing network
1193
- // JSON it reconciles via the server stream (backend cases). Rating edits never
1506
+ // operating point without re-parsing, returning the nodal-value/parameter column for
1507
+ // the selection target in the same solve; on a Study failure or missing PowerIO
1508
+ // module it reconciles via the server stream (backend cases). Rating edits never
1194
1509
  // fall back: the server solves at base ratings, so a Study failure there is
1195
1510
  // terminal.
1196
1511
  runSolve = (c, target) => {
1512
+ if (c.formulation === 'acpf')
1513
+ target = null;
1197
1514
  // Cancel this case's own previous server stream, if any (backend only).
1198
1515
  if (this.isBackendCase(c)) {
1199
1516
  c.closeStream?.();
@@ -1207,11 +1524,18 @@ export class Controller {
1207
1524
  c.solveFallbackReason = null;
1208
1525
  c.iterations = [];
1209
1526
  c.solveMs = null;
1210
- this.ensureNetworkJson(c).then(async (networkJson) => {
1527
+ this.ensureStudyInputJson(c).then(async (studyInputJson) => {
1211
1528
  if (seq !== (c.solveSeq ?? 0))
1212
1529
  return;
1213
- if (!networkJson) {
1214
- c.solveFallbackReason ??= 'browser network JSON unavailable';
1530
+ if (!studyInputJson) {
1531
+ c.solveFallbackReason ??= 'PowerIO module unavailable';
1532
+ // The server fallback only implements DC OPF. Preserve the requested
1533
+ // calculation when its browser input cannot be loaded.
1534
+ if (c.formulation !== 'dcopf') {
1535
+ c.solving = false;
1536
+ this.app.error = `${this.caseName(c)}: ${formulationLabel(c.formulation)} requires its PowerIO module; ${c.solveFallbackReason}`;
1537
+ return;
1538
+ }
1215
1539
  if (this.hasRatingEdits(c)) {
1216
1540
  c.solving = false;
1217
1541
  this.app.error = this.ratingEditsFallbackError(c);
@@ -1220,15 +1544,15 @@ export class Controller {
1220
1544
  if (this.isBackendCase(c))
1221
1545
  return this.serverSolve(c, target, seq);
1222
1546
  c.solving = false;
1223
- this.app.error = `${c.label}: local case has no browser network JSON`;
1547
+ this.app.error = `${c.label}: local case has no retained PowerIO module`;
1224
1548
  return;
1225
1549
  }
1226
1550
  const t0 = performance.now();
1227
1551
  c.solveBackend = 'clarabel-wasm';
1228
1552
  // Build-once Study path: commit the new operating point (no re-parse). The
1229
- // ∂LMP/∂parameter column for the selection target is computed in the same
1553
+ // selected nodal value sensitivity column is computed in the same
1230
1554
  // solve and comes back with it, so there is no second solve to reconcile it.
1231
- const study = await this.getStudy(c, networkJson);
1555
+ const study = await this.getStudy(c, studyInputJson);
1232
1556
  if (seq !== (c.solveSeq ?? 0))
1233
1557
  return;
1234
1558
  if (study) {
@@ -1303,13 +1627,17 @@ export class Controller {
1303
1627
  return;
1304
1628
  }
1305
1629
  c.solving = false;
1306
- this.app.error = `${c.label}: browser solve unavailable${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`;
1630
+ const failure = browserSolveFailure(c.label, c.solveFallbackReason);
1631
+ this.app.error = failure.message;
1632
+ this.app.errorDetail = failure.detail;
1307
1633
  });
1308
1634
  };
1309
1635
  serverSolve = (c, target, seq = c.solveSeq) => {
1310
- // Known-disabled server compute: show the notice instead of opening a
1311
- // stream that answers 403 without a readable body (EventSource hides it).
1312
- if (this.serverComputeOff) {
1636
+ // Known-disabled server compute (or no backend at all): show the notice
1637
+ // instead of opening a stream that answers 403 without a readable body
1638
+ // (EventSource hides it).
1639
+ const api = this.api;
1640
+ if (this.serverComputeOff || !api) {
1313
1641
  if (seq !== c.solveSeq)
1314
1642
  return;
1315
1643
  c.solving = false;
@@ -1325,7 +1653,7 @@ export class Controller {
1325
1653
  c.solveFallbackReason ??= 'browser solve unavailable';
1326
1654
  // The server computes bus columns only; a branch target streams no sensitivity.
1327
1655
  const sensBus = target && 'bus' in target ? target.bus : null;
1328
- c.closeStream = this.api.openSolveStream(c.id, c.deltas, sensBus, {
1656
+ c.closeStream = api.openSolveStream(c.id, c.deltas, sensBus, {
1329
1657
  onsolution: (sol) => {
1330
1658
  if (seq !== c.solveSeq)
1331
1659
  return;
@@ -1366,6 +1694,7 @@ export class Controller {
1366
1694
  this.runPreview(c, bus, value);
1367
1695
  c.predictedObjective = this.predictedDeltaObj;
1368
1696
  c.deltas = this.previewDeltas(c, bus, value);
1697
+ this.bumpRevision(c);
1369
1698
  this.app.previewDeltaMw = value;
1370
1699
  this.app.previewActive = true;
1371
1700
  this.runSolve(c, { bus });
@@ -1373,7 +1702,7 @@ export class Controller {
1373
1702
  finishDemandInput = (value) => {
1374
1703
  if (this.sliderDisabled)
1375
1704
  return;
1376
- if (Math.abs(value - this.committedDelta) < 0.25) {
1705
+ if (Math.abs(value - this.committedDelta) < DEAD_ZONE_MW) {
1377
1706
  if (!this.activeSolvable?.solving) {
1378
1707
  this.app.previewActive = false;
1379
1708
  this.app.previewDeltaMw = null;
@@ -1385,8 +1714,9 @@ export class Controller {
1385
1714
  resetCase = (c) => {
1386
1715
  c.deltas = {};
1387
1716
  c.ratings = {};
1717
+ this.bumpRevision(c);
1388
1718
  c.predictedObjective = null;
1389
- this.app.previewLmp = null;
1719
+ this.app.previewPrices = null;
1390
1720
  this.previewObjective = null;
1391
1721
  this.ratingSlope = null;
1392
1722
  this.app.previewDeltaMw = this.app.selectedBus === null ? null : 0;
@@ -1399,19 +1729,19 @@ export class Controller {
1399
1729
  c.solution = c.baseSolution;
1400
1730
  this.runSolve(c, this.selectionTarget);
1401
1731
  };
1402
- // Switch the active case to a new OPF formulation. Solving every formulation stays
1403
- // entirely in the browser via the Study (nothing is routed to the server), so this
1404
- // disposes the old Study — `getStudy` rebuilds it for the new formulation, re-parsing
1405
- // and re-solving the base — then re-solves at the committed demand. The base solution
1406
- // is dropped so it is recaptured under the new formulation (a DC and an AC objective
1407
- // are not comparable). A no-op when the choice is unchanged.
1732
+ /** Rebuild the numerical Study for a selected calculation at the current demand. */
1408
1733
  changeFormulation = (c, next) => {
1409
1734
  if (c.formulation === next)
1410
1735
  return;
1411
- // Disabled menu items (e.g. AC OPF, coming soon) are not selectable in the engine yet.
1736
+ if (c instanceof LocalCase && c.declaredFormulation && c.declaredFormulation !== next) {
1737
+ this.fail('This PowerIO instance requires its declared calculation.');
1738
+ return;
1739
+ }
1412
1740
  if (FORMULATIONS.find((f) => f.id === next)?.disabled)
1413
1741
  return;
1414
1742
  c.formulation = next;
1743
+ this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
1744
+ this.bumpRevision(c);
1415
1745
  // The committed point carries over (same demand), but the model and its solution do
1416
1746
  // not; drop the Study and the cached solutions so they rebuild under `next`.
1417
1747
  this.disposeStudy(c);
@@ -1420,7 +1750,7 @@ export class Controller {
1420
1750
  c.iterations = [];
1421
1751
  c.solveMs = null;
1422
1752
  c.predictedObjective = null;
1423
- this.app.previewLmp = null;
1753
+ this.app.previewPrices = null;
1424
1754
  this.previewObjective = null;
1425
1755
  this.ratingSlope = null;
1426
1756
  this.app.error = null;
@@ -1460,10 +1790,8 @@ export class Controller {
1460
1790
  }
1461
1791
  };
1462
1792
  ingestFileBatch = async (list) => {
1793
+ const sidecarTarget = this.app.activeMulti ?? this.app.activeLocal ?? this.app.active;
1463
1794
  let parsedCaseCount = 0;
1464
- // True once a dropped balanced case took the geographic sidecars. When no
1465
- // case parses, the selected existing case may take them at the end.
1466
- let geoLayersConsumed = false;
1467
1795
  // Routable `.json` content (saved packages, model JSON, and transmission or
1468
1796
  // distribution documents) shares the extension with geographic files. Route
1469
1797
  // it up front, but stage recognized payloads until the geographic/display
@@ -1475,9 +1803,7 @@ export class Controller {
1475
1803
  for (const file of list) {
1476
1804
  if (file.name.toLowerCase().endsWith('.json')) {
1477
1805
  const route = await this.routeJsonDrop(file);
1478
- if (route.outcome === 'balanced' ||
1479
- route.outcome === 'restored' ||
1480
- route.outcome === 'multiconductor') {
1806
+ if (route.outcome === 'balanced' || route.outcome === 'multiconductor') {
1481
1807
  parsedCaseCount++;
1482
1808
  routedJson.push({ file, route });
1483
1809
  continue;
@@ -1499,7 +1825,7 @@ export class Controller {
1499
1825
  geoLayers.push({
1500
1826
  name: file.name,
1501
1827
  layer: parsed.layer,
1502
- warnings: parsed.warnings
1828
+ diagnostics: parsed.diagnostics
1503
1829
  });
1504
1830
  this.app.error = null;
1505
1831
  }
@@ -1543,16 +1869,21 @@ export class Controller {
1543
1869
  try {
1544
1870
  for (const { file, route } of routedJson) {
1545
1871
  if (route.outcome === 'multiconductor') {
1546
- this.addMultiCase(file.name, route.payload);
1872
+ const multi = this.addMultiCase(file.name, route.payload);
1873
+ if (geoLayers.length) {
1874
+ try {
1875
+ await this.applyMultiGeoLayers(multi, geoLayers);
1876
+ }
1877
+ catch (error) {
1878
+ this.app.error = errorText(error);
1879
+ }
1880
+ }
1547
1881
  continue;
1548
1882
  }
1549
- if (route.outcome !== 'balanced' && route.outcome !== 'restored')
1883
+ if (route.outcome !== 'balanced')
1550
1884
  continue;
1551
- const local = route.outcome === 'restored'
1552
- ? this.localFromPackage(file.name, route.payload)
1553
- : this.localFromBalancedPayload(file.name, route.payload);
1885
+ const local = this.localFromBalancedPayload(file.name, route.payload);
1554
1886
  const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
1555
- geoLayersConsumed ||= placement.geoLayersConsumed;
1556
1887
  this.addAndActivateLocal(local);
1557
1888
  this.app.error = placement.error;
1558
1889
  }
@@ -1564,8 +1895,17 @@ export class Controller {
1564
1895
  // OpenDSS routes by extension into the multiconductor viewing path.
1565
1896
  const distFormat = distExtensionFormat(file.name);
1566
1897
  if (distFormat) {
1567
- if (await this.ingestDistFile(file, distFormat))
1898
+ if (await this.ingestDistFile(file, distFormat)) {
1568
1899
  parsedCaseCount++;
1900
+ if (geoLayers.length && this.app.activeMulti) {
1901
+ try {
1902
+ await this.applyMultiGeoLayers(this.app.activeMulti, geoLayers);
1903
+ }
1904
+ catch (error) {
1905
+ this.app.error = errorText(error);
1906
+ }
1907
+ }
1908
+ }
1569
1909
  continue;
1570
1910
  }
1571
1911
  const format = formatOf(file.name);
@@ -1576,7 +1916,7 @@ export class Controller {
1576
1916
  this.app.parsingFile = true;
1577
1917
  try {
1578
1918
  const bytes = new Uint8Array(await file.arrayBuffer());
1579
- const { network_json, topology, view, ...summary } = await ingestCase(bytes, format);
1919
+ const { module_json, topology, view, ...summary } = await ingestCase(bytes, format);
1580
1920
  if (format === 'aux' && (summary.n_branch === 0 || summary.n_gen === 0)) {
1581
1921
  this.app.error = `${file.name}: aux parsed, but no complete network; drop the matching .m or .raw case file`;
1582
1922
  continue;
@@ -1590,13 +1930,12 @@ export class Controller {
1590
1930
  label,
1591
1931
  fileName: file.name,
1592
1932
  summary,
1593
- networkJson: network_json,
1933
+ studyInputJson: module_json,
1594
1934
  topology,
1595
1935
  coordsKind: summary.coords_kind,
1596
1936
  view
1597
1937
  });
1598
1938
  const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
1599
- geoLayersConsumed ||= placement.geoLayersConsumed;
1600
1939
  this.addAndActivateLocal(local);
1601
1940
  parsedCaseCount++;
1602
1941
  this.app.error = placement.error; // a successful parse clears a prior file's error
@@ -1608,28 +1947,13 @@ export class Controller {
1608
1947
  this.app.parsingFile = false;
1609
1948
  }
1610
1949
  }
1611
- // A .pwd no case consumed becomes a substation point preview entry, at
1612
- // the approximate positions the engine projected.
1613
- for (const d of displays.filter((entry) => !entry.consumed)) {
1614
- const points = d.preview.substations.map((s) => ({
1615
- number: s.number,
1616
- name: s.name,
1617
- lon: s.lon,
1618
- lat: s.lat
1619
- }));
1620
- this.addAndActivateLocal(new LocalCase({
1621
- id: `local-${++this.localSeq}`,
1622
- label: d.file.name.replace(/\.[^.]+$/, ''),
1623
- fileName: d.file.name,
1624
- summary: null,
1625
- view: null,
1626
- substations: { points, approximate: true }
1627
- }));
1628
- }
1629
- // When the drop carried only sidecars, apply them to the selected existing
1630
- // case. Every balanced case parsed in this batch already took them above.
1631
- if (geoLayers.length > 0 && !geoLayersConsumed && parsedCaseCount === 0) {
1632
- await this.applyGeoLayersToExisting(geoLayers);
1950
+ if (parsedCaseCount === 0 && (geoLayers.length > 0 || displays.length > 0)) {
1951
+ await this.attachSidecarsToSelected(geoLayers, displays, sidecarTarget);
1952
+ }
1953
+ else {
1954
+ const unmatchedDisplays = displays.filter((display) => !display.consumed);
1955
+ if (unmatchedDisplays.length)
1956
+ this.app.error = `${unmatchedDisplays.map((display) => display.file.name).join(' + ')}: no drawing objects matched the dropped case`;
1633
1957
  }
1634
1958
  };
1635
1959
  /** Apply co-dropped geographic data before a local case becomes active and
@@ -1637,43 +1961,96 @@ export class Controller {
1637
1961
  * the first matching PowerWorld display. A failed geographic join keeps the
1638
1962
  * parsed case and returns the user-facing placement error. */
1639
1963
  prepareDroppedLocal = async (local, geoLayers, displays) => {
1640
- if (geoLayers.length > 0 && local.networkJson) {
1964
+ let geoLayersConsumed = false;
1965
+ let error = null;
1966
+ if (geoLayers.length > 0 && local.studyInputJson) {
1641
1967
  try {
1642
1968
  await this.applyGeoLayers(local, geoLayers);
1643
- return { geoLayersConsumed: true, error: null };
1969
+ geoLayersConsumed = true;
1644
1970
  }
1645
1971
  catch (e) {
1646
- return {
1647
- geoLayersConsumed: false,
1648
- error: `${geoLayers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`
1649
- };
1972
+ error = `${geoLayers.map((layer) => layer.name).join(' + ')}: ${errorText(e)}`;
1650
1973
  }
1651
1974
  }
1652
- if (local.coordsKind === 'synthetic_pending' && local.networkJson) {
1975
+ if (local.studyInputJson && displays.length > 0) {
1653
1976
  await this.fillFromDisplaySibling(local, displays);
1654
1977
  }
1655
- return { geoLayersConsumed: false, error: null };
1978
+ this.placeTopologyAsDiagram(local);
1979
+ return { geoLayersConsumed, error };
1656
1980
  };
1657
- /** Fill a coordinate-less case's positions from a co-dropped PowerWorld
1658
- * `.pwd`: the display's substation points project to approximate
1659
- * longitude/latitude and join onto buses through the `SubNum` extras key.
1660
- * The first display that joins wins; one that matches nothing stays
1661
- * available as its own preview entry, and the case stays placeable. */
1981
+ /** A case that still has no geometry gets a connectivity layout as its
1982
+ * diagram, so it can render and solve without a placement step. */
1983
+ placeTopologyAsDiagram(local) {
1984
+ if (local.view || local.diagram || !local.topology)
1985
+ return;
1986
+ const view = {
1987
+ ...placeSyntheticTopology(local.topology, { lon: 0, lat: 0 }),
1988
+ coordinate_space: 'diagram'
1989
+ };
1990
+ const layer = JSON.stringify({
1991
+ type: 'FeatureCollection',
1992
+ powerio_geo: { space: 'diagram', kind: 'synthetic' },
1993
+ features: [
1994
+ ...view.buses.map((b) => ({
1995
+ type: 'Feature',
1996
+ properties: { target: 'bus', id: String(b.id) },
1997
+ geometry: { type: 'Point', coordinates: [b.lon, b.lat] }
1998
+ })),
1999
+ ...view.branches.map((b) => ({
2000
+ type: 'Feature',
2001
+ properties: {
2002
+ target: 'branch',
2003
+ branch_id: String(b.id),
2004
+ from: String(b.from),
2005
+ to: String(b.to)
2006
+ },
2007
+ geometry: { type: 'LineString', coordinates: b.path }
2008
+ }))
2009
+ ]
2010
+ });
2011
+ local.diagram = {
2012
+ view,
2013
+ layer,
2014
+ name: 'Network diagram',
2015
+ warnings: ['No geographic coordinates; positions follow network connectivity.']
2016
+ };
2017
+ local.displayMode = 'diagram';
2018
+ }
2019
+ /** Retain diagram geometry separately from the electrical case's geographic data. */
1662
2020
  fillFromDisplaySibling = async (c, displays) => {
1663
- if (!c.networkJson)
2021
+ if (!c.studyInputJson)
1664
2022
  return;
1665
2023
  for (const d of displays.filter((entry) => !entry.consumed)) {
1666
2024
  try {
1667
- const payload = await applyDisplayGeo(c.networkJson, d.bytes);
1668
- this.adoptGeoPayload(c, payload, d.file.name, `${d.file.name} substations`, payload.report.notes);
2025
+ const payload = await applyDisplayGeo(c.studyInputJson, d.bytes);
2026
+ if (!payload.view)
2027
+ continue;
2028
+ c.diagram = {
2029
+ view: payload.view,
2030
+ layer: d.preview.layer,
2031
+ name: d.file.name,
2032
+ warnings: [
2033
+ `${payload.report.matched_buses} buses, ${payload.report.matched_branches} routes matched; ${payload.report.unmatched_features} unmatched objects`,
2034
+ ...payload.report.notes
2035
+ ]
2036
+ };
2037
+ if (!c.view)
2038
+ c.displayMode = 'diagram';
2039
+ c.geoWarnings = [...(c.geoWarnings ?? []), ...c.diagram.warnings];
1669
2040
  d.consumed = true;
1670
- return;
1671
2041
  }
1672
- catch {
1673
- // No SubNum join against this display; try the next one.
2042
+ catch (error) {
2043
+ c.geoWarnings = [errorText(error)];
1674
2044
  }
1675
2045
  }
1676
2046
  };
2047
+ setCaseDisplayMode = (c, mode) => {
2048
+ if (mode === 'diagram' ? !c.diagram : !c.view)
2049
+ return;
2050
+ c.displayMode = mode;
2051
+ c.network = this.localNetwork(c);
2052
+ this.app.requestFrame(c.id);
2053
+ };
1677
2054
  /** Route a dropped `.json` by its content. Recognized payloads return without
1678
2055
  * mutating case state so co-dropped sidecars can be applied first; unrouted
1679
2056
  * content returns the bytes already read for the geographic fallback. */
@@ -1694,15 +2071,20 @@ export class Controller {
1694
2071
  this.app.error = `${file.name}: JSON markers name both transmission and distribution formats`;
1695
2072
  return { outcome: 'failed' };
1696
2073
  }
1697
- if (result.kind === 'balanced-package') {
2074
+ if (result.kind === 'transmission') {
1698
2075
  this.app.error = null;
1699
- return { outcome: 'restored', payload: result.payload };
2076
+ return { outcome: 'balanced', payload: result.payload };
1700
2077
  }
1701
- if (result.kind === 'model-json' || result.kind === 'transmission') {
2078
+ if (result.kind === 'module') {
2079
+ // A stored module carries either network family; the payload's own
2080
+ // marker decides the route, mirroring the engine's value kind.
1702
2081
  this.app.error = null;
1703
- return { outcome: 'balanced', payload: result.payload };
2082
+ const payload = result.payload;
2083
+ if ('model' in payload)
2084
+ return { outcome: 'multiconductor', payload };
2085
+ return { outcome: 'balanced', payload };
1704
2086
  }
1705
- if (result.kind === 'multiconductor-package' || result.kind === 'distribution') {
2087
+ if (result.kind === 'distribution') {
1706
2088
  this.app.error = null;
1707
2089
  return { outcome: 'multiconductor', payload: result.payload };
1708
2090
  }
@@ -1734,14 +2116,14 @@ export class Controller {
1734
2116
  this.app.parsingFile = false;
1735
2117
  }
1736
2118
  };
1737
- /** Build a `MulticonductorCase` from an ingest payload and make it active.
1738
- * Geographic cases place immediately; planar/synthetic cases enter placement
1739
- * (a map click or "place on map") so the user picks their center. */
2119
+ /** Retain parsed electrical inputs and draw each declared coordinate space directly. */
1740
2120
  addMultiCase(fileName, payload) {
1741
2121
  const { graph, ...summary } = payload;
1742
2122
  const coordsKind = payload.coords_kind;
1743
2123
  const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1744
- const view = coordsKind === 'geographic' ? buildGeographicView(graph) : null;
2124
+ const view = coordsKind === 'geographic'
2125
+ ? buildGeographicView(graph, payload.geo_layer)
2126
+ : buildDiagramView(graph, payload.geo_layer);
1745
2127
  const c = new MulticonductorCase({
1746
2128
  id: `dist-${++this.localSeq}`,
1747
2129
  label,
@@ -1751,12 +2133,352 @@ export class Controller {
1751
2133
  coordsKind,
1752
2134
  view
1753
2135
  });
2136
+ this.app.studyView = null;
2137
+ this.clearSelection();
1754
2138
  this.app.addMulti(c);
1755
2139
  if (c.placed)
1756
2140
  this.app.requestFrame(c.id);
2141
+ return c;
1757
2142
  }
2143
+ /** Compute terminal quantities only for the retained input and current case revision. */
2144
+ solveMultiCase = async (c, options = {}, signal) => {
2145
+ if (signal?.aborted)
2146
+ throw new DOMException('Calculation cancelled', 'AbortError');
2147
+ if (c.solving)
2148
+ throw new Error('A calculation is already running for this case');
2149
+ const unavailable = c.mcPfReason;
2150
+ if (unavailable) {
2151
+ this.app.error = unavailable;
2152
+ throw new Error(unavailable);
2153
+ }
2154
+ let input = c.moduleJson;
2155
+ const retainedAtStart = c.mcSession;
2156
+ const revision = c.revisionGeneration;
2157
+ const seq = ++c.solveSeq;
2158
+ const abort = new AbortController();
2159
+ const cancel = () => abort.abort();
2160
+ signal?.addEventListener('abort', cancel, { once: true });
2161
+ c.solveAbort = abort;
2162
+ c.solving = true;
2163
+ this.app.error = null;
2164
+ const started = performance.now();
2165
+ let nextSession = null;
2166
+ try {
2167
+ if (retainedAtStart)
2168
+ input = await retainedAtStart.inputModule();
2169
+ let snapshot = null;
2170
+ let result;
2171
+ let fullResult = null;
2172
+ let loadBranches = c.mcLoadBranches;
2173
+ if (this.mcTransport.createMcPfSession) {
2174
+ // The session answers with its summary; terminal and equipment
2175
+ // detail is fetched only when a view asks for it.
2176
+ nextSession = await this.mcTransport.createMcPfSession(input, options, abort.signal);
2177
+ result = nextSession.initialSummary;
2178
+ loadBranches = await nextSession.loadBranches();
2179
+ }
2180
+ else {
2181
+ snapshot = this.mcTransport.solveMcStudy
2182
+ ? await this.mcTransport.solveMcStudy(input, crypto.randomUUID(), c.label, options, abort.signal)
2183
+ : null;
2184
+ fullResult =
2185
+ snapshot?.result ?? (await this.mcTransport.solveMcModule(input, options, abort.signal));
2186
+ result = summarizeMcPfResult(fullResult);
2187
+ }
2188
+ if (abort.signal.aborted)
2189
+ throw new DOMException('Calculation cancelled', 'AbortError');
2190
+ if (seq !== c.solveSeq ||
2191
+ (retainedAtStart ? retainedAtStart !== c.mcSession : input !== c.moduleJson) ||
2192
+ revision !== c.revisionGeneration ||
2193
+ !this.app.multiCases.includes(c))
2194
+ throw new Error('The case changed while calculating. Run AC power flow again');
2195
+ if (!result.converged)
2196
+ throw new Error('AC power flow did not converge');
2197
+ // A load edit staged against the previous session has nothing to
2198
+ // apply to the fresh operating point; drop its pending flush.
2199
+ if (c.mcEditTimer !== null) {
2200
+ clearTimeout(c.mcEditTimer);
2201
+ c.mcEditTimer = null;
2202
+ }
2203
+ c.mcSession?.free();
2204
+ c.mcSession = nextSession;
2205
+ nextSession = null;
2206
+ c.mcLoadBranches = loadBranches;
2207
+ c.mcLoadEdits = [];
2208
+ c.moduleJson = input;
2209
+ c.result = result;
2210
+ c.mcFullResult = fullResult;
2211
+ c.mcTiming = null;
2212
+ c.mcSnapshot = snapshot;
2213
+ c.mcSavedAt = null;
2214
+ c.solveMs = performance.now() - started;
2215
+ c.revisionGeneration++;
2216
+ return result;
2217
+ }
2218
+ catch (error) {
2219
+ nextSession?.free();
2220
+ if (!abort.signal.aborted &&
2221
+ !(error instanceof DOMException && error.name === 'AbortError')) {
2222
+ this.app.error = errorText(error);
2223
+ this.app.errorRetry = () => {
2224
+ void this.solveMultiCase(c, options).catch(() => { });
2225
+ };
2226
+ }
2227
+ throw error;
2228
+ }
2229
+ finally {
2230
+ signal?.removeEventListener('abort', cancel);
2231
+ if (c.solveSeq === seq) {
2232
+ c.solving = false;
2233
+ c.solveAbort = null;
2234
+ }
2235
+ }
2236
+ };
2237
+ /** Materialize the current retained operating point only when a portable
2238
+ * snapshot is actually needed (save/export/geography). */
2239
+ snapshotMultiCase = async (c) => {
2240
+ if (c.mcSnapshot)
2241
+ return c.mcSnapshot;
2242
+ const session = c.mcSession;
2243
+ if (!session || !c.result)
2244
+ throw new Error('Run AC power flow before saving its result');
2245
+ if (c.solving)
2246
+ throw new Error('Wait for the calculation before saving its result');
2247
+ const revision = c.revisionGeneration;
2248
+ const snapshot = await session.snapshot(crypto.randomUUID(), c.label);
2249
+ if (session !== c.mcSession ||
2250
+ revision !== c.revisionGeneration ||
2251
+ !this.app.multiCases.includes(c))
2252
+ throw new Error('The case changed while preparing its result');
2253
+ c.mcSnapshot = snapshot;
2254
+ return snapshot;
2255
+ };
2256
+ /** One bus's terminals and a page of equipment ports for the displayed
2257
+ * operating point, from the live session or the stored full result. Null
2258
+ * when the operating point changed before the page arrived. */
2259
+ multiResultDetail = async (c, query) => {
2260
+ const summary = c.result;
2261
+ if (!summary)
2262
+ return null;
2263
+ const session = c.mcSession;
2264
+ if (session && !c.mcFullResult) {
2265
+ const detail = await session.detail(query);
2266
+ return session === c.mcSession && c.result?.solve_count === detail.solve_count
2267
+ ? detail
2268
+ : null;
2269
+ }
2270
+ const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
2271
+ return full ? mcPfResultDetail(full, query, summary.solve_count) : null;
2272
+ };
2273
+ /** Every terminal's identity, voltage, and injected current, as numeric
2274
+ * arrays of interleaved `[re, im]` values. */
2275
+ multiTerminalTable = async (c) => {
2276
+ if (!c.result)
2277
+ return null;
2278
+ const session = c.mcSession;
2279
+ if (session && !c.mcFullResult) {
2280
+ // Arrays carry no solve count, and a running flush may discard its solve.
2281
+ if (c.mcEditRunning)
2282
+ return null;
2283
+ const solve = c.result.solve_count;
2284
+ const [ids, voltages, currents] = await Promise.all([
2285
+ session.terminalIds(),
2286
+ session.terminalVoltages(),
2287
+ session.terminalCurrents()
2288
+ ]);
2289
+ return session === c.mcSession && c.result?.solve_count === solve
2290
+ ? { ids, voltages, currents }
2291
+ : null;
2292
+ }
2293
+ const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
2294
+ if (!full)
2295
+ return null;
2296
+ const voltages = new Float64Array(2 * full.terminals.length);
2297
+ const currents = new Float64Array(2 * full.terminals.length);
2298
+ full.terminals.forEach((terminal, k) => {
2299
+ voltages[2 * k] = terminal.voltage.re;
2300
+ voltages[2 * k + 1] = terminal.voltage.im;
2301
+ currents[2 * k] = terminal.current_into_network.re;
2302
+ currents[2 * k + 1] = terminal.current_into_network.im;
2303
+ });
2304
+ return {
2305
+ ids: full.terminals.map((terminal) => [terminal.bus, terminal.terminal]),
2306
+ voltages,
2307
+ currents
2308
+ };
2309
+ };
2310
+ /** Apply coordinates atomically to the explicitly selected conductor-resolved case. */
2311
+ applyMultiGeoLayers = async (c, layers) => {
2312
+ if (!layers.length)
2313
+ return;
2314
+ if (this.app.studyView || this.app.activeMulti !== c)
2315
+ throw new Error('Select the matching case before attaching coordinates');
2316
+ if (c.solving)
2317
+ throw new Error('Wait for the calculation before attaching coordinates');
2318
+ if (!c.moduleJson || !this.mcTransport.applyMcGeo)
2319
+ throw new Error('Coordinate attachment is unavailable for this case');
2320
+ const baseInput = c.moduleJson;
2321
+ const retainedAtStart = c.mcSession;
2322
+ let snapshot = c.mcSnapshot;
2323
+ if (!snapshot && c.mcSession && c.result)
2324
+ snapshot = await this.snapshotMultiCase(c);
2325
+ const input = snapshot?.input_module ?? c.moduleJson;
2326
+ const revision = c.revisionGeneration;
2327
+ let next = input;
2328
+ let payload;
2329
+ const notes = [];
2330
+ let matched = 0;
2331
+ for (const layer of layers) {
2332
+ payload = await this.mcTransport.applyMcGeo(next, layer.layer);
2333
+ if (snapshot) {
2334
+ if (!this.mcTransport.applyMcStudyGeo)
2335
+ throw new Error('Saved result coordinates are unavailable in this build');
2336
+ snapshot = await this.mcTransport.applyMcStudyGeo(snapshot, layer.layer);
2337
+ }
2338
+ if (!payload.module_json)
2339
+ throw new Error('Coordinate attachment returned no electrical input');
2340
+ next = payload.module_json;
2341
+ matched += payload.report.matched_buses + payload.report.matched_branches;
2342
+ notes.push(`${payload.report.matched_buses} buses, ${payload.report.matched_branches} routes matched; ${payload.report.unmatched_features} unmatched objects`, ...payload.report.notes, ...layer.diagnostics.map(formatPowerIoDiagnostic));
2343
+ }
2344
+ if (!payload || !matched)
2345
+ throw new Error('No coordinates matched the selected case');
2346
+ if (this.app.studyView ||
2347
+ this.app.activeMulti !== c ||
2348
+ c.moduleJson !== baseInput ||
2349
+ c.mcSession !== retainedAtStart ||
2350
+ c.revisionGeneration !== revision ||
2351
+ c.solving)
2352
+ throw new Error('The selected case changed. Attach the coordinates again');
2353
+ const { graph, ...summary } = payload;
2354
+ if (c.mcEditTimer !== null) {
2355
+ clearTimeout(c.mcEditTimer);
2356
+ c.mcEditTimer = null;
2357
+ }
2358
+ c.mcSession?.free();
2359
+ c.mcSession = null;
2360
+ // Without a session, result detail comes from the saved result.
2361
+ c.mcFullResult = snapshot?.result ?? c.mcFullResult;
2362
+ c.mcLoadBranches = [];
2363
+ c.mcLoadEdits = [];
2364
+ c.moduleJson = next;
2365
+ c.mcSnapshot = snapshot ? { ...snapshot, id: crypto.randomUUID() } : null;
2366
+ c.mcSavedAt = null;
2367
+ c.geoLayer = payload.geo_layer ?? null;
2368
+ c.graph = graph;
2369
+ c.summary = summary;
2370
+ c.coordsKind = payload.coords_kind;
2371
+ c.view =
2372
+ payload.coords_kind === 'geographic'
2373
+ ? buildGeographicView(graph, payload.geo_layer)
2374
+ : buildDiagramView(graph, payload.geo_layer);
2375
+ c.geoWarnings = notes;
2376
+ c.revisionGeneration++;
2377
+ this.app.placingMultiId = null;
2378
+ this.app.error = null;
2379
+ this.app.requestFrame(c.id);
2380
+ };
2381
+ /** Stage an absolute load-branch P/Q edit and coalesce rapid UI changes into
2382
+ * one retained-factor warm solve. */
2383
+ queueMultiLoadPower = (c, load, branch, p_w, q_var) => {
2384
+ if (!c.mcSession) {
2385
+ this.app.error = 'Run AC power flow once before editing loads';
2386
+ return;
2387
+ }
2388
+ if (!Number.isFinite(p_w) || !Number.isFinite(q_var)) {
2389
+ this.app.error = 'Load P and Q must be finite numbers';
2390
+ return;
2391
+ }
2392
+ const row = c.mcLoadBranches.find((entry) => entry.load === load && entry.branch === branch);
2393
+ if (!row) {
2394
+ this.app.error = `Load ${load} branch ${branch} is unavailable`;
2395
+ return;
2396
+ }
2397
+ c.mcLoadBranches = c.mcLoadBranches.map((entry) => entry.load === load && entry.branch === branch ? { ...entry, p_w, q_var } : entry);
2398
+ const next = { load, branch, p_w, q_var };
2399
+ const without = c.mcLoadEdits.filter((edit) => edit.load !== load || edit.branch !== branch);
2400
+ c.mcLoadEdits = p_w === row.base_p_w && q_var === row.base_q_var ? without : [...without, next];
2401
+ c.mcEditRevision++;
2402
+ c.mcSnapshot = null;
2403
+ c.mcSavedAt = null;
2404
+ c.solving = true;
2405
+ if (c.mcEditTimer !== null)
2406
+ clearTimeout(c.mcEditTimer);
2407
+ c.mcEditTimer = setTimeout(() => {
2408
+ c.mcEditTimer = null;
2409
+ void this.flushMultiLoadPowers(c);
2410
+ }, 180);
2411
+ };
2412
+ flushMultiLoadPowers = async (c) => {
2413
+ // A running flush picks the new revision up in its own loop.
2414
+ if (c.mcEditRunning)
2415
+ return;
2416
+ const session = c.mcSession;
2417
+ if (!session || !this.app.multiCases.includes(c)) {
2418
+ // `queueMultiLoadPower` marked the case as solving on behalf of
2419
+ // this flush; release it, or Save and Export stay disabled.
2420
+ c.solving = false;
2421
+ return;
2422
+ }
2423
+ c.mcEditRunning = true;
2424
+ c.solving = true;
2425
+ this.app.error = null;
2426
+ try {
2427
+ for (;;) {
2428
+ const revision = c.mcEditRevision;
2429
+ const edits = c.mcLoadEdits.map((edit) => ({ ...edit }));
2430
+ const started = performance.now();
2431
+ const summary = await session.replaceLoadPowers(edits);
2432
+ if (session !== c.mcSession || !this.app.multiCases.includes(c))
2433
+ return;
2434
+ if (revision !== c.mcEditRevision)
2435
+ continue;
2436
+ // One round trip: the queued edit already set the branch state
2437
+ // this successful solve confirms. Only a failure refetches it.
2438
+ c.result = summary;
2439
+ c.mcFullResult = null;
2440
+ c.mcSnapshot = null;
2441
+ c.mcSavedAt = null;
2442
+ const total = performance.now() - started;
2443
+ c.solveMs = total;
2444
+ c.mcTiming = session.lastTiming ? { ...session.lastTiming, total_ms: total } : null;
2445
+ c.revisionGeneration++;
2446
+ break;
2447
+ }
2448
+ }
2449
+ catch (error) {
2450
+ if (session === c.mcSession) {
2451
+ try {
2452
+ c.mcLoadBranches = await session.loadBranches();
2453
+ }
2454
+ catch {
2455
+ // The session itself no longer answers (the worker trapped
2456
+ // or the handle is gone). Retrying against it would fail
2457
+ // forever, so drop it and let the next run rebuild the
2458
+ // session against a fresh worker.
2459
+ session.free();
2460
+ c.mcSession = null;
2461
+ c.mcLoadEdits = [];
2462
+ }
2463
+ }
2464
+ this.app.error = errorText(error);
2465
+ this.app.errorRetry = c.mcSession
2466
+ ? () => void this.flushMultiLoadPowers(c)
2467
+ : () => void this.solveMultiCase(c).catch(() => { });
2468
+ }
2469
+ finally {
2470
+ // The running flag belongs to this call regardless of whether the
2471
+ // session was replaced meanwhile; leaving it set would silently
2472
+ // ignore every later edit. The solving flag is shared with an
2473
+ // explicit solve, which clears it itself when it owns it.
2474
+ c.mcEditRunning = false;
2475
+ if (session === c.mcSession || c.mcSession === null)
2476
+ c.solving = false;
2477
+ }
2478
+ };
1758
2479
  /** Make a multiconductor case active, framing it when it is placed. */
1759
2480
  activateMulti = (c) => {
2481
+ this.app.studyView = null;
1760
2482
  this.clearSelection();
1761
2483
  this.app.activeCaseId = null;
1762
2484
  this.app.activeLocalId = null;
@@ -1814,6 +2536,11 @@ export class Controller {
1814
2536
  };
1815
2537
  removeMultiCase = async (c, event) => {
1816
2538
  event?.stopPropagation();
2539
+ c.solveAbort?.abort();
2540
+ if (c.mcEditTimer !== null)
2541
+ clearTimeout(c.mcEditTimer);
2542
+ c.mcSession?.free();
2543
+ c.mcSession = null;
1817
2544
  if (this.app.activeMultiId === c.id) {
1818
2545
  c.selectedBusId = null;
1819
2546
  c.selectedEdgeId = null;
@@ -1828,53 +2555,164 @@ export class Controller {
1828
2555
  else
1829
2556
  this.placeLocalCase(lon, lat);
1830
2557
  };
1831
- /** Build a local case from a bare balanced ingest payload without activating
2558
+ async projectStudyInput(input, layer) {
2559
+ const parsed = layer
2560
+ ? { payload: await applyGeo(input, layer) }
2561
+ : await ingestJsonDrop(new TextEncoder().encode(input));
2562
+ if (!parsed.payload || !('topology' in parsed.payload))
2563
+ throw new Error('Study requires a balanced network');
2564
+ const c = parsed.payload;
2565
+ const view = c.view ?? {
2566
+ ...placeSyntheticTopology(c.topology, { lon: 0, lat: 0 }),
2567
+ coordinate_space: 'diagram'
2568
+ };
2569
+ return { id: 'study', name: c.name, base_mva: c.base_mva, synthetic_coords: !c.view, ...view };
2570
+ }
2571
+ async caseGeographyLayers(c) {
2572
+ if (!c.studyInputJson || !c.view)
2573
+ return [];
2574
+ return [await extractGeo(c.studyInputJson)];
2575
+ }
2576
+ /** Capture a matching cached result without constructing or solving a model. */
2577
+ async captureSavedCase(c) {
2578
+ const base = await this.ensureStudyInputJson(c);
2579
+ if (!base)
2580
+ throw new Error('The case input is unavailable');
2581
+ const detached = c instanceof LocalCase ? this.detachedCaseCaptures.get(c) : undefined;
2582
+ if (detached?.baseInput === base &&
2583
+ detached.edits === this.caseEditKey(c) &&
2584
+ detached.solution === c.solution)
2585
+ return detached.captured;
2586
+ const cached = this.caseStudies.get(c);
2587
+ if (c.solution && cached?.studyInputJson === base && cached.formulation === c.formulation) {
2588
+ const current = await cached.study.currentSolution();
2589
+ if (JSON.stringify(current) === JSON.stringify(c.solution)) {
2590
+ return {
2591
+ input: await cached.study.saveInstanceModule(),
2592
+ solution: await cached.study.saveSolutionModule(),
2593
+ view: await cached.study.currentView()
2594
+ };
2595
+ }
2596
+ }
2597
+ if (Object.values(this.caseDeltas(c)).some((v) => v !== 0) ||
2598
+ Object.values(this.caseRatings(c)).some((v) => v !== 0)) {
2599
+ throw new Error('The displayed edits have no matching saved result. Finish solving before saving them.');
2600
+ }
2601
+ const api = this.api;
2602
+ if (c instanceof CaseState && c.solution && c.formulation === 'dcopf' && api?.getSavedCase) {
2603
+ const snapshot = await api.getSavedCase(c.id);
2604
+ if (snapshot.base_input !== base ||
2605
+ JSON.stringify(snapshot.display_solution) !== JSON.stringify(c.solution))
2606
+ throw new Error('The server case changed. Reload it before saving.');
2607
+ return { input: snapshot.input, solution: snapshot.solution, view: snapshot.view };
2608
+ }
2609
+ return { input: base };
2610
+ }
2611
+ /** Build a local case from a balanced PowerIO ingest payload without activating
1832
2612
  * it yet; co-dropped placement data is applied before the first solve. */
1833
2613
  localFromBalancedPayload = (fileName, payload) => {
1834
- const { network_json, topology, view, ...summary } = payload;
2614
+ const { module_json, topology, view, formulation, ...summary } = payload;
1835
2615
  const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1836
2616
  return new LocalCase({
1837
2617
  id: `local-${++this.localSeq}`,
1838
2618
  label,
1839
2619
  fileName,
2620
+ formulation,
2621
+ declaredFormulation: formulation,
1840
2622
  summary,
1841
- networkJson: network_json,
2623
+ studyInputJson: module_json,
1842
2624
  topology,
1843
2625
  coordsKind: summary.coords_kind,
1844
2626
  view
1845
2627
  });
1846
2628
  };
1847
- /** Build a local case from a saved package, including its edit state and
1848
- * formulation, without activating it until sidecars have been applied. */
1849
- localFromPackage = (fileName, pkg) => {
1850
- const { network_json, topology, view, formulation, deltas, rates, ...summary } = pkg;
1851
- const id = `local-${++this.localSeq}`;
1852
- const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1853
- const local = new LocalCase({
1854
- id,
1855
- label,
1856
- fileName,
1857
- summary,
1858
- networkJson: network_json,
1859
- topology,
1860
- coordsKind: summary.coords_kind,
1861
- view
2629
+ /** Ingest module text through the engine and require a balanced network. */
2630
+ async ingestBalancedModule(name, moduleJson) {
2631
+ if (moduleJson === undefined)
2632
+ throw new Error(`${name}: openBalancedModule needs moduleJson or payload`);
2633
+ const result = await ingestJsonDrop(new TextEncoder().encode(moduleJson));
2634
+ if (result.kind === 'transmission')
2635
+ return result.payload;
2636
+ if (result.kind === 'module' && !('model' in result.payload))
2637
+ return result.payload;
2638
+ throw new Error(`${name}: not a balanced network module (${result.kind})`);
2639
+ }
2640
+ /** Open a balanced powerio module from memory, the programmatic twin of
2641
+ * dropping a `.pio.json` file: ingest (unless `payload` is given), apply an
2642
+ * optional geographic layer, lay out a module without coordinates as a
2643
+ * diagram, then add and activate it. Resolves to the new case; throws when
2644
+ * the module is not a balanced network or the geographic layer matches
2645
+ * nothing. */
2646
+ openBalancedModule = async (input) => {
2647
+ this.app.parsingFile = true;
2648
+ try {
2649
+ const payload = input.payload ?? (await this.ingestBalancedModule(input.name, input.moduleJson));
2650
+ const local = this.localFromBalancedPayload(`${input.name}.pio.json`, {
2651
+ ...payload,
2652
+ name: input.name
2653
+ });
2654
+ if (input.formulation)
2655
+ local.formulation = input.formulation;
2656
+ if (input.geoLayer) {
2657
+ await this.applyGeoLayers(local, [
2658
+ { name: `${input.name}.geo.json`, layer: input.geoLayer, diagnostics: [] }
2659
+ ]);
2660
+ }
2661
+ this.placeTopologyAsDiagram(local);
2662
+ if (input.activate !== false)
2663
+ this.addAndActivateLocal(local, { frame: input.frame });
2664
+ return local;
2665
+ }
2666
+ finally {
2667
+ this.app.parsingFile = false;
2668
+ }
2669
+ };
2670
+ /** Swap a local case for a new module in place: same slot in the case list,
2671
+ * same formulation unless `input.formulation` says otherwise, same display
2672
+ * mode when the new case supports it, and the selected bus kept when the
2673
+ * new case has it. The previous case's Study is freed. */
2674
+ replaceLocalCase = async (previous, input) => {
2675
+ const wasActive = this.app.activeLocalId === previous.id;
2676
+ const selectedBus = wasActive ? this.app.selectedBus : null;
2677
+ const displayMode = previous.displayMode;
2678
+ const next = await this.openBalancedModule({
2679
+ ...input,
2680
+ formulation: input.formulation ?? previous.formulation,
2681
+ activate: false
1862
2682
  });
1863
- local.deltas = deltas;
1864
- local.ratings = rates;
1865
- local.formulation = formulation;
1866
- return local;
2683
+ if (displayMode === 'diagram' ? next.diagram : next.view)
2684
+ next.displayMode = displayMode;
2685
+ const slot = this.app.localCases.findIndex((c) => c.id === previous.id);
2686
+ // A seq bump invalidates any in-flight browser solve of the old case.
2687
+ previous.solveSeq = (previous.solveSeq ?? 0) + 1;
2688
+ this.disposeStudy(previous);
2689
+ if (wasActive) {
2690
+ this.addAndActivateLocal(next, { frame: input.frame });
2691
+ }
2692
+ else {
2693
+ this.app.localCases = [...this.app.localCases, next];
2694
+ this.maybeStartLocalSolve(next.id);
2695
+ }
2696
+ const cases = this.app.localCases.filter((c) => c.id !== previous.id && c.id !== next.id);
2697
+ cases.splice(slot < 0 ? cases.length : slot, 0, next);
2698
+ this.app.localCases = cases;
2699
+ if (this.app.placingLocalId === previous.id)
2700
+ this.app.placingLocalId = null;
2701
+ if (selectedBus !== null && localHasBus(next, selectedBus)) {
2702
+ void this.selectLocalBus(next.id, selectedBus);
2703
+ }
2704
+ return next;
1867
2705
  };
1868
2706
  /** Build (or reuse) the case's Study and commit it at the current edit state, so a
1869
2707
  * save or export captures exactly what is on screen. Null (with an error set) when no
1870
- * network or Study is available. */
2708
+ * PowerIO module or Study is available. */
1871
2709
  syncedStudy = async (c) => {
1872
- const networkJson = await this.ensureNetworkJson(c);
1873
- if (!networkJson) {
1874
- this.app.error = `${this.caseName(c)}: no network available to save`;
2710
+ const studyInputJson = await this.ensureStudyInputJson(c);
2711
+ if (!studyInputJson) {
2712
+ this.app.error = `${this.caseName(c)}: no PowerIO module available to save`;
1875
2713
  return null;
1876
2714
  }
1877
- const study = await this.getStudy(c, networkJson);
2715
+ const study = await this.getStudy(c, studyInputJson);
1878
2716
  if (!study) {
1879
2717
  this.app.error = `${this.caseName(c)}: the browser study is unavailable`;
1880
2718
  return null;
@@ -1882,31 +2720,47 @@ export class Controller {
1882
2720
  await study.commit(c.id, this.caseDeltas(c), this.caseRatings(c), null);
1883
2721
  return study;
1884
2722
  };
1885
- /** Save the active study as a `.pio.json` package and download it. */
1886
- saveStudyPackage = async (c) => {
2723
+ /** Save the materialized case as a retained PowerIO module. */
2724
+ saveCaseModule = async (c) => {
2725
+ const study = await this.syncedStudy(c);
2726
+ if (!study)
2727
+ return;
2728
+ try {
2729
+ downloadText(await study.saveModule(), `${this.caseFileStem(c)}.pio.json`, 'application/json');
2730
+ this.app.error = null;
2731
+ }
2732
+ catch (e) {
2733
+ this.app.error = `${this.caseName(c)}: could not save PowerIO module: ${errorText(e)}`;
2734
+ }
2735
+ };
2736
+ /** Save the exact DC OPF result with the materialized problem instance it solved. */
2737
+ saveSolutionModule = async (c) => {
2738
+ if (c.formulation !== 'dcopf') {
2739
+ this.app.error = `${this.caseName(c)}: exact solution modules require the DC OPF formulation`;
2740
+ return;
2741
+ }
1887
2742
  const study = await this.syncedStudy(c);
1888
2743
  if (!study)
1889
2744
  return;
1890
2745
  try {
1891
- downloadText(await study.savePackage(), `${this.caseFileStem(c)}.pio.json`, 'application/json');
2746
+ downloadText(await study.saveSolutionModule(), `${this.caseFileStem(c)}.solution.pio.json`, 'application/json');
1892
2747
  this.app.error = null;
1893
2748
  }
1894
2749
  catch (e) {
1895
- this.app.error = `${this.caseName(c)}: could not save study: ${errorText(e)}`;
2750
+ this.app.error = `${this.caseName(c)}: could not save exact solution: ${errorText(e)}`;
1896
2751
  }
1897
2752
  };
1898
- /** Export the active study's committed state to `format` and download it. Returns the
1899
- * writer's fidelity warnings so the caller can surface them. */
1900
- exportStudyAs = async (c, format) => {
2753
+ /** Export the active study's committed state to `format` and download it.
2754
+ * Returns this output operation's structured diagnostics. */
2755
+ exportCaseAs = async (c, format) => {
1901
2756
  const study = await this.syncedStudy(c);
1902
2757
  if (!study)
1903
2758
  return [];
1904
2759
  try {
1905
- const packageJson = await study.savePackage();
1906
- const exported = await exportStudy(packageJson, studyCommitIndex(packageJson), format);
2760
+ const exported = await study.export(format);
1907
2761
  downloadText(exported.text, `${this.caseFileStem(c)}.${exported.extension}`, 'text/plain');
1908
2762
  this.app.error = null;
1909
- return exported.warnings;
2763
+ return exported.diagnostics;
1910
2764
  }
1911
2765
  catch (e) {
1912
2766
  this.app.error = `${this.caseName(c)}: could not export study: ${errorText(e)}`;
@@ -1916,7 +2770,7 @@ export class Controller {
1916
2770
  /** A filesystem-safe base name for a downloaded case file. */
1917
2771
  caseFileStem(c) {
1918
2772
  const base = this.isBackendCase(c) ? c.name : c.label || c.fileName.replace(/\.[^.]+$/, '');
1919
- return base.replace(/[^\w.-]+/g, '_') || 'study';
2773
+ return base.replace(/[^\w.-]+/g, '_') || 'case';
1920
2774
  }
1921
2775
  setDemandRangeMode = (mode) => {
1922
2776
  this.app.demandRangeMode = mode;
@@ -1931,11 +2785,13 @@ export class Controller {
1931
2785
  };
1932
2786
  // First-order engine preview for the live drag. Uses the case's already-built
1933
2787
  // Study (no re-parse, no re-solve) to paint predicted per-bus
1934
- // ΔLMP and the predicted Δobjective. A no-op when the Study isn't built yet or
2788
+ // nodal value changes and the predicted objective change. A no-op when the Study isn't built yet or
1935
2789
  // can't preview (browser fallback path, server-only cases): the map then
1936
2790
  // falls back to the JS sensitivity-times-step preview.
1937
2791
  runPreview = (c, bus, value) => {
1938
- // Fast path: the committed ∂LMP/∂d column (already solved at the committed point),
2792
+ if (c.formulation === 'acpf')
2793
+ return;
2794
+ // Fast path: the committed price/demand column (already solved at the committed point),
1939
2795
  // scaled by the demand step, is the same first-order linearization the engine preview
1940
2796
  // returns — without rebuilding the differentiable KKT every drag frame. That engine
1941
2797
  // preview is ~80 ms/frame on the largest case (CATS, ~8870 buses), which blocked the
@@ -1943,16 +2799,25 @@ export class Controller {
1943
2799
  const col = c.sensitivity;
1944
2800
  if (col && col.bus === bus) {
1945
2801
  const committedAtBus = this.caseDeltas(c)[bus] ?? 0;
1946
- const step = (Math.abs(value) < 0.25 ? 0 : value) - committedAtBus;
2802
+ const step = (Math.abs(value) < DEAD_ZONE_MW ? 0 : value) - committedAtBus;
1947
2803
  const delta = new Map();
1948
2804
  for (const v of col.values)
1949
2805
  delta.set(v.bus, v.value * step);
1950
- this.app.previewLmp = { caseId: c.id, target: { bus }, delta };
1951
- const lmpAtBus = c.solution?.lmp.find((l) => l.bus === bus)?.usd_per_mwh ?? null;
2806
+ this.app.previewPrices = {
2807
+ caseId: c.id,
2808
+ target: { bus },
2809
+ delta,
2810
+ units: previewUnits(col.units)
2811
+ };
2812
+ const priceAtBus = c.solution?.prices.find((price) => price.bus === bus)?.value ?? null;
1952
2813
  this.previewObjective =
1953
- lmpAtBus === null
2814
+ priceAtBus === null
1954
2815
  ? null
1955
- : { caseId: c.id, target: { bus }, objectiveDelta: lmpAtBus * step };
2816
+ : {
2817
+ caseId: c.id,
2818
+ target: { bus },
2819
+ objectiveDelta: priceAtBus * step
2820
+ };
1956
2821
  return;
1957
2822
  }
1958
2823
  // Fallback (no committed column yet): the engine's first-order preview, which
@@ -1983,21 +2848,26 @@ export class Controller {
1983
2848
  if (!study)
1984
2849
  continue;
1985
2850
  try {
1986
- const { lmp, objectiveDelta } = await study.preview(this.previewDeltas(c, bus, value), this.caseRatings(c));
2851
+ const { prices, objectiveDelta, units } = await study.preview(this.previewDeltas(c, bus, value), this.caseRatings(c));
1987
2852
  // Apply only while this point is still the live drag target: a
1988
2853
  // preview that resolves after a re-selection or a commit is stale.
1989
2854
  if (!this.app.previewActive || this.activeSolvable !== c || this.app.selectedBus !== bus)
1990
2855
  continue;
1991
2856
  const delta = new Map();
1992
- for (const e of lmp)
1993
- delta.set(e.bus, e.usd_per_mwh);
1994
- this.app.previewLmp = { caseId: c.id, target: { bus }, delta };
2857
+ for (const e of prices)
2858
+ delta.set(e.bus, e.value);
2859
+ this.app.previewPrices = {
2860
+ caseId: c.id,
2861
+ target: { bus },
2862
+ delta,
2863
+ units: units?.replaceAll('objective_unit', 'objective units') ?? 'objective units/MW'
2864
+ };
1995
2865
  this.previewObjective =
1996
2866
  objectiveDelta === null ? null : { caseId: c.id, target: { bus }, objectiveDelta };
1997
2867
  }
1998
2868
  catch {
1999
2869
  if (this.activeSolvable === c && this.app.selectedBus === bus) {
2000
- this.app.previewLmp = null;
2870
+ this.app.previewPrices = null;
2001
2871
  this.previewObjective = null;
2002
2872
  }
2003
2873
  }
@@ -2021,27 +2891,34 @@ export class Controller {
2021
2891
  // tiny nudge reads as "no change at this line".
2022
2892
  previewRatings(c, branch, value) {
2023
2893
  const ratings = { ...this.caseRatings(c) };
2024
- if (Math.abs(value) < 0.25)
2894
+ if (Math.abs(value) < DEAD_ZONE_MW)
2025
2895
  delete ratings[branch];
2026
2896
  else
2027
2897
  ratings[branch] = value;
2028
2898
  return ratings;
2029
2899
  }
2030
2900
  // First-order engine preview for the live rating drag, mirroring runPreview: the
2031
- // committed ∂LMP/∂rating column scaled by the rating step paints predicted per-bus
2032
- // ΔLMP without touching the engine per frame. The predicted Δobjective comes from
2901
+ // committed price/rating column scaled by the rating step paints predicted per-bus
2902
+ // nodal value changes without touching the engine per frame. The predicted objective change comes from
2033
2903
  // a per-MW slope taken once from Study.preview at a +1 MW step off the committed
2034
2904
  // point (preview is replacement-absolute, so the absolute ratings map is built),
2035
2905
  // then scaled by the live step; null when no Study slope is available.
2036
2906
  runRatingPreview = (c, branch, value) => {
2907
+ if (c.formulation === 'acpf')
2908
+ return;
2037
2909
  const committedAtBranch = this.caseRatings(c)[branch] ?? 0;
2038
- const step = (Math.abs(value) < 0.25 ? 0 : value) - committedAtBranch;
2910
+ const step = (Math.abs(value) < DEAD_ZONE_MW ? 0 : value) - committedAtBranch;
2039
2911
  const col = c.sensitivity;
2040
2912
  if (col && col.branch === branch) {
2041
2913
  const delta = new Map();
2042
2914
  for (const v of col.values)
2043
2915
  delta.set(v.bus, v.value * step);
2044
- this.app.previewLmp = { caseId: c.id, target: { branch }, delta };
2916
+ this.app.previewPrices = {
2917
+ caseId: c.id,
2918
+ target: { branch },
2919
+ delta,
2920
+ units: previewUnits(col.units)
2921
+ };
2045
2922
  }
2046
2923
  let cached = this.ratingSlope;
2047
2924
  if (cached?.caseId !== c.id || cached.branch !== branch) {
@@ -2126,6 +3003,7 @@ export class Controller {
2126
3003
  }
2127
3004
  c.predictedObjective = this.predictedDeltaObj;
2128
3005
  c.ratings = this.previewRatings(c, branch, value);
3006
+ this.bumpRevision(c);
2129
3007
  // The slope was taken at the old committed point; the next drag re-derives it.
2130
3008
  this.ratingSlope = null;
2131
3009
  this.app.previewRatingMw = value;
@@ -2133,7 +3011,7 @@ export class Controller {
2133
3011
  this.runSolve(c, this.selectionTarget);
2134
3012
  };
2135
3013
  finishRatingInput = (value) => {
2136
- if (Math.abs(value - this.committedRating) < 0.25) {
3014
+ if (Math.abs(value - this.committedRating) < DEAD_ZONE_MW) {
2137
3015
  if (!this.activeSolvable?.solving) {
2138
3016
  this.app.previewActive = false;
2139
3017
  this.app.previewRatingMw = null;
@@ -2143,6 +3021,8 @@ export class Controller {
2143
3021
  this.commitRating(value);
2144
3022
  };
2145
3023
  restoreDefaultCases = () => {
3024
+ if (!this.loadDefaultCases)
3025
+ return;
2146
3026
  try {
2147
3027
  if (typeof localStorage !== 'undefined')
2148
3028
  localStorage.removeItem(HIDDEN_DEFAULT_CASES_KEY);