@tellegen/svelte 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +46 -0
  2. package/dist/Sparkline.svelte.d.ts +0 -1
  3. package/dist/TellegenMap.svelte +328 -200
  4. package/dist/TellegenMap.svelte.d.ts +0 -1
  5. package/dist/TellegenProvider.svelte +4 -0
  6. package/dist/TellegenProvider.svelte.d.ts +0 -1
  7. package/dist/TellegenShell.svelte +19 -13
  8. package/dist/TellegenShell.svelte.d.ts +0 -1
  9. package/dist/TellegenViewer.svelte.d.ts +0 -1
  10. package/dist/api.d.ts +8 -2
  11. package/dist/api.js +6 -2
  12. package/dist/colors.d.ts +7 -8
  13. package/dist/colors.js +13 -13
  14. package/dist/components/AppFooter.svelte.d.ts +0 -1
  15. package/dist/components/AppHeader.svelte +27 -13
  16. package/dist/components/AppHeader.svelte.d.ts +0 -1
  17. package/dist/components/BindingLines.svelte +3 -6
  18. package/dist/components/BindingLines.svelte.d.ts +0 -1
  19. package/dist/components/BusPicker.svelte +8 -2
  20. package/dist/components/BusPicker.svelte.d.ts +0 -1
  21. package/dist/components/ControlPanel.svelte +106 -343
  22. package/dist/components/ControlPanel.svelte.d.ts +0 -1
  23. package/dist/components/DemandSlider.svelte +26 -8
  24. package/dist/components/DemandSlider.svelte.d.ts +0 -1
  25. package/dist/components/DiagramCanvas.svelte +486 -0
  26. package/dist/components/DiagramCanvas.svelte.d.ts +13 -0
  27. package/dist/components/DisplayControls.svelte +4 -2
  28. package/dist/components/DisplayControls.svelte.d.ts +0 -1
  29. package/dist/components/DropZone.svelte.d.ts +0 -1
  30. package/dist/components/FormulationSelector.svelte +8 -3
  31. package/dist/components/FormulationSelector.svelte.d.ts +0 -1
  32. package/dist/components/LocalCaseDetails.svelte +57 -44
  33. package/dist/components/LocalCaseDetails.svelte.d.ts +0 -1
  34. package/dist/components/McResults.svelte +365 -0
  35. package/dist/components/McResults.svelte.d.ts +11 -0
  36. package/dist/components/ModelDetails.svelte +230 -0
  37. package/dist/components/ModelDetails.svelte.d.ts +7 -0
  38. package/dist/components/MulticonductorDetails.svelte +227 -78
  39. package/dist/components/MulticonductorDetails.svelte.d.ts +0 -1
  40. package/dist/components/NetworkStats.svelte +20 -18
  41. package/dist/components/NetworkStats.svelte.d.ts +0 -1
  42. package/dist/components/NotificationCenter.svelte +243 -0
  43. package/dist/components/NotificationCenter.svelte.d.ts +3 -0
  44. package/dist/components/PanelCard.svelte +319 -0
  45. package/dist/components/PanelCard.svelte.d.ts +9 -0
  46. package/dist/components/PanelFrame.svelte +46 -0
  47. package/dist/components/PanelFrame.svelte.d.ts +16 -0
  48. package/dist/components/PanelHost.svelte +212 -0
  49. package/dist/components/PanelHost.svelte.d.ts +3 -0
  50. package/dist/components/PlacementCue.svelte.d.ts +0 -1
  51. package/dist/components/PowerFlowDetails.svelte +99 -0
  52. package/dist/components/PowerFlowDetails.svelte.d.ts +3 -0
  53. package/dist/components/RatingSlider.svelte +5 -9
  54. package/dist/components/RatingSlider.svelte.d.ts +0 -1
  55. package/dist/components/RestoreDefaultsButton.svelte.d.ts +0 -1
  56. package/dist/components/SavedNetworkDetails.svelte +214 -0
  57. package/dist/components/SavedNetworkDetails.svelte.d.ts +3 -0
  58. package/dist/components/SensitivityReadout.svelte +24 -16
  59. package/dist/components/SensitivityReadout.svelte.d.ts +0 -1
  60. package/dist/components/SizeLegend.svelte.d.ts +0 -1
  61. package/dist/components/SolveCard.svelte +46 -49
  62. package/dist/components/SolveCard.svelte.d.ts +0 -1
  63. package/dist/components/TopMovers.svelte +8 -3
  64. package/dist/components/TopMovers.svelte.d.ts +2 -18
  65. package/dist/components.d.ts +2 -1
  66. package/dist/components.js +2 -0
  67. package/dist/context.svelte.d.ts +0 -1
  68. package/dist/controller.svelte.d.ts +74 -41
  69. package/dist/controller.svelte.js +1095 -284
  70. package/dist/diagram-labels.d.ts +9 -0
  71. package/dist/diagram-labels.js +40 -0
  72. package/dist/display.d.ts +3 -8
  73. package/dist/display.js +31 -27
  74. package/dist/drop-classify.d.ts +5 -42
  75. package/dist/drop-classify.js +5 -78
  76. package/dist/drop-limits.d.ts +27 -0
  77. package/dist/drop-limits.js +83 -0
  78. package/dist/format.d.ts +0 -1
  79. package/dist/format.js +5 -4
  80. package/dist/index.d.ts +11 -7
  81. package/dist/index.js +8 -3
  82. package/dist/map-bounds.d.ts +5 -0
  83. package/dist/map-bounds.js +41 -0
  84. package/dist/map.d.ts +1 -2
  85. package/dist/map.js +1 -1
  86. package/dist/multiconductor.d.ts +11 -3
  87. package/dist/multiconductor.js +107 -14
  88. package/dist/notices.svelte.d.ts +29 -0
  89. package/dist/notices.svelte.js +82 -0
  90. package/dist/panels.svelte.d.ts +65 -0
  91. package/dist/panels.svelte.js +215 -0
  92. package/dist/state.svelte.d.ts +90 -24
  93. package/dist/state.svelte.js +94 -20
  94. package/dist/synthetic-layout.d.ts +0 -1
  95. package/dist/synthetic-layout.js +17 -2
  96. package/dist/transformer-icon.d.ts +0 -1
  97. package/dist/types.d.ts +0 -1
  98. package/package.json +28 -12
  99. package/dist/Sparkline.svelte.d.ts.map +0 -1
  100. package/dist/TellegenMap.svelte.d.ts.map +0 -1
  101. package/dist/TellegenProvider.svelte.d.ts.map +0 -1
  102. package/dist/TellegenShell.svelte.d.ts.map +0 -1
  103. package/dist/TellegenViewer.svelte.d.ts.map +0 -1
  104. package/dist/api.d.ts.map +0 -1
  105. package/dist/colors.d.ts.map +0 -1
  106. package/dist/components/AppFooter.svelte.d.ts.map +0 -1
  107. package/dist/components/AppHeader.svelte.d.ts.map +0 -1
  108. package/dist/components/BindingLines.svelte.d.ts.map +0 -1
  109. package/dist/components/BusPicker.svelte.d.ts.map +0 -1
  110. package/dist/components/ControlPanel.svelte.d.ts.map +0 -1
  111. package/dist/components/DemandSlider.svelte.d.ts.map +0 -1
  112. package/dist/components/DisplayControls.svelte.d.ts.map +0 -1
  113. package/dist/components/DropZone.svelte.d.ts.map +0 -1
  114. package/dist/components/FormulationSelector.svelte.d.ts.map +0 -1
  115. package/dist/components/LocalCaseDetails.svelte.d.ts.map +0 -1
  116. package/dist/components/MulticonductorDetails.svelte.d.ts.map +0 -1
  117. package/dist/components/NetworkStats.svelte.d.ts.map +0 -1
  118. package/dist/components/PlacementCue.svelte.d.ts.map +0 -1
  119. package/dist/components/RatingSlider.svelte.d.ts.map +0 -1
  120. package/dist/components/RestoreDefaultsButton.svelte.d.ts.map +0 -1
  121. package/dist/components/SensitivityReadout.svelte.d.ts.map +0 -1
  122. package/dist/components/SizeLegend.svelte.d.ts.map +0 -1
  123. package/dist/components/SolveCard.svelte.d.ts.map +0 -1
  124. package/dist/components/TopMovers.svelte.d.ts.map +0 -1
  125. package/dist/components.d.ts.map +0 -1
  126. package/dist/context.svelte.d.ts.map +0 -1
  127. package/dist/controller.svelte.d.ts.map +0 -1
  128. package/dist/display.d.ts.map +0 -1
  129. package/dist/drop-classify.d.ts.map +0 -1
  130. package/dist/format.d.ts.map +0 -1
  131. package/dist/index.d.ts.map +0 -1
  132. package/dist/map.d.ts.map +0 -1
  133. package/dist/multiconductor.d.ts.map +0 -1
  134. package/dist/state.svelte.d.ts.map +0 -1
  135. package/dist/study-package.d.ts +0 -9
  136. package/dist/study-package.d.ts.map +0 -1
  137. package/dist/study-package.js +0 -17
  138. package/dist/synthetic-layout.d.ts.map +0 -1
  139. package/dist/transformer-icon.d.ts.map +0 -1
  140. package/dist/types.d.ts.map +0 -1
@@ -3,10 +3,10 @@ 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
- import { classifyJson, distExtensionFormat, isGeoFileName } from './drop-classify.js';
8
- import { buildGeographicView, placeMultiView } from './multiconductor.js';
9
- import { applyDisplayGeo, applyGeo, applyLayout, createStudy, exportStudy, extractGeo, FORMULATIONS, formatOf, ingestCase, ingestDistCase, isDisplayFile, isPermanentEngineFailure, loadPackage, parseDisplay, parseGeo } from '@tellegen/engine';
6
+ import { distExtensionFormat, isGeoFileName } from './drop-classify.js';
7
+ import { DropBatchGate, readDropFileBytes, validateDropBatch } from './drop-limits.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 } from '@tellegen/engine';
10
10
  import { errorText, extent, formulationLabel, rgbaCss } from './format.js';
11
11
  const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
12
12
  // Open on South Carolina by default: it has the most interesting price action.
@@ -21,6 +21,16 @@ const COMPUTE_OFF_NOTICE = 'solving in this browser did not complete, and server
21
21
  * starts anyway: a missing map or a cue that never appears must not stall the
22
22
  * selection. */
23
23
  const FOCUS_SETTLE_CAP_MS = 1200;
24
+ function previewUnits(sensitivityUnits) {
25
+ const numerator = sensitivityUnits.replace(/\/(?:MW|MVA)$/, '').trim();
26
+ const unwrapped = numerator.startsWith('(') && numerator.endsWith(')') ? numerator.slice(1, -1) : numerator;
27
+ return unwrapped.replaceAll('objective_unit', 'objective units');
28
+ }
29
+ /** A lowered analysis row is view-only only when a current producer says so;
30
+ * absence keeps payloads from older compatible engine/server versions editable. */
31
+ export function isDisplayOnlyElement(element) {
32
+ return element?.editable === false;
33
+ }
24
34
  function delayUntilFocusSettles(ms, signal) {
25
35
  if (ms <= 0 || signal.aborted)
26
36
  return Promise.resolve();
@@ -55,6 +65,7 @@ function downloadText(text, filename, mime) {
55
65
  export class Controller {
56
66
  app;
57
67
  api;
68
+ mcTransport;
58
69
  abort = null;
59
70
  // While set (epoch ms), the server sensitivity fallback is rate limited: skip
60
71
  // the request and show the rate-limit copy instead of burning the budget on a
@@ -64,11 +75,12 @@ export class Controller {
64
75
  // Latched once any server fallback answers 403: compute is disabled on this
65
76
  // deploy, so later fallbacks show the notice instead of firing doomed requests.
66
77
  serverComputeOff = false;
67
- // Build-once browser Study per case: the network is parsed and the model built
78
+ // Build-once browser Study per case: the retained PowerIO module is parsed and the model built
68
79
  // when the Study is created, so a drag re-solves (commit) and previews without
69
80
  // re-parsing. Kept in a WeakMap off the reactive/raw case payloads — the wasm
70
81
  // handle is neither serialized nor part of any $state.
71
82
  caseStudies = new WeakMap();
83
+ detachedCaseCaptures = new WeakMap();
72
84
  // Latch a permanent sensitivity-module failure per case so we don't retry
73
85
  // createStudy — and the same permanent error — on every drag. Transient
74
86
  // failures are not latched.
@@ -83,6 +95,9 @@ export class Controller {
83
95
  // In-flight initial case-list load, so a remount during the first fetch dedupes
84
96
  // instead of firing a second concurrent load().
85
97
  loading = null;
98
+ // File reads and worker messages retain whole buffers. Admit one validated
99
+ // batch at a time so repeated drops cannot multiply the aggregate byte cap.
100
+ dropBatchGate = new DropBatchGate();
86
101
  nearbyRangeAnchor = $state(null);
87
102
  // Default cases the user has closed; drives the restore affordance. Seeded in load().
88
103
  hiddenDefaults = $state(new Set());
@@ -104,6 +119,7 @@ export class Controller {
104
119
  constructor(app, options = {}) {
105
120
  this.app = app;
106
121
  this.api = options.api ?? createApiClient({ apiBase: options.apiBase });
122
+ this.mcTransport = options.mcTransport ?? browserWasmTransport;
107
123
  }
108
124
  // ===== helpers =====
109
125
  isBackendCase(c) {
@@ -131,6 +147,9 @@ export class Controller {
131
147
  isPerturbed(c) {
132
148
  return c?.perturbed ?? false;
133
149
  }
150
+ bumpRevision(c) {
151
+ c.revisionGeneration += 1;
152
+ }
134
153
  setNearbyRangeAnchor(c, bus, delta = this.caseDeltas(c)[bus] ?? 0) {
135
154
  this.nearbyRangeAnchor = { caseId: c.id, bus, delta };
136
155
  }
@@ -140,6 +159,7 @@ export class Controller {
140
159
  // the sensitivity seq the caller guards its async writes with.
141
160
  beginBusSelection(c, busId) {
142
161
  this.abort?.abort();
162
+ this.bumpRevision(c);
143
163
  const ac = new AbortController();
144
164
  this.abort = ac;
145
165
  const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
@@ -161,6 +181,7 @@ export class Controller {
161
181
  // but the selection lands on a branch and the demand preview fields are cleared.
162
182
  beginBranchSelection(c, branchId) {
163
183
  this.abort?.abort();
184
+ this.bumpRevision(c);
164
185
  const ac = new AbortController();
165
186
  this.abort = ac;
166
187
  const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
@@ -210,45 +231,50 @@ export class Controller {
210
231
  this.hiddenDefaults = new Set(hidden);
211
232
  }
212
233
  localNetwork(c) {
213
- if (!c.summary || !c.view)
234
+ const view = c.displayMode === 'diagram' ? c.diagram?.view : c.view;
235
+ if (!c.summary || !view)
214
236
  return null;
215
237
  return {
216
238
  id: c.id,
217
239
  name: c.label,
218
240
  base_mva: c.summary.base_mva,
219
241
  synthetic_coords: c.coordsKind !== 'file' && c.coordsKind !== 'geofile',
220
- buses: c.view.buses,
221
- branches: c.view.branches
242
+ ...(c.network?.model_details ? { model_details: c.network.model_details } : {}),
243
+ coordinate_space: view.coordinate_space,
244
+ buses: view.buses,
245
+ branches: view.branches
222
246
  };
223
247
  }
224
248
  maybeStartLocalSolve(id) {
225
249
  const c = this.app.localCases.find((lc) => lc.id === id);
226
- if (!c?.networkJson || !c.view || !c.summary)
250
+ if (!c?.studyInputJson || (!c.view && !c.diagram) || !c.summary)
227
251
  return;
228
252
  c.network = this.localNetwork(c) ?? c.network ?? null;
229
- if (c.networkJson && c.network && !c.solution)
253
+ if (c.network && !c.solution)
230
254
  this.runSolve(c, null);
231
255
  }
232
- // The case's Study, building it once for `(networkJson, formulation)` and rebuilding
256
+ // The case's Study, building it once for `(studyInputJson, formulation)` and rebuilding
233
257
  // (after free) if either changed — so picking a new formulation re-parses and re-solves
234
258
  // under that formulation. Returns null when the sens module can't load; the caller then
235
259
  // falls back to the server, surfacing solveFallbackReason.
236
- async getStudy(c, networkJson) {
260
+ async getStudy(c, studyInputJson) {
237
261
  const latched = this.studyUnavailable.get(c);
238
262
  if (latched) {
239
263
  c.solveFallbackReason ??= latched;
240
264
  return null;
241
265
  }
242
266
  const cached = this.caseStudies.get(c);
243
- if (cached && cached.networkJson === networkJson && cached.formulation === c.formulation)
267
+ if (cached && cached.studyInputJson === studyInputJson && cached.formulation === c.formulation)
244
268
  return cached.study;
245
- // Coalesce concurrent builds for the same (case, networkJson, formulation): a
269
+ // Coalesce concurrent builds for the same (case, module, formulation): a
246
270
  // bus-select sensitivity fetch and a solve can both reach here before either
247
271
  // createStudy resolves. Without this each builds its own wasm Study and the
248
272
  // later set() orphans the earlier one (a leaked handle), and the cache can end
249
273
  // up holding an instance the live solve never committed on.
250
274
  const inflight = this.studyBuilds.get(c);
251
- if (inflight && inflight.networkJson === networkJson && inflight.formulation === c.formulation)
275
+ if (inflight &&
276
+ inflight.studyInputJson === studyInputJson &&
277
+ inflight.formulation === c.formulation)
252
278
  return inflight.promise;
253
279
  // Different params, or no build pending: drop any stale Study before rebuilding.
254
280
  if (cached) {
@@ -261,7 +287,7 @@ export class Controller {
261
287
  const token = {};
262
288
  const build = (async () => {
263
289
  try {
264
- const study = await createStudy(networkJson, formulation);
290
+ const study = await createStudy(studyInputJson, formulation);
265
291
  const baseSolution = await study.currentSolution();
266
292
  // The case may have switched formulation or been disposed (removed, or its
267
293
  // list reloaded) while building; don't cache a now-unwanted Study, and free
@@ -270,7 +296,12 @@ export class Controller {
270
296
  study.free();
271
297
  return null;
272
298
  }
273
- this.caseStudies.set(c, { study, networkJson, formulation, baseSolution });
299
+ this.caseStudies.set(c, {
300
+ study,
301
+ studyInputJson,
302
+ formulation,
303
+ baseSolution
304
+ });
274
305
  return study;
275
306
  }
276
307
  catch (e) {
@@ -288,20 +319,23 @@ export class Controller {
288
319
  this.studyBuilds.delete(c);
289
320
  }
290
321
  })();
291
- this.studyBuilds.set(c, { networkJson, formulation, token, promise: build });
322
+ this.studyBuilds.set(c, {
323
+ studyInputJson,
324
+ formulation,
325
+ token,
326
+ promise: build
327
+ });
292
328
  return build;
293
329
  }
294
- // The ∂LMP/∂parameter column at `target` for the case's active formulation, solved in
330
+ // The nodal-value/parameter column at `target` for the case's active formulation, solved in
295
331
  // the browser. Every column, bus or branch, any formulation, comes from the Study's
296
332
  // exact re-solve at the case's operating point. The column is null (with the reason on
297
333
  // `solveFallbackReason`) when the Study can't be built; the selection paths then
298
334
  // reconcile via the server where a DC endpoint exists. Throws only on a hard browser
299
335
  // error.
300
- async browserSensitivity(c, networkJson, target) {
301
- const study = await this.getStudy(c, networkJson);
302
- return study
303
- ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target)
304
- : null;
336
+ async browserSensitivity(c, studyInputJson, target) {
337
+ const study = await this.getStudy(c, studyInputJson);
338
+ return study ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target) : null;
305
339
  }
306
340
  // Release a case's Study (if any) when the case is removed.
307
341
  disposeStudy(c) {
@@ -343,21 +377,21 @@ export class Controller {
343
377
  this.app.previewDeltaMw = null;
344
378
  this.app.previewRatingMw = null;
345
379
  // The committed solution supersedes the live engine preview.
346
- this.app.previewLmp = null;
380
+ this.app.previewPrices = null;
347
381
  this.previewObjective = null;
348
382
  }
349
383
  }
350
- // Fetch and cache the raw powerio Network JSON for the browser solver.
351
- // Returns null when it can't be loaded, so callers fall back to the server.
352
- async ensureNetworkJson(c) {
384
+ // Fetch and cache the retained PowerIO module used by every browser Study.
385
+ // Returns null when it cannot be loaded, so callers can report or fall back.
386
+ async ensureStudyInputJson(c) {
387
+ if (c.studyInputJson)
388
+ return c.studyInputJson;
353
389
  if (!this.isBackendCase(c))
354
- return c.networkJson ?? null;
355
- if (c.networkJson)
356
- return c.networkJson;
390
+ return null;
357
391
  try {
358
- const json = await this.api.getCaseNetworkJson(c.id);
359
- c.networkJson = json;
360
- return json;
392
+ const moduleJson = await this.api.getCaseModuleJson(c.id);
393
+ c.studyInputJson = moduleJson;
394
+ return moduleJson;
361
395
  }
362
396
  catch (e) {
363
397
  c.solveFallbackReason = `case fetch failed: ${errorText(e)}`;
@@ -376,7 +410,7 @@ export class Controller {
376
410
  return deltas;
377
411
  }
378
412
  demandBounds(mode, bus, center) {
379
- if (!bus)
413
+ if (!bus || isDisplayOnlyElement(bus))
380
414
  return { min: 0, max: 0, span: 0 };
381
415
  // Floor, not ceil: -ceil(demand) can push base + delta below zero for
382
416
  // non-integer demand, which the server rejects (400) and which is
@@ -384,7 +418,11 @@ export class Controller {
384
418
  const physicalMin = -Math.floor(bus.demand_mw);
385
419
  const physicalMax = Math.max(Math.ceil(bus.demand_mw), 50);
386
420
  if (mode === 'full')
387
- return { min: physicalMin, max: physicalMax, span: physicalMax - physicalMin };
421
+ return {
422
+ min: physicalMin,
423
+ max: physicalMax,
424
+ span: physicalMax - physicalMin
425
+ };
388
426
  const span = Math.max(5, Math.min(25, 0.1 * Math.max(bus.demand_mw, 50)));
389
427
  return {
390
428
  min: Math.max(physicalMin, center - span),
@@ -403,8 +441,12 @@ export class Controller {
403
441
  buses: c.network.buses.length,
404
442
  branches: c.network.branches.length,
405
443
  objective: c.solution?.objective ?? null,
406
- deltaObjective: c.solution && c.baseSolution ? c.solution.objective - c.baseSolution.objective : null,
407
- binding: c.solution ? c.solution.flows.filter((f) => f.loading >= 0.999).length : null
444
+ deltaObjective: c.solution?.objective != null && c.baseSolution?.objective != null
445
+ ? c.solution.objective - c.baseSolution.objective
446
+ : null,
447
+ binding: c.solution && c.formulation !== 'acpf'
448
+ ? c.solution.flows.filter((f) => f.loading >= 0.999).length
449
+ : null
408
450
  };
409
451
  });
410
452
  displayOptions = $derived(displayMetaFor(this.activeSolvable));
@@ -416,8 +458,11 @@ export class Controller {
416
458
  return null;
417
459
  // Drive the values off the resolved mode, not app.displayMode, so the
418
460
  // displayOptions[0] fallback holds while displayMode is stale relative to the
419
- // formulation (e.g. leaving SOCWR before +page resets it to 'lmp').
420
- return { ...activeMeta, values: displaySeriesFor(this.activeSolvable, activeMeta.mode) };
461
+ // formulation (e.g. leaving SOCWR before +page resets it to price).
462
+ return {
463
+ ...activeMeta,
464
+ values: displaySeriesFor(this.activeSolvable, activeMeta.mode)
465
+ };
421
466
  });
422
467
  displayStats = $derived.by(() => {
423
468
  if (!this.activeDisplay)
@@ -427,7 +472,7 @@ export class Controller {
427
472
  return null;
428
473
  const domain = scalarDomain(this.activeDisplay.mode, values);
429
474
  const { min, max } = extent(values);
430
- const flatThreshold = this.activeDisplay.mode === 'lmp' ? 1 : 1e-5;
475
+ const flatThreshold = this.activeDisplay.mode === 'price' ? 1 : 1e-5;
431
476
  return {
432
477
  lo: { value: domain.lo, clamped: min < domain.lo - flatThreshold / 20 },
433
478
  hi: { value: domain.hi, clamped: max > domain.hi + flatThreshold / 20 },
@@ -446,8 +491,8 @@ export class Controller {
446
491
  return null;
447
492
  return c.network.branches.find((b) => b.id === this.app.selectedBranch) ?? null;
448
493
  });
449
- // The sensitivity target of the current selection: the selected bus (∂LMP/∂d),
450
- // else the selected branch (∂LMP/∂rating), else null. Selections are mutually
494
+ // The sensitivity target of the current selection: the selected bus (price/demand),
495
+ // else the selected branch (price/rating), else null. Selections are mutually
451
496
  // exclusive, so bus-first ordering is only a tiebreak for impossible states.
452
497
  selectionTarget = $derived.by(() => this.app.selectedBus !== null
453
498
  ? { bus: this.app.selectedBus }
@@ -471,6 +516,7 @@ export class Controller {
471
516
  sliderBounds = $derived.by(() => this.demandBounds(this.app.demandRangeMode, this.selectedBusData, this.nearbyRangeCenter));
472
517
  sliderMin = $derived(this.sliderBounds.min);
473
518
  sliderMax = $derived(this.sliderBounds.max);
519
+ sliderDisabled = $derived(!this.selectedBusData || isDisplayOnlyElement(this.selectedBusData));
474
520
  committedRating = $derived.by(() => this.activeSolvable && this.app.selectedBranch !== null
475
521
  ? (this.caseRatings(this.activeSolvable)[this.app.selectedBranch] ?? 0)
476
522
  : 0);
@@ -482,10 +528,14 @@ export class Controller {
482
528
  // to perturb.
483
529
  ratingBounds = $derived.by(() => {
484
530
  const b = this.selectedBranchData;
485
- if (!b || b.rate_mw <= 0)
531
+ if (!b || isDisplayOnlyElement(b) || b.rate_mw <= 0)
486
532
  return { min: 0, max: 0, disabled: true };
487
533
  const span = Math.min(50, Math.max(5, 0.2 * b.rate_mw));
488
- return { min: Math.max(-(b.rate_mw - 1), -span), max: span, disabled: false };
534
+ return {
535
+ min: Math.max(-(b.rate_mw - 1), -span),
536
+ max: span,
537
+ disabled: false
538
+ };
489
539
  });
490
540
  selectedSensitivity = $derived.by(() => {
491
541
  const c = this.activeSolvable;
@@ -516,9 +566,9 @@ export class Controller {
516
566
  const c = this.activeSolvable;
517
567
  if (!c?.solution || this.app.selectedBus === null)
518
568
  return null;
519
- return c.solution.lmp.find((e) => e.bus === this.app.selectedBus)?.usd_per_mwh ?? null;
569
+ return c.solution.prices.find((e) => e.bus === this.app.selectedBus)?.value ?? null;
520
570
  });
521
- // Self-sensitivity ∂LMP_bb/∂d at the selected bus: the curvature term the
571
+ // Self-sensitivity of the selected bus value to demand: the curvature term the
522
572
  // first-order fallback uses for a second-order objective estimate. Zero when no
523
573
  // sensitivity column is loaded for the bus.
524
574
  selfSens = $derived.by(() => {
@@ -530,13 +580,13 @@ export class Controller {
530
580
  // engine preview (Study.preview at the committed point, plus the committed part);
531
581
  // fall back to a second-order gradient estimate when no Study preview is available
532
582
  // (server-only cases, or a browser that can't load the sensitivity module): the
533
- // exact committed part plus lmp·step + S_bb·step²/2 along the gradient. The
534
- // gradient fallback is demand-only — a rating step has no LMP-at-bus analogue —
583
+ // exact committed part plus price·step + S_bb·step²/2 along the gradient. The
584
+ // gradient fallback is demand-only — a rating step has no corresponding bus term —
535
585
  // so a branch selection without an engine slope shows no prediction.
536
586
  predictedDeltaObj = $derived.by(() => {
537
587
  const c = this.activeSolvable;
538
588
  const target = this.selectionTarget;
539
- if (!c?.solution || !c.baseSolution || target === null)
589
+ if (c?.solution?.objective == null || c.baseSolution?.objective == null || target === null)
540
590
  return null;
541
591
  const committedPart = c.solution.objective - c.baseSolution.objective;
542
592
  if (this.previewObjective &&
@@ -551,7 +601,10 @@ export class Controller {
551
601
  });
552
602
  gradientScore = $derived.by(() => {
553
603
  const c = this.activeSolvable;
554
- if (!c?.solution || !c.baseSolution || c.predictedObjective == null || c.solving)
604
+ if (c?.solution?.objective == null ||
605
+ c.baseSolution?.objective == null ||
606
+ c.predictedObjective == null ||
607
+ c.solving)
555
608
  return null;
556
609
  const exact = c.solution.objective - c.baseSolution.objective;
557
610
  return { pred: c.predictedObjective, exact };
@@ -605,7 +658,9 @@ export class Controller {
605
658
  this.app.activeLocalId = null;
606
659
  this.app.placingLocalId = null;
607
660
  this.app.activeCaseId =
608
- this.app.cases.find((c) => c.id === DEFAULT_CASE_ID)?.id ?? this.app.cases[0]?.id ?? null;
661
+ this.app.cases.find((c) => c.id === DEFAULT_CASE_ID && !c.unavailableReason)?.id ??
662
+ this.app.cases.find((c) => !c.unavailableReason)?.id ??
663
+ null;
609
664
  const active = this.app.active;
610
665
  if (active)
611
666
  await this.loadBackendCase(active, true);
@@ -625,6 +680,10 @@ export class Controller {
625
680
  return this.loading;
626
681
  };
627
682
  loadBackendCase = async (c, frame = false) => {
683
+ if (c.unavailableReason) {
684
+ this.fail(`${c.name}: ${c.unavailableReason}`);
685
+ return;
686
+ }
628
687
  if (c.network && c.solution && c.baseSolution) {
629
688
  if (frame && this.app.activeCaseId === c.id)
630
689
  this.app.requestFrame(c.id);
@@ -636,7 +695,7 @@ export class Controller {
636
695
  const [network, dcBaseSolution] = await Promise.all([
637
696
  c.network ? Promise.resolve(c.network) : this.api.getNetwork(c.id),
638
697
  // The server caches only the DC OPF base solution. Other formulations
639
- // must hydrate from the browser Study so their cost, prices, flows, and
698
+ // must hydrate from the browser Study so their objective, nodal values, flows, and
640
699
  // voltage fields all come from the selected formulation.
641
700
  requestedFormulation === 'dcopf' ? this.api.getSolution(c.id) : Promise.resolve(null)
642
701
  ]);
@@ -671,10 +730,19 @@ export class Controller {
671
730
  this.app.placingMultiId = null;
672
731
  }
673
732
  activateCase = async (id) => {
733
+ const candidate = this.app.byId(id);
734
+ if (!candidate)
735
+ return;
736
+ if (candidate.unavailableReason) {
737
+ this.fail(`${candidate.name}: ${candidate.unavailableReason}`);
738
+ return;
739
+ }
740
+ const inspectedSavedState = this.app.studyView !== null;
741
+ this.app.studyView = null;
674
742
  this.app.activeLocalId = null;
675
743
  this.app.placingLocalId = null;
676
744
  this.leaveMulti();
677
- if (this.app.activeCaseId !== id) {
745
+ if (inspectedSavedState || this.app.activeCaseId !== id) {
678
746
  this.clearSelection();
679
747
  this.app.activeCaseId = id;
680
748
  }
@@ -710,6 +778,7 @@ export class Controller {
710
778
  await this.hydrateFallback(this.app.removeCase(c.id));
711
779
  };
712
780
  activateLocal = (c) => {
781
+ this.app.studyView = null;
713
782
  this.clearSelection();
714
783
  // Mirror activateCase's reset: a local and a backend case are mutually
715
784
  // exclusive, so drop the backend selection. Otherwise app.active (derived
@@ -718,8 +787,10 @@ export class Controller {
718
787
  this.app.activeCaseId = null;
719
788
  this.leaveMulti();
720
789
  this.app.activeLocalId = c.id;
721
- this.app.placingLocalId = c.coordsKind === 'synthetic_pending' ? c.id : null;
722
- if (c.view || c.substations)
790
+ if (c.formulation === 'acpf' && this.app.displayMode === 'price')
791
+ this.app.displayMode = 'voltage';
792
+ this.app.placingLocalId = c.coordsKind === 'synthetic_pending' && !c.diagram ? c.id : null;
793
+ if (c.view || c.diagram || c.substations)
723
794
  this.app.requestFrame(c.id);
724
795
  this.maybeStartLocalSolve(c.id);
725
796
  };
@@ -735,11 +806,16 @@ export class Controller {
735
806
  await this.hydrateFallback(this.app.removeLocal(c.id));
736
807
  };
737
808
  addAndActivateLocal = (c) => {
809
+ this.app.studyView = null;
738
810
  this.clearSelection();
739
811
  this.app.activeCaseId = null;
740
812
  this.leaveMulti();
741
813
  this.app.addLocal(c);
742
- if (c.view || c.substations)
814
+ if (c.formulation === 'acpf' && this.app.displayMode === 'price')
815
+ this.app.displayMode = 'voltage';
816
+ if (c.diagram)
817
+ this.app.placingLocalId = null;
818
+ if (c.view || c.diagram || c.substations)
743
819
  this.app.requestFrame(c.id);
744
820
  this.maybeStartLocalSolve(c.id);
745
821
  };
@@ -761,18 +837,18 @@ export class Controller {
761
837
  };
762
838
  /** Write the case's on-screen layout into its network payload
763
839
  * (`Bus.location` with `synthetic` provenance — the engine also accepts
764
- * `manual`, which round trips through restored packages), so saves,
840
+ * `manual`, which round trips through saved PowerIO modules), so saves,
765
841
  * exports, and the `.geo.json` download carry it. Best effort: on an
766
842
  * engine failure the view still renders, only the layout's persistence is
767
843
  * lost. */
768
844
  stampLayout = async (c) => {
769
- if (!c.networkJson || !c.view)
845
+ if (!c.studyInputJson || !c.view)
770
846
  return;
771
847
  try {
772
848
  const coords = Object.fromEntries(c.view.buses.map((b) => [b.id, [b.lon, b.lat]]));
773
- const stamped = await applyLayout(c.networkJson, coords, 'synthetic');
774
- c.networkJson = stamped.network_json;
775
- await this.syncCachedStudy(c, stamped.network_json, [stamped.layer]);
849
+ const stamped = await applyLayout(c.studyInputJson, coords, 'synthetic');
850
+ c.studyInputJson = stamped.module_json;
851
+ await this.syncStudyGeo(c, [stamped.layer]);
776
852
  }
777
853
  catch {
778
854
  // The on-screen view is unaffected; only persistence was lost.
@@ -782,10 +858,12 @@ export class Controller {
782
858
  * GeoJSON with provenance stamped (`synthetic`/`manual` for layouts), the
783
859
  * document powerio and tellegen both read back. */
784
860
  downloadGeoLayer = async (c) => {
785
- if (!c.networkJson)
861
+ if (!c.studyInputJson)
786
862
  return;
787
863
  try {
788
- downloadText(await extractGeo(c.networkJson), `${this.caseFileStem(c)}.geo.json`, 'application/geo+json');
864
+ downloadText(c.displayMode === 'diagram' && c.diagram
865
+ ? c.diagram.layer
866
+ : await extractGeo(c.studyInputJson), `${this.caseFileStem(c)}.geo.json`, 'application/geo+json');
789
867
  this.app.error = null;
790
868
  }
791
869
  catch (e) {
@@ -801,23 +879,46 @@ export class Controller {
801
879
  * wins on shared elements). The engine owns matching and the network becomes
802
880
  * the single owner of the coordinates: matched points land in
803
881
  * `Bus.location`, routes in `Branch.route`, and the refreshed
804
- * `network_json` replaces the case payload — so a saved package or export
882
+ * `module_json` replaces the case IR — so a saved module or export
805
883
  * carries exactly what is on screen. A live Study is synced the same way.
806
884
  * Throws when a layer matches nothing. */
807
885
  applyGeoLayers = async (c, layers) => {
808
- if (!c.networkJson || layers.length === 0)
886
+ if (!c.studyInputJson || layers.length === 0)
809
887
  return;
810
888
  let payload = null;
811
- let networkJson = c.networkJson;
889
+ const geographic = [];
890
+ for (const entry of layers) {
891
+ const document = JSON.parse(entry.layer);
892
+ const space = document.powerio_geo?.space;
893
+ if (space === 'diagram' || space?.kind === 'diagram') {
894
+ const payload = await applyGeo(c.studyInputJson, entry.layer);
895
+ if (payload.view) {
896
+ c.diagram = {
897
+ view: payload.view,
898
+ layer: entry.layer,
899
+ name: entry.name,
900
+ warnings: payload.report.notes
901
+ };
902
+ if (!c.view)
903
+ c.displayMode = 'diagram';
904
+ }
905
+ }
906
+ else
907
+ geographic.push(entry);
908
+ }
909
+ layers = geographic;
910
+ if (!layers.length)
911
+ return;
912
+ let moduleJson = c.studyInputJson;
812
913
  const warnings = [];
813
914
  let routes = 0;
814
915
  let unmatched = 0;
815
916
  for (const l of layers) {
816
- payload = await applyGeo(networkJson, l.layer);
817
- networkJson = payload.network_json;
917
+ payload = await applyGeo(moduleJson, l.layer);
918
+ moduleJson = payload.module_json;
818
919
  routes += payload.report.matched_branches;
819
920
  unmatched += payload.report.unmatched_features;
820
- warnings.push(...l.warnings.map((w) => `${l.name}: ${w}`), ...payload.report.notes.map((n) => `${l.name}: ${n}`));
921
+ warnings.push(...l.diagnostics.map((d) => `${l.name}: ${formatPowerIoDiagnostic(d)}`), ...payload.report.notes.map((n) => `${l.name}: ${n}`));
821
922
  }
822
923
  if (!payload)
823
924
  return;
@@ -827,56 +928,258 @@ export class Controller {
827
928
  ...(unmatched > 0 ? [`${unmatched} feature(s) matched no case element`] : []),
828
929
  ...warnings
829
930
  ]);
830
- await this.syncCachedStudy(c, networkJson, layers.map((l) => l.layer));
931
+ await this.syncStudyGeo(c, layers.map((l) => l.layer));
831
932
  };
832
933
  /** Adopt an engine geo apply onto the case: the refreshed payload becomes
833
934
  * the case's network and view, provenance flips to the geographic source,
834
935
  * and the warnings panel gets the placement summary. */
835
936
  adoptGeoPayload(c, payload, sourceLabel, placedFrom, extraWarnings) {
836
- c.networkJson = payload.network_json;
937
+ c.studyInputJson = payload.module_json;
837
938
  c.view = payload.view;
838
939
  c.coordsKind = 'geofile';
940
+ c.displayMode = payload.view?.coordinate_space === 'diagram' ? 'diagram' : 'geographic';
839
941
  c.syntheticCenter = undefined;
840
942
  c.geoSource = sourceLabel;
943
+ const placedCanonicalBuses = payload.view?.buses.filter((bus) => bus.editable !== false).length ?? 0;
841
944
  c.geoWarnings = [
842
- `${payload.view?.buses.length ?? 0} of ${payload.n_bus} buses placed from ${placedFrom}`,
945
+ `${placedCanonicalBuses} of ${payload.n_bus} buses placed from ${placedFrom}`,
843
946
  ...extraWarnings,
844
947
  ...payload.warnings
845
948
  ];
846
949
  }
847
- /** Keep a live Study's base network in step with a geo change (locations
848
- * are metadata the model never reads, so nothing re-solves), and re-key its
849
- * cache entry so the next solve reuses it instead of rebuilding. */
850
- syncCachedStudy = async (c, networkJson, layers) => {
950
+ /** Update an existing numerical Study after the base input receives geographic data. */
951
+ syncStudyGeo = async (c, layers) => {
851
952
  const cached = this.caseStudies.get(c);
852
953
  if (!cached)
853
954
  return;
854
- cached.networkJson = networkJson;
855
- for (const layer of layers)
856
- await cached.study.applyGeoLayer(layer);
955
+ if (cached.formulation !== c.formulation) {
956
+ this.disposeStudy(c);
957
+ return;
958
+ }
959
+ try {
960
+ for (const layer of layers)
961
+ await cached.study.applyGeoLayer(layer);
962
+ cached.studyInputJson = c.studyInputJson;
963
+ }
964
+ catch (error) {
965
+ this.disposeStudy(c);
966
+ throw error;
967
+ }
857
968
  };
858
- applyGeoLayersToExisting = async (layers) => {
859
- const target = (this.app.activeLocal?.networkJson ? this.app.activeLocal : null) ??
860
- this.app.localCases.find((c) => c.coordsKind === 'synthetic_pending' && c.networkJson) ??
861
- [...this.app.localCases].reverse().find((c) => c.networkJson);
862
- if (!target?.networkJson) {
863
- this.app.error =
864
- 'drop a case file with the geographic file, or select a parsed local case first';
969
+ caseEditKey(c) {
970
+ return JSON.stringify([c.formulation, this.caseDeltas(c), this.caseRatings(c)]);
971
+ }
972
+ async localSidecarCopy(source) {
973
+ if (source.solving)
974
+ throw new Error('Wait for the current solve before attaching coordinates');
975
+ const revision = source.revisionGeneration;
976
+ const captured = await this.captureSavedCase(source);
977
+ const base = await this.ensureStudyInputJson(source);
978
+ if (!base)
979
+ throw new Error('The selected case input is unavailable');
980
+ const parsed = await ingestJsonDrop(new TextEncoder().encode(base));
981
+ if (!parsed.payload || !('topology' in parsed.payload))
982
+ throw new Error('The selected case has no balanced network data');
983
+ const copy = this.localFromBalancedPayload(`${source.name}.pio.json`, {
984
+ ...parsed.payload,
985
+ name: `${source.name} (local copy)`
986
+ });
987
+ copy.formulation = source.formulation;
988
+ copy.deltas = { ...source.deltas };
989
+ copy.ratings = { ...source.ratings };
990
+ copy.solution = source.solution;
991
+ copy.baseSolution = source.baseSolution;
992
+ copy.iterations = source.iterations;
993
+ copy.solveMs = source.solveMs;
994
+ copy.solveBackend = source.solveBackend;
995
+ copy.network = source.network;
996
+ if (source.network) {
997
+ copy.view = {
998
+ buses: source.network.buses,
999
+ branches: source.network.branches,
1000
+ coordinate_space: source.network.coordinate_space
1001
+ };
1002
+ copy.coordsKind = source.network.synthetic_coords ? 'synthetic' : 'file';
1003
+ if (!source.network.synthetic_coords && source.network.coordinate_space !== 'diagram') {
1004
+ const layer = JSON.stringify({
1005
+ type: 'FeatureCollection',
1006
+ powerio_geo: { space: 'geographic' },
1007
+ features: [
1008
+ ...source.network.buses.map((bus) => ({
1009
+ type: 'Feature',
1010
+ properties: { target: 'bus', id: String(bus.id) },
1011
+ geometry: { type: 'Point', coordinates: [bus.lon, bus.lat] }
1012
+ })),
1013
+ ...source.network.branches
1014
+ .filter((branch) => branch.path.length > 1)
1015
+ .map((branch) => ({
1016
+ type: 'Feature',
1017
+ properties: {
1018
+ target: 'branch',
1019
+ branch_id: String(branch.id),
1020
+ from: String(branch.from),
1021
+ to: String(branch.to)
1022
+ },
1023
+ geometry: { type: 'LineString', coordinates: branch.path }
1024
+ }))
1025
+ ]
1026
+ });
1027
+ const placed = await applyGeo(copy.studyInputJson, layer);
1028
+ copy.studyInputJson = placed.module_json;
1029
+ copy.view = placed.view;
1030
+ }
1031
+ }
1032
+ if (source.revisionGeneration !== revision || source.solving)
1033
+ throw new Error('The selected case changed while loading. Attach the coordinates again');
1034
+ this.detachedCaseCaptures.set(copy, {
1035
+ baseInput: copy.studyInputJson,
1036
+ edits: this.caseEditKey(copy),
1037
+ solution: copy.solution,
1038
+ captured
1039
+ });
1040
+ return copy;
1041
+ }
1042
+ stageLocalSidecars(source) {
1043
+ const copy = new LocalCase({
1044
+ id: source.id,
1045
+ label: source.label,
1046
+ fileName: source.fileName,
1047
+ summary: source.summary,
1048
+ studyInputJson: source.studyInputJson,
1049
+ topology: source.topology,
1050
+ coordsKind: source.coordsKind,
1051
+ view: source.view,
1052
+ substations: source.substations
1053
+ });
1054
+ for (const key of [
1055
+ 'diagram',
1056
+ 'displayMode',
1057
+ 'geoSource',
1058
+ 'geoWarnings',
1059
+ 'syntheticCenter',
1060
+ 'formulation',
1061
+ 'deltas',
1062
+ 'ratings',
1063
+ 'solution',
1064
+ 'baseSolution',
1065
+ 'iterations',
1066
+ 'solveMs',
1067
+ 'solveBackend'
1068
+ ])
1069
+ Object.assign(copy, { [key]: source[key] });
1070
+ const captured = this.detachedCaseCaptures.get(source);
1071
+ if (captured)
1072
+ this.detachedCaseCaptures.set(copy, { ...captured });
1073
+ return copy;
1074
+ }
1075
+ async attachSidecarsToSelected(layers, displays = [], expected) {
1076
+ if (this.app.studyView) {
1077
+ this.app.error = 'Return to the live case before attaching coordinates';
865
1078
  return;
866
1079
  }
1080
+ const source = this.app.activeMulti ?? this.app.activeLocal ?? this.app.active;
1081
+ if (expected !== undefined && source !== expected) {
1082
+ this.app.error = 'The selected case changed. Attach the coordinates again';
1083
+ return;
1084
+ }
1085
+ if (!source) {
1086
+ this.app.error = 'Select the matching case before attaching coordinates';
1087
+ return;
1088
+ }
1089
+ if (source instanceof MulticonductorCase) {
1090
+ try {
1091
+ await this.applyMultiGeoLayers(source, layers);
1092
+ }
1093
+ catch (error) {
1094
+ this.app.error = errorText(error);
1095
+ }
1096
+ if (displays.length)
1097
+ this.app.error = 'Attach a GeoJSON or CSV drawing to this multiconductor case';
1098
+ return;
1099
+ }
1100
+ const selectedId = source.id;
1101
+ const revision = source.revisionGeneration;
1102
+ let target = null;
867
1103
  try {
868
- await this.applyGeoLayers(target, layers);
1104
+ target =
1105
+ source instanceof CaseState
1106
+ ? await this.localSidecarCopy(source)
1107
+ : this.stageLocalSidecars(source);
1108
+ if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId || this.app.studyView)
1109
+ throw new Error('The selected case changed. Attach the coordinates again');
1110
+ const placement = await this.prepareDroppedLocal(target, layers, displays);
1111
+ const applied = placement.geoLayersConsumed || displays.some((display) => display.consumed);
1112
+ if (!applied)
1113
+ throw new Error(placement.error ?? 'No coordinates matched the selected case');
1114
+ if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId ||
1115
+ this.app.studyView ||
1116
+ source.revisionGeneration !== revision)
1117
+ throw new Error('The selected case changed. Attach the coordinates again');
1118
+ const saved = this.detachedCaseCaptures.get(target);
1119
+ if (saved)
1120
+ saved.baseInput = target.studyInputJson;
1121
+ if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId ||
1122
+ this.app.studyView ||
1123
+ source.revisionGeneration !== revision ||
1124
+ source.solving)
1125
+ throw new Error('The selected case changed. Attach the coordinates again');
1126
+ if (source instanceof LocalCase) {
1127
+ const cached = this.caseStudies.get(source);
1128
+ if (cached && target.studyInputJson && target.view) {
1129
+ const layer = await extractGeo(target.studyInputJson);
1130
+ try {
1131
+ await cached.study.applyGeoLayer(layer);
1132
+ }
1133
+ catch (error) {
1134
+ this.disposeStudy(source);
1135
+ throw error;
1136
+ }
1137
+ if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId ||
1138
+ this.app.studyView ||
1139
+ source.revisionGeneration !== revision ||
1140
+ source.solving) {
1141
+ this.disposeStudy(source);
1142
+ throw new Error('The selected case changed. Attach the coordinates again');
1143
+ }
1144
+ cached.studyInputJson = target.studyInputJson;
1145
+ }
1146
+ for (const key of [
1147
+ 'studyInputJson',
1148
+ 'view',
1149
+ 'diagram',
1150
+ 'displayMode',
1151
+ 'coordsKind',
1152
+ 'geoSource',
1153
+ 'geoWarnings',
1154
+ 'syntheticCenter'
1155
+ ])
1156
+ Object.assign(source, { [key]: target[key] });
1157
+ if (saved)
1158
+ this.detachedCaseCaptures.set(source, saved);
1159
+ target = source;
1160
+ }
1161
+ this.app.studyView = null;
1162
+ this.clearSelection();
869
1163
  this.app.activeCaseId = null;
870
1164
  this.leaveMulti();
1165
+ if (target !== source)
1166
+ this.app.addLocal(target);
871
1167
  this.app.activeLocalId = target.id;
872
1168
  this.app.placingLocalId = null;
1169
+ target.network = this.localNetwork(target);
1170
+ this.bumpRevision(target);
873
1171
  this.app.requestFrame(target.id);
874
1172
  this.maybeStartLocalSolve(target.id);
875
- this.app.error = null;
1173
+ this.app.error = placement.error;
876
1174
  }
877
- catch (e) {
878
- this.app.error = `${layers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`;
1175
+ catch (error) {
1176
+ if (target && target !== source)
1177
+ this.disposeStudy(target);
1178
+ this.app.error = errorText(error);
879
1179
  }
1180
+ }
1181
+ applyGeoLayersToExisting = async (layers) => {
1182
+ await this.attachSidecarsToSelected(layers);
880
1183
  };
881
1184
  /** Ask the map to frame the selected branch and wait for the camera to land,
882
1185
  * capped by FOCUS_SETTLE_CAP_MS and cut short by the selection's abort. */
@@ -916,13 +1219,15 @@ export class Controller {
916
1219
  if (ac.signal.aborted)
917
1220
  return;
918
1221
  }
1222
+ if (c.formulation === 'acpf')
1223
+ return;
919
1224
  try {
920
1225
  // The column from the browser Study (under the case's formulation). DC OPF
921
1226
  // may reconcile a null column via the server; AC OPF / SOCWR are browser
922
1227
  // only, so a null column there is terminal.
923
- const networkJson = await this.ensureNetworkJson(c);
924
- if (networkJson) {
925
- const sensitivity = await this.browserSensitivity(c, networkJson, target);
1228
+ const studyInputJson = await this.ensureStudyInputJson(c);
1229
+ if (studyInputJson) {
1230
+ const sensitivity = await this.browserSensitivity(c, studyInputJson, target);
926
1231
  if (!ac.signal.aborted && sensitivity) {
927
1232
  this.acceptSensitivity(c, sensitivity, target, sensitivitySeq);
928
1233
  handled = true;
@@ -975,7 +1280,7 @@ export class Controller {
975
1280
  computeOffNotice(c) {
976
1281
  return this.studyUnavailable.has(c) ? WASM_REQUIRED_NOTICE : COMPUTE_OFF_NOTICE;
977
1282
  }
978
- /** The one server ∂LMP/∂parameter request a selection may make: the
1283
+ /** The one server nodal-value/parameter request a selection may make: the
979
1284
  * reconciliation path when the browser Study produced no column for a DC OPF
980
1285
  * case. Never retries on its own: a second request inside the same rate limit
981
1286
  * window is a guaranteed rejection, and the retry button covers the user
@@ -1041,7 +1346,7 @@ export class Controller {
1041
1346
  };
1042
1347
  selectLocalBus = async (localId, busId) => {
1043
1348
  const c = this.app.localCases.find((lc) => lc.id === localId);
1044
- if (!c?.networkJson || !c.network)
1349
+ if (!c?.studyInputJson || !c.network)
1045
1350
  return;
1046
1351
  this.app.activeCaseId = null;
1047
1352
  this.leaveMulti();
@@ -1049,14 +1354,20 @@ export class Controller {
1049
1354
  this.app.placingLocalId = null;
1050
1355
  const { ac, sensitivitySeq } = this.beginBusSelection(c, busId);
1051
1356
  try {
1052
- const sensitivity = await this.browserSensitivity(c, c.networkJson, { bus: busId });
1357
+ if (c.formulation === 'acpf')
1358
+ return;
1359
+ const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1360
+ bus: busId
1361
+ });
1053
1362
  if (!ac.signal.aborted)
1054
1363
  this.acceptSensitivity(c, sensitivity, { bus: busId }, sensitivitySeq);
1055
1364
  if (!ac.signal.aborted && !sensitivity) {
1056
- // A null column means the solve ran but produced no dLMP/dd for this bus (or the
1365
+ // A null column means the solve ran but produced no price/demand column for this bus (or the
1057
1366
  // Study could not be built); local cases have no server fallback, so say so
1058
- // instead of leaving the panel in LMP view with no explanation.
1059
- this.app.error = `${c.label}: ${formulationLabel(c.formulation)} sensitivity unavailable in the browser${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ' (no dLMP/dd column for this bus)'}`;
1367
+ // instead of leaving the panel in nodal value view with no explanation.
1368
+ this.app.error = `${c.label}: ${formulationLabel(c.formulation)} sensitivity unavailable in the browser${c.solveFallbackReason
1369
+ ? `: ${c.solveFallbackReason}`
1370
+ : ' (no price sensitivity column for this bus)'}`;
1060
1371
  }
1061
1372
  }
1062
1373
  catch (e) {
@@ -1080,7 +1391,7 @@ export class Controller {
1080
1391
  failColumnUnavailable(c, target, retry) {
1081
1392
  const what = 'bus' in target
1082
1393
  ? `${formulationLabel(c.formulation)} sensitivity`
1083
- : '∂LMP/∂rating sensitivity';
1394
+ : 'price/rating sensitivity';
1084
1395
  this.fail(`${this.caseName(c)}: ${what} unavailable${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`, retry);
1085
1396
  }
1086
1397
  /** `focus` frames the selected line on the map first; the sensitivity solve
@@ -1088,7 +1399,7 @@ export class Controller {
1088
1399
  selectBranch = (caseId, branchId, opts = {}) => this.selectTarget(caseId, { branch: branchId }, opts.focus ?? false);
1089
1400
  selectLocalBranch = async (localId, branchId, opts = {}) => {
1090
1401
  const c = this.app.localCases.find((lc) => lc.id === localId);
1091
- if (!c?.networkJson || !c.network)
1402
+ if (!c?.studyInputJson || !c.network)
1092
1403
  return;
1093
1404
  this.app.activeCaseId = null;
1094
1405
  this.leaveMulti();
@@ -1102,7 +1413,11 @@ export class Controller {
1102
1413
  if (ac.signal.aborted)
1103
1414
  return;
1104
1415
  }
1105
- const sensitivity = await this.browserSensitivity(c, c.networkJson, { branch: branchId });
1416
+ if (c.formulation === 'acpf')
1417
+ return;
1418
+ const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1419
+ branch: branchId
1420
+ });
1106
1421
  if (ac.signal.aborted)
1107
1422
  return;
1108
1423
  if (sensitivity) {
@@ -1126,11 +1441,13 @@ export class Controller {
1126
1441
  this.abort?.abort();
1127
1442
  const c = this.app.active;
1128
1443
  if (c) {
1444
+ this.bumpRevision(c);
1129
1445
  c.sensitivitySeq++;
1130
1446
  c.sensitivity = null;
1131
1447
  }
1132
1448
  const lc = this.app.activeLocal;
1133
1449
  if (lc) {
1450
+ this.bumpRevision(lc);
1134
1451
  lc.sensitivitySeq = (lc.sensitivitySeq ?? 0) + 1;
1135
1452
  lc.sensitivity = null;
1136
1453
  }
@@ -1146,7 +1463,7 @@ export class Controller {
1146
1463
  this.app.previewDeltaMw = null;
1147
1464
  this.app.previewRatingMw = null;
1148
1465
  this.app.previewActive = false;
1149
- this.app.previewLmp = null;
1466
+ this.app.previewPrices = null;
1150
1467
  this.previewObjective = null;
1151
1468
  this.ratingSlope = null;
1152
1469
  this.app.demandRangeMode = 'local';
@@ -1154,12 +1471,14 @@ export class Controller {
1154
1471
  this.app.sensitivityLoading = false;
1155
1472
  };
1156
1473
  // Exact solve in the browser (wasm). The build-once Study commits the new
1157
- // operating point without re-parsing, returning the ∂LMP/∂parameter column for
1158
- // the selection target in the same solve; on a Study failure or missing network
1159
- // JSON it reconciles via the server stream (backend cases). Rating edits never
1474
+ // operating point without re-parsing, returning the nodal-value/parameter column for
1475
+ // the selection target in the same solve; on a Study failure or missing PowerIO
1476
+ // module it reconciles via the server stream (backend cases). Rating edits never
1160
1477
  // fall back: the server solves at base ratings, so a Study failure there is
1161
1478
  // terminal.
1162
1479
  runSolve = (c, target) => {
1480
+ if (c.formulation === 'acpf')
1481
+ target = null;
1163
1482
  // Cancel this case's own previous server stream, if any (backend only).
1164
1483
  if (this.isBackendCase(c)) {
1165
1484
  c.closeStream?.();
@@ -1173,11 +1492,11 @@ export class Controller {
1173
1492
  c.solveFallbackReason = null;
1174
1493
  c.iterations = [];
1175
1494
  c.solveMs = null;
1176
- this.ensureNetworkJson(c).then(async (networkJson) => {
1495
+ this.ensureStudyInputJson(c).then(async (studyInputJson) => {
1177
1496
  if (seq !== (c.solveSeq ?? 0))
1178
1497
  return;
1179
- if (!networkJson) {
1180
- c.solveFallbackReason ??= 'browser network JSON unavailable';
1498
+ if (!studyInputJson) {
1499
+ c.solveFallbackReason ??= 'PowerIO module unavailable';
1181
1500
  if (this.hasRatingEdits(c)) {
1182
1501
  c.solving = false;
1183
1502
  this.app.error = this.ratingEditsFallbackError(c);
@@ -1186,15 +1505,15 @@ export class Controller {
1186
1505
  if (this.isBackendCase(c))
1187
1506
  return this.serverSolve(c, target, seq);
1188
1507
  c.solving = false;
1189
- this.app.error = `${c.label}: local case has no browser network JSON`;
1508
+ this.app.error = `${c.label}: local case has no retained PowerIO module`;
1190
1509
  return;
1191
1510
  }
1192
1511
  const t0 = performance.now();
1193
1512
  c.solveBackend = 'clarabel-wasm';
1194
1513
  // Build-once Study path: commit the new operating point (no re-parse). The
1195
- // ∂LMP/∂parameter column for the selection target is computed in the same
1514
+ // selected nodal value sensitivity column is computed in the same
1196
1515
  // solve and comes back with it, so there is no second solve to reconcile it.
1197
- const study = await this.getStudy(c, networkJson);
1516
+ const study = await this.getStudy(c, studyInputJson);
1198
1517
  if (seq !== (c.solveSeq ?? 0))
1199
1518
  return;
1200
1519
  if (study) {
@@ -1325,18 +1644,21 @@ export class Controller {
1325
1644
  commitDelta = (value) => {
1326
1645
  const c = this.activeSolvable;
1327
1646
  const bus = this.app.selectedBus;
1328
- if (!c || bus === null)
1647
+ if (!c || bus === null || this.sliderDisabled)
1329
1648
  return;
1330
1649
  // Refresh the engine preview at the commit value (a typed value may not have
1331
1650
  // driven a drag), then score the commit with the engine's predicted Δobjective.
1332
1651
  this.runPreview(c, bus, value);
1333
1652
  c.predictedObjective = this.predictedDeltaObj;
1334
1653
  c.deltas = this.previewDeltas(c, bus, value);
1654
+ this.bumpRevision(c);
1335
1655
  this.app.previewDeltaMw = value;
1336
1656
  this.app.previewActive = true;
1337
1657
  this.runSolve(c, { bus });
1338
1658
  };
1339
1659
  finishDemandInput = (value) => {
1660
+ if (this.sliderDisabled)
1661
+ return;
1340
1662
  if (Math.abs(value - this.committedDelta) < 0.25) {
1341
1663
  if (!this.activeSolvable?.solving) {
1342
1664
  this.app.previewActive = false;
@@ -1349,8 +1671,9 @@ export class Controller {
1349
1671
  resetCase = (c) => {
1350
1672
  c.deltas = {};
1351
1673
  c.ratings = {};
1674
+ this.bumpRevision(c);
1352
1675
  c.predictedObjective = null;
1353
- this.app.previewLmp = null;
1676
+ this.app.previewPrices = null;
1354
1677
  this.previewObjective = null;
1355
1678
  this.ratingSlope = null;
1356
1679
  this.app.previewDeltaMw = this.app.selectedBus === null ? null : 0;
@@ -1363,19 +1686,19 @@ export class Controller {
1363
1686
  c.solution = c.baseSolution;
1364
1687
  this.runSolve(c, this.selectionTarget);
1365
1688
  };
1366
- // Switch the active case to a new OPF formulation. Solving every formulation stays
1367
- // entirely in the browser via the Study (nothing is routed to the server), so this
1368
- // disposes the old Study — `getStudy` rebuilds it for the new formulation, re-parsing
1369
- // and re-solving the base — then re-solves at the committed demand. The base solution
1370
- // is dropped so it is recaptured under the new formulation (a DC and an AC objective
1371
- // are not comparable). A no-op when the choice is unchanged.
1689
+ /** Rebuild the numerical Study for a selected calculation at the current demand. */
1372
1690
  changeFormulation = (c, next) => {
1373
1691
  if (c.formulation === next)
1374
1692
  return;
1375
- // Disabled menu items (e.g. AC OPF, coming soon) are not selectable in the engine yet.
1693
+ if (c instanceof LocalCase && c.declaredFormulation && c.declaredFormulation !== next) {
1694
+ this.fail('This PowerIO instance requires its declared calculation.');
1695
+ return;
1696
+ }
1376
1697
  if (FORMULATIONS.find((f) => f.id === next)?.disabled)
1377
1698
  return;
1378
1699
  c.formulation = next;
1700
+ this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
1701
+ this.bumpRevision(c);
1379
1702
  // The committed point carries over (same demand), but the model and its solution do
1380
1703
  // not; drop the Study and the cached solutions so they rebuild under `next`.
1381
1704
  this.disposeStudy(c);
@@ -1384,7 +1707,7 @@ export class Controller {
1384
1707
  c.iterations = [];
1385
1708
  c.solveMs = null;
1386
1709
  c.predictedObjective = null;
1387
- this.app.previewLmp = null;
1710
+ this.app.previewPrices = null;
1388
1711
  this.previewObjective = null;
1389
1712
  this.ratingSlope = null;
1390
1713
  this.app.error = null;
@@ -1395,32 +1718,57 @@ export class Controller {
1395
1718
  this.runSolve(c, this.selectionTarget);
1396
1719
  };
1397
1720
  /** Parse dropped files in the browser via the powerio wasm module. Case
1398
- * files (.m, .raw, .aux) become local networks; geographic files can
1721
+ * files (.m, .raw, .aux, .epc, .pwb) become local networks; geographic files can
1399
1722
  * place those networks; a PowerWorld .pwd becomes a substation point
1400
1723
  * preview. Files run serially; nothing uploads. */
1401
1724
  ingestFiles = async (files) => {
1402
- const list = Array.from(files);
1725
+ const release = this.dropBatchGate.enter();
1726
+ if (!release) {
1727
+ this.app.error = 'another file batch is still being parsed';
1728
+ return;
1729
+ }
1730
+ try {
1731
+ let list;
1732
+ try {
1733
+ list = validateDropBatch(files);
1734
+ }
1735
+ catch (e) {
1736
+ this.app.error = errorText(e);
1737
+ return;
1738
+ }
1739
+ await this.ingestFileBatch(list);
1740
+ }
1741
+ catch (e) {
1742
+ this.app.error = errorText(e);
1743
+ }
1744
+ finally {
1745
+ this.app.parsingFile = false;
1746
+ release();
1747
+ }
1748
+ };
1749
+ ingestFileBatch = async (list) => {
1750
+ const sidecarTarget = this.app.activeMulti ?? this.app.activeLocal ?? this.app.active;
1403
1751
  let parsedCaseCount = 0;
1404
- // True once a dropped case file took the geographic sidecars; a restored
1405
- // package or an existing case only takes them when no case file did.
1406
- let geoLayersConsumed = false;
1407
- // Routable `.json` content (a saved study package, a multiconductor package,
1408
- // a BMOPF/PMD document) shares the `.json` extension with a geographic file,
1409
- // so classify and consume it up front: the geo/case split below sends every
1410
- // `.json` to the coordinate parser, which would misread these as coordinate
1411
- // data. Unroutable `.json` files fall through unchanged.
1752
+ // Routable `.json` content (saved packages, model JSON, and transmission or
1753
+ // distribution documents) shares the extension with geographic files. Route
1754
+ // it up front, but stage recognized payloads until the geographic/display
1755
+ // sidecars below have parsed. Unrouted JSON retains its first byte buffer so
1756
+ // the geographic fallback does not read a potentially large file twice.
1412
1757
  const rest = [];
1413
- let restoredAnyPackage = false;
1758
+ const routedJson = [];
1759
+ const jsonBytes = new Map();
1414
1760
  for (const file of list) {
1415
1761
  if (file.name.toLowerCase().endsWith('.json')) {
1416
- const outcome = await this.ingestJsonDrop(file);
1417
- if (outcome === 'restored' || outcome === 'viewed') {
1762
+ const route = await this.routeJsonDrop(file);
1763
+ if (route.outcome === 'balanced' || route.outcome === 'multiconductor') {
1418
1764
  parsedCaseCount++;
1419
- restoredAnyPackage ||= outcome === 'restored';
1765
+ routedJson.push({ file, route });
1420
1766
  continue;
1421
1767
  }
1422
- if (outcome === 'failed')
1768
+ if (route.outcome === 'failed')
1423
1769
  continue;
1770
+ if (route.bytes)
1771
+ jsonBytes.set(file, route.bytes);
1424
1772
  }
1425
1773
  rest.push(file);
1426
1774
  }
@@ -1428,8 +1776,14 @@ export class Controller {
1428
1776
  for (const file of rest.filter((f) => isGeoFileName(f.name))) {
1429
1777
  this.app.parsingFile = true;
1430
1778
  try {
1431
- const parsed = await parseGeo(new Uint8Array(await file.arrayBuffer()), file.name);
1432
- geoLayers.push({ name: file.name, layer: parsed.layer, warnings: parsed.warnings });
1779
+ const bytes = await readDropFileBytes(file, jsonBytes.get(file));
1780
+ jsonBytes.delete(file);
1781
+ const parsed = await parseGeo(bytes, file.name);
1782
+ geoLayers.push({
1783
+ name: file.name,
1784
+ layer: parsed.layer,
1785
+ diagnostics: parsed.diagnostics
1786
+ });
1433
1787
  this.app.error = null;
1434
1788
  }
1435
1789
  catch (e) {
@@ -1447,7 +1801,12 @@ export class Controller {
1447
1801
  this.app.parsingFile = true;
1448
1802
  try {
1449
1803
  const bytes = new Uint8Array(await file.arrayBuffer());
1450
- displays.push({ file, bytes, preview: await parseDisplay(bytes), consumed: false });
1804
+ displays.push({
1805
+ file,
1806
+ bytes,
1807
+ preview: await parseDisplay(bytes),
1808
+ consumed: false
1809
+ });
1451
1810
  this.app.error = null;
1452
1811
  }
1453
1812
  catch (e) {
@@ -1457,23 +1816,64 @@ export class Controller {
1457
1816
  this.app.parsingFile = false;
1458
1817
  }
1459
1818
  }
1819
+ // Materialize routed JSON cases only after sidecars are ready. This keeps
1820
+ // placement ahead of the first solve and preserves the original JSON order.
1821
+ // `prepareDroppedLocal` is a wasm call that takes seconds on a large case,
1822
+ // and the drop gate refuses a second batch throughout, so the UI has to
1823
+ // keep saying so.
1824
+ if (routedJson.length > 0)
1825
+ this.app.parsingFile = true;
1826
+ try {
1827
+ for (const { file, route } of routedJson) {
1828
+ if (route.outcome === 'multiconductor') {
1829
+ const multi = this.addMultiCase(file.name, route.payload);
1830
+ if (geoLayers.length) {
1831
+ try {
1832
+ await this.applyMultiGeoLayers(multi, geoLayers);
1833
+ }
1834
+ catch (error) {
1835
+ this.app.error = errorText(error);
1836
+ }
1837
+ }
1838
+ continue;
1839
+ }
1840
+ if (route.outcome !== 'balanced')
1841
+ continue;
1842
+ const local = this.localFromBalancedPayload(file.name, route.payload);
1843
+ const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
1844
+ this.addAndActivateLocal(local);
1845
+ this.app.error = placement.error;
1846
+ }
1847
+ }
1848
+ finally {
1849
+ this.app.parsingFile = false;
1850
+ }
1460
1851
  for (const file of rest.filter((f) => !isGeoFileName(f.name) && !isDisplayFile(f.name))) {
1461
1852
  // OpenDSS routes by extension into the multiconductor viewing path.
1462
1853
  const distFormat = distExtensionFormat(file.name);
1463
1854
  if (distFormat) {
1464
- if (await this.ingestDistFile(file, distFormat))
1855
+ if (await this.ingestDistFile(file, distFormat)) {
1465
1856
  parsedCaseCount++;
1857
+ if (geoLayers.length && this.app.activeMulti) {
1858
+ try {
1859
+ await this.applyMultiGeoLayers(this.app.activeMulti, geoLayers);
1860
+ }
1861
+ catch (error) {
1862
+ this.app.error = errorText(error);
1863
+ }
1864
+ }
1865
+ }
1466
1866
  continue;
1467
1867
  }
1468
1868
  const format = formatOf(file.name);
1469
1869
  if (!format) {
1470
- this.app.error = `${file.name}: not a case or geographic file (.m, .raw, .aux, .dss, .pwd, .csv, .json, .geojson)`;
1870
+ this.app.error = `${file.name}: not a case or geographic file (.m, .raw, .aux, .epc, .pwb, .dss, .pwd, .csv, .json, .geojson)`;
1471
1871
  continue;
1472
1872
  }
1473
1873
  this.app.parsingFile = true;
1474
1874
  try {
1475
- const text = await file.text();
1476
- const { network_json, topology, view, ...summary } = await ingestCase(text, format);
1875
+ const bytes = new Uint8Array(await file.arrayBuffer());
1876
+ const { module_json, topology, view, ...summary } = await ingestCase(bytes, format);
1477
1877
  if (format === 'aux' && (summary.n_branch === 0 || summary.n_gen === 0)) {
1478
1878
  this.app.error = `${file.name}: aux parsed, but no complete network; drop the matching .m or .raw case file`;
1479
1879
  continue;
@@ -1487,34 +1887,15 @@ export class Controller {
1487
1887
  label,
1488
1888
  fileName: file.name,
1489
1889
  summary,
1490
- networkJson: network_json,
1890
+ studyInputJson: module_json,
1491
1891
  topology,
1492
1892
  coordsKind: summary.coords_kind,
1493
1893
  view
1494
1894
  });
1495
- // A co-dropped geographic file places (or repositions) the network. Apply
1496
- // it for any parsed case, not just synthetic ones, so a geo overlay
1497
- // dropped alongside a case that already has coordinates takes effect
1498
- // instead of being silently discarded. A failed apply keeps the parsed
1499
- // case (placement still works) and surfaces the reason.
1500
- let geoError = null;
1501
- if (geoLayers.length > 0 && local.networkJson) {
1502
- try {
1503
- await this.applyGeoLayers(local, geoLayers);
1504
- geoLayersConsumed = true;
1505
- }
1506
- catch (e) {
1507
- geoError = `${geoLayers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`;
1508
- }
1509
- }
1510
- else if (local.coordsKind === 'synthetic_pending' && local.networkJson) {
1511
- // No sidecar: a coordinate-less case may still borrow a co-dropped
1512
- // .pwd's substation points through the SubNum join.
1513
- await this.fillFromDisplaySibling(local, displays);
1514
- }
1895
+ const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
1515
1896
  this.addAndActivateLocal(local);
1516
1897
  parsedCaseCount++;
1517
- this.app.error = geoError; // a successful parse clears a prior file's error
1898
+ this.app.error = placement.error; // a successful parse clears a prior file's error
1518
1899
  }
1519
1900
  catch (e) {
1520
1901
  this.app.error = `${file.name}: ${e instanceof Error ? e.message : e}`;
@@ -1523,92 +1904,147 @@ export class Controller {
1523
1904
  this.app.parsingFile = false;
1524
1905
  }
1525
1906
  }
1526
- // A .pwd no case consumed becomes a substation point preview entry, at
1527
- // the approximate positions the engine projected.
1528
- for (const d of displays.filter((entry) => !entry.consumed)) {
1529
- const points = d.preview.substations.map((s) => ({
1530
- number: s.number,
1531
- name: s.name,
1532
- lon: s.lon,
1533
- lat: s.lat
1534
- }));
1535
- this.addAndActivateLocal(new LocalCase({
1536
- id: `local-${++this.localSeq}`,
1537
- label: d.file.name.replace(/\.[^.]+$/, ''),
1538
- fileName: d.file.name,
1539
- summary: null,
1540
- view: null,
1541
- substations: { points, approximate: true }
1542
- }));
1543
- }
1544
- // Unconsumed sidecars apply to the restored package (now the active local
1545
- // case) so a saved study co-dropped with its coordinates places, or to an
1546
- // existing case when nothing in the drop parsed at all.
1547
- if (geoLayers.length > 0 && !geoLayersConsumed && (restoredAnyPackage || parsedCaseCount === 0)) {
1548
- await this.applyGeoLayersToExisting(geoLayers);
1907
+ if (parsedCaseCount === 0 && (geoLayers.length > 0 || displays.length > 0)) {
1908
+ await this.attachSidecarsToSelected(geoLayers, displays, sidecarTarget);
1909
+ }
1910
+ else {
1911
+ const unmatchedDisplays = displays.filter((display) => !display.consumed);
1912
+ if (unmatchedDisplays.length)
1913
+ this.app.error = `${unmatchedDisplays.map((display) => display.file.name).join(' + ')}: no drawing objects matched the dropped case`;
1549
1914
  }
1550
1915
  };
1551
- /** Fill a coordinate-less case's positions from a co-dropped PowerWorld
1552
- * `.pwd`: the display's substation points project to approximate
1553
- * longitude/latitude and join onto buses through the `SubNum` extras key.
1554
- * The first display that joins wins; one that matches nothing stays
1555
- * available as its own preview entry, and the case stays placeable. */
1916
+ /** Apply co-dropped geographic data before a local case becomes active and
1917
+ * starts solving. Without geographic data, let a coordinate-less case borrow
1918
+ * the first matching PowerWorld display. A failed geographic join keeps the
1919
+ * parsed case and returns the user-facing placement error. */
1920
+ prepareDroppedLocal = async (local, geoLayers, displays) => {
1921
+ let geoLayersConsumed = false;
1922
+ let error = null;
1923
+ if (geoLayers.length > 0 && local.studyInputJson) {
1924
+ try {
1925
+ await this.applyGeoLayers(local, geoLayers);
1926
+ geoLayersConsumed = true;
1927
+ }
1928
+ catch (e) {
1929
+ error = `${geoLayers.map((layer) => layer.name).join(' + ')}: ${errorText(e)}`;
1930
+ }
1931
+ }
1932
+ if (local.studyInputJson && displays.length > 0) {
1933
+ await this.fillFromDisplaySibling(local, displays);
1934
+ }
1935
+ if (!local.view && !local.diagram && local.topology) {
1936
+ const view = {
1937
+ ...placeSyntheticTopology(local.topology, { lon: 0, lat: 0 }),
1938
+ coordinate_space: 'diagram'
1939
+ };
1940
+ const layer = JSON.stringify({
1941
+ type: 'FeatureCollection',
1942
+ powerio_geo: { space: 'diagram', kind: 'synthetic' },
1943
+ features: [
1944
+ ...view.buses.map((b) => ({
1945
+ type: 'Feature',
1946
+ properties: { target: 'bus', id: String(b.id) },
1947
+ geometry: { type: 'Point', coordinates: [b.lon, b.lat] }
1948
+ })),
1949
+ ...view.branches.map((b) => ({
1950
+ type: 'Feature',
1951
+ properties: {
1952
+ target: 'branch',
1953
+ branch_id: String(b.id),
1954
+ from: String(b.from),
1955
+ to: String(b.to)
1956
+ },
1957
+ geometry: { type: 'LineString', coordinates: b.path }
1958
+ }))
1959
+ ]
1960
+ });
1961
+ local.diagram = {
1962
+ view,
1963
+ layer,
1964
+ name: 'Network diagram',
1965
+ warnings: ['No geographic coordinates; positions follow network connectivity.']
1966
+ };
1967
+ local.displayMode = 'diagram';
1968
+ }
1969
+ return { geoLayersConsumed, error };
1970
+ };
1971
+ /** Retain diagram geometry separately from the electrical case's geographic data. */
1556
1972
  fillFromDisplaySibling = async (c, displays) => {
1557
- if (!c.networkJson)
1973
+ if (!c.studyInputJson)
1558
1974
  return;
1559
1975
  for (const d of displays.filter((entry) => !entry.consumed)) {
1560
1976
  try {
1561
- const payload = await applyDisplayGeo(c.networkJson, d.bytes);
1562
- this.adoptGeoPayload(c, payload, d.file.name, `${d.file.name} substations`, payload.report.notes);
1977
+ const payload = await applyDisplayGeo(c.studyInputJson, d.bytes);
1978
+ if (!payload.view)
1979
+ continue;
1980
+ c.diagram = {
1981
+ view: payload.view,
1982
+ layer: d.preview.layer,
1983
+ name: d.file.name,
1984
+ warnings: [
1985
+ `${payload.report.matched_buses} buses, ${payload.report.matched_branches} routes matched; ${payload.report.unmatched_features} unmatched objects`,
1986
+ ...payload.report.notes
1987
+ ]
1988
+ };
1989
+ if (!c.view)
1990
+ c.displayMode = 'diagram';
1991
+ c.geoWarnings = [...(c.geoWarnings ?? []), ...c.diagram.warnings];
1563
1992
  d.consumed = true;
1564
- return;
1565
1993
  }
1566
- catch {
1567
- // No SubNum join against this display; try the next one.
1994
+ catch (error) {
1995
+ c.geoWarnings = [errorText(error)];
1568
1996
  }
1569
1997
  }
1570
1998
  };
1571
- /** Route a dropped `.json` by its content: a saved study package restores
1572
- * (`restored`), a multiconductor package or a BMOPF/PMD distribution
1573
- * document views (`viewed`), `failed` means the content looked routable but
1574
- * the engine rejected it (a fail-closed message is set), and `not-package`
1575
- * falls through to the caller's geo/case handling. `drop-classify` is the
1576
- * single owner of the classification. */
1577
- ingestJsonDrop = async (file) => {
1578
- let text;
1999
+ setCaseDisplayMode = (c, mode) => {
2000
+ if (mode === 'diagram' ? !c.diagram : !c.view)
2001
+ return;
2002
+ c.displayMode = mode;
2003
+ c.network = this.localNetwork(c);
2004
+ this.app.requestFrame(c.id);
2005
+ };
2006
+ /** Route a dropped `.json` by its content. Recognized payloads return without
2007
+ * mutating case state so co-dropped sidecars can be applied first; unrouted
2008
+ * content returns the bytes already read for the geographic fallback. */
2009
+ routeJsonDrop = async (file) => {
2010
+ let bytes;
1579
2011
  try {
1580
- text = await file.text();
2012
+ bytes = await readDropFileBytes(file);
1581
2013
  }
1582
2014
  catch {
1583
- return 'not-package';
2015
+ return { outcome: 'unrouted', bytes: null };
1584
2016
  }
1585
- const kind = classifyJson(text);
1586
- if (kind === 'not-json')
1587
- return 'not-package';
1588
2017
  this.app.parsingFile = true;
1589
2018
  try {
1590
- if (kind === 'balanced-package') {
1591
- this.restoreLocalFromPackage(file.name, await loadPackage(text));
2019
+ const result = await ingestJsonDrop(bytes);
2020
+ if (result.kind === 'unknown')
2021
+ return { outcome: 'unrouted', bytes };
2022
+ if (result.kind === 'ambiguous') {
2023
+ this.app.error = `${file.name}: JSON markers name both transmission and distribution formats`;
2024
+ return { outcome: 'failed' };
2025
+ }
2026
+ if (result.kind === 'transmission') {
1592
2027
  this.app.error = null;
1593
- return 'restored';
1594
- }
1595
- const format = kind === 'multiconductor-package' ? 'pio' : kind;
1596
- const payload = await ingestDistCase(text, format);
1597
- // BMOPF is the classifier's catch-all and its reader is liberal: an
1598
- // arbitrary JSON object parses as an empty case (unknown fields land
1599
- // in extras). Zero buses means this was not a distribution document;
1600
- // leave the file to the geo sidecar path (which places coordinates
1601
- // or reports its own precise error) instead of adding a phantom
1602
- // empty multiconductor case.
1603
- if (kind === 'bmopf' && payload.n_bus === 0)
1604
- return 'not-package';
1605
- this.addMultiCase(file.name, payload);
1606
- this.app.error = null;
1607
- return 'viewed';
2028
+ return { outcome: 'balanced', payload: result.payload };
2029
+ }
2030
+ if (result.kind === 'module') {
2031
+ // A stored module carries either network family; the payload's own
2032
+ // marker decides the route, mirroring the engine's value kind.
2033
+ this.app.error = null;
2034
+ const payload = result.payload;
2035
+ if ('model' in payload)
2036
+ return { outcome: 'multiconductor', payload };
2037
+ return { outcome: 'balanced', payload };
2038
+ }
2039
+ if (result.kind === 'distribution') {
2040
+ this.app.error = null;
2041
+ return { outcome: 'multiconductor', payload: result.payload };
2042
+ }
2043
+ throw new Error('engine returned an unsupported JSON drop result');
1608
2044
  }
1609
2045
  catch (e) {
1610
2046
  this.app.error = `${file.name}: ${errorText(e)}`;
1611
- return 'failed';
2047
+ return { outcome: 'failed' };
1612
2048
  }
1613
2049
  finally {
1614
2050
  this.app.parsingFile = false;
@@ -1619,7 +2055,8 @@ export class Controller {
1619
2055
  ingestDistFile = async (file, format) => {
1620
2056
  this.app.parsingFile = true;
1621
2057
  try {
1622
- this.addMultiCase(file.name, await ingestDistCase(await file.text(), format));
2058
+ const bytes = new Uint8Array(await file.arrayBuffer());
2059
+ this.addMultiCase(file.name, await ingestDistCaseBytes(bytes, format));
1623
2060
  this.app.error = null;
1624
2061
  return true;
1625
2062
  }
@@ -1631,14 +2068,14 @@ export class Controller {
1631
2068
  this.app.parsingFile = false;
1632
2069
  }
1633
2070
  };
1634
- /** Build a `MulticonductorCase` from an ingest payload and make it active.
1635
- * Geographic cases place immediately; planar/synthetic cases enter placement
1636
- * (a map click or "place on map") so the user picks their center. */
2071
+ /** Retain parsed electrical inputs and draw each declared coordinate space directly. */
1637
2072
  addMultiCase(fileName, payload) {
1638
2073
  const { graph, ...summary } = payload;
1639
2074
  const coordsKind = payload.coords_kind;
1640
2075
  const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1641
- const view = coordsKind === 'geographic' ? buildGeographicView(graph) : null;
2076
+ const view = coordsKind === 'geographic'
2077
+ ? buildGeographicView(graph, payload.geo_layer)
2078
+ : buildDiagramView(graph, payload.geo_layer);
1642
2079
  const c = new MulticonductorCase({
1643
2080
  id: `dist-${++this.localSeq}`,
1644
2081
  label,
@@ -1648,12 +2085,291 @@ export class Controller {
1648
2085
  coordsKind,
1649
2086
  view
1650
2087
  });
2088
+ this.app.studyView = null;
2089
+ this.clearSelection();
1651
2090
  this.app.addMulti(c);
1652
2091
  if (c.placed)
1653
2092
  this.app.requestFrame(c.id);
2093
+ return c;
1654
2094
  }
2095
+ /** Compute terminal quantities only for the retained input and current case revision. */
2096
+ solveMultiCase = async (c, options = {}, signal) => {
2097
+ if (signal?.aborted)
2098
+ throw new DOMException('Calculation cancelled', 'AbortError');
2099
+ if (c.solving)
2100
+ throw new Error('A calculation is already running for this case');
2101
+ const unavailable = c.mcPfReason;
2102
+ if (unavailable) {
2103
+ this.app.error = unavailable;
2104
+ throw new Error(unavailable);
2105
+ }
2106
+ let input = c.moduleJson;
2107
+ const retainedAtStart = c.mcSession;
2108
+ const revision = c.revisionGeneration;
2109
+ const seq = ++c.solveSeq;
2110
+ const abort = new AbortController();
2111
+ const cancel = () => abort.abort();
2112
+ signal?.addEventListener('abort', cancel, { once: true });
2113
+ c.solveAbort = abort;
2114
+ c.solving = true;
2115
+ this.app.error = null;
2116
+ const started = performance.now();
2117
+ let nextSession = null;
2118
+ try {
2119
+ if (retainedAtStart)
2120
+ input = await retainedAtStart.inputModule();
2121
+ let snapshot = null;
2122
+ let result;
2123
+ let loadBranches = c.mcLoadBranches;
2124
+ if (this.mcTransport.createMcPfSession) {
2125
+ nextSession = await this.mcTransport.createMcPfSession(input, options, abort.signal);
2126
+ [result, loadBranches] = await Promise.all([
2127
+ nextSession.result(),
2128
+ nextSession.loadBranches()
2129
+ ]);
2130
+ }
2131
+ else {
2132
+ snapshot = this.mcTransport.solveMcStudy
2133
+ ? await this.mcTransport.solveMcStudy(input, crypto.randomUUID(), c.label, options, abort.signal)
2134
+ : null;
2135
+ result =
2136
+ snapshot?.result ?? (await this.mcTransport.solveMcModule(input, options, abort.signal));
2137
+ }
2138
+ if (abort.signal.aborted)
2139
+ throw new DOMException('Calculation cancelled', 'AbortError');
2140
+ if (seq !== c.solveSeq ||
2141
+ (retainedAtStart ? retainedAtStart !== c.mcSession : input !== c.moduleJson) ||
2142
+ revision !== c.revisionGeneration ||
2143
+ !this.app.multiCases.includes(c))
2144
+ throw new Error('The case changed while calculating. Run AC power flow again');
2145
+ if (!result.converged)
2146
+ throw new Error('AC power flow did not converge');
2147
+ // A load edit staged against the previous session has nothing to
2148
+ // apply to the fresh operating point; drop its pending flush.
2149
+ if (c.mcEditTimer !== null) {
2150
+ clearTimeout(c.mcEditTimer);
2151
+ c.mcEditTimer = null;
2152
+ }
2153
+ c.mcSession?.free();
2154
+ c.mcSession = nextSession;
2155
+ nextSession = null;
2156
+ c.mcLoadBranches = loadBranches;
2157
+ c.mcLoadEdits = [];
2158
+ c.moduleJson = input;
2159
+ c.result = result;
2160
+ c.mcSnapshot = snapshot;
2161
+ c.mcSavedAt = null;
2162
+ c.solveMs = performance.now() - started;
2163
+ c.revisionGeneration++;
2164
+ return result;
2165
+ }
2166
+ catch (error) {
2167
+ nextSession?.free();
2168
+ if (!abort.signal.aborted &&
2169
+ !(error instanceof DOMException && error.name === 'AbortError')) {
2170
+ this.app.error = errorText(error);
2171
+ this.app.errorRetry = () => {
2172
+ void this.solveMultiCase(c, options).catch(() => { });
2173
+ };
2174
+ }
2175
+ throw error;
2176
+ }
2177
+ finally {
2178
+ signal?.removeEventListener('abort', cancel);
2179
+ if (c.solveSeq === seq) {
2180
+ c.solving = false;
2181
+ c.solveAbort = null;
2182
+ }
2183
+ }
2184
+ };
2185
+ /** Materialize the current retained operating point only when a portable
2186
+ * snapshot is actually needed (save/export/geography). */
2187
+ snapshotMultiCase = async (c) => {
2188
+ if (c.mcSnapshot)
2189
+ return c.mcSnapshot;
2190
+ const session = c.mcSession;
2191
+ if (!session || !c.result)
2192
+ throw new Error('Run AC power flow before saving its result');
2193
+ if (c.solving)
2194
+ throw new Error('Wait for the calculation before saving its result');
2195
+ const revision = c.revisionGeneration;
2196
+ const snapshot = await session.snapshot(crypto.randomUUID(), c.label);
2197
+ if (session !== c.mcSession ||
2198
+ revision !== c.revisionGeneration ||
2199
+ !this.app.multiCases.includes(c))
2200
+ throw new Error('The case changed while preparing its result');
2201
+ c.mcSnapshot = snapshot;
2202
+ return snapshot;
2203
+ };
2204
+ /** Apply coordinates atomically to the explicitly selected conductor-resolved case. */
2205
+ applyMultiGeoLayers = async (c, layers) => {
2206
+ if (!layers.length)
2207
+ return;
2208
+ if (this.app.studyView || this.app.activeMulti !== c)
2209
+ throw new Error('Select the matching case before attaching coordinates');
2210
+ if (c.solving)
2211
+ throw new Error('Wait for the calculation before attaching coordinates');
2212
+ if (!c.moduleJson || !this.mcTransport.applyMcGeo)
2213
+ throw new Error('Coordinate attachment is unavailable for this case');
2214
+ const baseInput = c.moduleJson;
2215
+ const retainedAtStart = c.mcSession;
2216
+ let snapshot = c.mcSnapshot;
2217
+ if (!snapshot && c.mcSession && c.result)
2218
+ snapshot = await this.snapshotMultiCase(c);
2219
+ const input = snapshot?.input_module ?? c.moduleJson;
2220
+ const revision = c.revisionGeneration;
2221
+ let next = input;
2222
+ let payload;
2223
+ const notes = [];
2224
+ let matched = 0;
2225
+ for (const layer of layers) {
2226
+ payload = await this.mcTransport.applyMcGeo(next, layer.layer);
2227
+ if (snapshot) {
2228
+ if (!this.mcTransport.applyMcStudyGeo)
2229
+ throw new Error('Saved result coordinates are unavailable in this build');
2230
+ snapshot = await this.mcTransport.applyMcStudyGeo(snapshot, layer.layer);
2231
+ }
2232
+ if (!payload.module_json)
2233
+ throw new Error('Coordinate attachment returned no electrical input');
2234
+ next = payload.module_json;
2235
+ matched += payload.report.matched_buses + payload.report.matched_branches;
2236
+ 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));
2237
+ }
2238
+ if (!payload || !matched)
2239
+ throw new Error('No coordinates matched the selected case');
2240
+ if (this.app.studyView ||
2241
+ this.app.activeMulti !== c ||
2242
+ c.moduleJson !== baseInput ||
2243
+ c.mcSession !== retainedAtStart ||
2244
+ c.revisionGeneration !== revision ||
2245
+ c.solving)
2246
+ throw new Error('The selected case changed. Attach the coordinates again');
2247
+ const { graph, ...summary } = payload;
2248
+ if (c.mcEditTimer !== null) {
2249
+ clearTimeout(c.mcEditTimer);
2250
+ c.mcEditTimer = null;
2251
+ }
2252
+ c.mcSession?.free();
2253
+ c.mcSession = null;
2254
+ c.mcLoadBranches = [];
2255
+ c.mcLoadEdits = [];
2256
+ c.moduleJson = next;
2257
+ c.mcSnapshot = snapshot ? { ...snapshot, id: crypto.randomUUID() } : null;
2258
+ c.mcSavedAt = null;
2259
+ c.geoLayer = payload.geo_layer ?? null;
2260
+ c.graph = graph;
2261
+ c.summary = summary;
2262
+ c.coordsKind = payload.coords_kind;
2263
+ c.view =
2264
+ payload.coords_kind === 'geographic'
2265
+ ? buildGeographicView(graph, payload.geo_layer)
2266
+ : buildDiagramView(graph, payload.geo_layer);
2267
+ c.geoWarnings = notes;
2268
+ c.revisionGeneration++;
2269
+ this.app.placingMultiId = null;
2270
+ this.app.error = null;
2271
+ this.app.requestFrame(c.id);
2272
+ };
2273
+ /** Stage an absolute load-branch P/Q edit and coalesce rapid UI changes into
2274
+ * one retained-factor warm solve. */
2275
+ queueMultiLoadPower = (c, load, branch, p_w, q_var) => {
2276
+ if (!c.mcSession) {
2277
+ this.app.error = 'Run AC power flow once before editing loads';
2278
+ return;
2279
+ }
2280
+ if (!Number.isFinite(p_w) || !Number.isFinite(q_var)) {
2281
+ this.app.error = 'Load P and Q must be finite numbers';
2282
+ return;
2283
+ }
2284
+ const row = c.mcLoadBranches.find((entry) => entry.load === load && entry.branch === branch);
2285
+ if (!row) {
2286
+ this.app.error = `Load ${load} branch ${branch} is unavailable`;
2287
+ return;
2288
+ }
2289
+ c.mcLoadBranches = c.mcLoadBranches.map((entry) => entry.load === load && entry.branch === branch ? { ...entry, p_w, q_var } : entry);
2290
+ const next = { load, branch, p_w, q_var };
2291
+ const without = c.mcLoadEdits.filter((edit) => edit.load !== load || edit.branch !== branch);
2292
+ c.mcLoadEdits = p_w === row.base_p_w && q_var === row.base_q_var ? without : [...without, next];
2293
+ c.mcEditRevision++;
2294
+ c.mcSnapshot = null;
2295
+ c.mcSavedAt = null;
2296
+ c.solving = true;
2297
+ if (c.mcEditTimer !== null)
2298
+ clearTimeout(c.mcEditTimer);
2299
+ c.mcEditTimer = setTimeout(() => {
2300
+ c.mcEditTimer = null;
2301
+ void this.flushMultiLoadPowers(c);
2302
+ }, 180);
2303
+ };
2304
+ flushMultiLoadPowers = async (c) => {
2305
+ // A running flush picks the new revision up in its own loop.
2306
+ if (c.mcEditRunning)
2307
+ return;
2308
+ const session = c.mcSession;
2309
+ if (!session || !this.app.multiCases.includes(c)) {
2310
+ // `queueMultiLoadPower` marked the case as solving on behalf of
2311
+ // this flush; release it, or Save and Export stay disabled.
2312
+ c.solving = false;
2313
+ return;
2314
+ }
2315
+ c.mcEditRunning = true;
2316
+ c.solving = true;
2317
+ this.app.error = null;
2318
+ try {
2319
+ for (;;) {
2320
+ const revision = c.mcEditRevision;
2321
+ const edits = c.mcLoadEdits.map((edit) => ({ ...edit }));
2322
+ const started = performance.now();
2323
+ const result = await session.replaceLoadPowers(edits);
2324
+ if (session !== c.mcSession || !this.app.multiCases.includes(c))
2325
+ return;
2326
+ if (revision !== c.mcEditRevision)
2327
+ continue;
2328
+ const loadBranches = await session.loadBranches();
2329
+ if (revision !== c.mcEditRevision)
2330
+ continue;
2331
+ c.mcLoadBranches = loadBranches;
2332
+ c.result = result;
2333
+ c.mcSnapshot = null;
2334
+ c.mcSavedAt = null;
2335
+ c.solveMs = performance.now() - started;
2336
+ c.revisionGeneration++;
2337
+ break;
2338
+ }
2339
+ }
2340
+ catch (error) {
2341
+ if (session === c.mcSession) {
2342
+ try {
2343
+ c.mcLoadBranches = await session.loadBranches();
2344
+ }
2345
+ catch {
2346
+ // The session itself no longer answers (the worker trapped
2347
+ // or the handle is gone). Retrying against it would fail
2348
+ // forever, so drop it and let the next run rebuild the
2349
+ // session against a fresh worker.
2350
+ session.free();
2351
+ c.mcSession = null;
2352
+ c.mcLoadEdits = [];
2353
+ }
2354
+ }
2355
+ this.app.error = errorText(error);
2356
+ this.app.errorRetry = c.mcSession
2357
+ ? () => void this.flushMultiLoadPowers(c)
2358
+ : () => void this.solveMultiCase(c).catch(() => { });
2359
+ }
2360
+ finally {
2361
+ // The running flag belongs to this call regardless of whether the
2362
+ // session was replaced meanwhile; leaving it set would silently
2363
+ // ignore every later edit. The solving flag is shared with an
2364
+ // explicit solve, which clears it itself when it owns it.
2365
+ c.mcEditRunning = false;
2366
+ if (session === c.mcSession || c.mcSession === null)
2367
+ c.solving = false;
2368
+ }
2369
+ };
1655
2370
  /** Make a multiconductor case active, framing it when it is placed. */
1656
2371
  activateMulti = (c) => {
2372
+ this.app.studyView = null;
1657
2373
  this.clearSelection();
1658
2374
  this.app.activeCaseId = null;
1659
2375
  this.app.activeLocalId = null;
@@ -1711,6 +2427,11 @@ export class Controller {
1711
2427
  };
1712
2428
  removeMultiCase = async (c, event) => {
1713
2429
  event?.stopPropagation();
2430
+ c.solveAbort?.abort();
2431
+ if (c.mcEditTimer !== null)
2432
+ clearTimeout(c.mcEditTimer);
2433
+ c.mcSession?.free();
2434
+ c.mcSession = null;
1714
2435
  if (this.app.activeMultiId === c.id) {
1715
2436
  c.selectedBusId = null;
1716
2437
  c.selectedEdgeId = null;
@@ -1725,37 +2446,89 @@ export class Controller {
1725
2446
  else
1726
2447
  this.placeLocalCase(lon, lat);
1727
2448
  };
1728
- /** Build a local case from a restored study package: the same shape a dropped case
1729
- * takes, with the saved demand/rating deltas and formulation pre-applied. */
1730
- restoreLocalFromPackage = (fileName, pkg) => {
1731
- const { network_json, topology, view, formulation, deltas, rates, ...summary } = pkg;
1732
- const id = `local-${++this.localSeq}`;
2449
+ async projectStudyInput(input, layer) {
2450
+ const parsed = layer
2451
+ ? { payload: await applyGeo(input, layer) }
2452
+ : await ingestJsonDrop(new TextEncoder().encode(input));
2453
+ if (!parsed.payload || !('topology' in parsed.payload))
2454
+ throw new Error('Study requires a balanced network');
2455
+ const c = parsed.payload;
2456
+ const view = c.view ?? {
2457
+ ...placeSyntheticTopology(c.topology, { lon: 0, lat: 0 }),
2458
+ coordinate_space: 'diagram'
2459
+ };
2460
+ return { id: 'study', name: c.name, base_mva: c.base_mva, synthetic_coords: !c.view, ...view };
2461
+ }
2462
+ async caseGeographyLayers(c) {
2463
+ if (!c.studyInputJson || !c.view)
2464
+ return [];
2465
+ return [await extractGeo(c.studyInputJson)];
2466
+ }
2467
+ /** Capture a matching cached result without constructing or solving a model. */
2468
+ async captureSavedCase(c) {
2469
+ const base = await this.ensureStudyInputJson(c);
2470
+ if (!base)
2471
+ throw new Error('The case input is unavailable');
2472
+ const detached = c instanceof LocalCase ? this.detachedCaseCaptures.get(c) : undefined;
2473
+ if (detached?.baseInput === base &&
2474
+ detached.edits === this.caseEditKey(c) &&
2475
+ detached.solution === c.solution)
2476
+ return detached.captured;
2477
+ const cached = this.caseStudies.get(c);
2478
+ if (c.solution && cached?.studyInputJson === base && cached.formulation === c.formulation) {
2479
+ const current = await cached.study.currentSolution();
2480
+ if (JSON.stringify(current) === JSON.stringify(c.solution)) {
2481
+ return {
2482
+ input: await cached.study.saveInstanceModule(),
2483
+ solution: await cached.study.saveSolutionModule(),
2484
+ view: await cached.study.currentView()
2485
+ };
2486
+ }
2487
+ }
2488
+ if (Object.values(this.caseDeltas(c)).some((v) => v !== 0) ||
2489
+ Object.values(this.caseRatings(c)).some((v) => v !== 0)) {
2490
+ throw new Error('The displayed edits have no matching saved result. Finish solving before saving them.');
2491
+ }
2492
+ if (c instanceof CaseState &&
2493
+ c.solution &&
2494
+ c.formulation === 'dcopf' &&
2495
+ this.api.getSavedCase) {
2496
+ const snapshot = await this.api.getSavedCase(c.id);
2497
+ if (snapshot.base_input !== base ||
2498
+ JSON.stringify(snapshot.display_solution) !== JSON.stringify(c.solution))
2499
+ throw new Error('The server case changed. Reload it before saving.');
2500
+ return { input: snapshot.input, solution: snapshot.solution, view: snapshot.view };
2501
+ }
2502
+ return { input: base };
2503
+ }
2504
+ /** Build a local case from a balanced PowerIO ingest payload without activating
2505
+ * it yet; co-dropped placement data is applied before the first solve. */
2506
+ localFromBalancedPayload = (fileName, payload) => {
2507
+ const { module_json, topology, view, formulation, ...summary } = payload;
1733
2508
  const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1734
- const local = new LocalCase({
1735
- id,
2509
+ return new LocalCase({
2510
+ id: `local-${++this.localSeq}`,
1736
2511
  label,
1737
2512
  fileName,
2513
+ formulation,
2514
+ declaredFormulation: formulation,
1738
2515
  summary,
1739
- networkJson: network_json,
2516
+ studyInputJson: module_json,
1740
2517
  topology,
1741
2518
  coordsKind: summary.coords_kind,
1742
2519
  view
1743
2520
  });
1744
- local.deltas = deltas;
1745
- local.ratings = rates;
1746
- local.formulation = formulation;
1747
- this.addAndActivateLocal(local);
1748
2521
  };
1749
2522
  /** Build (or reuse) the case's Study and commit it at the current edit state, so a
1750
2523
  * save or export captures exactly what is on screen. Null (with an error set) when no
1751
- * network or Study is available. */
2524
+ * PowerIO module or Study is available. */
1752
2525
  syncedStudy = async (c) => {
1753
- const networkJson = await this.ensureNetworkJson(c);
1754
- if (!networkJson) {
1755
- this.app.error = `${this.caseName(c)}: no network available to save`;
2526
+ const studyInputJson = await this.ensureStudyInputJson(c);
2527
+ if (!studyInputJson) {
2528
+ this.app.error = `${this.caseName(c)}: no PowerIO module available to save`;
1756
2529
  return null;
1757
2530
  }
1758
- const study = await this.getStudy(c, networkJson);
2531
+ const study = await this.getStudy(c, studyInputJson);
1759
2532
  if (!study) {
1760
2533
  this.app.error = `${this.caseName(c)}: the browser study is unavailable`;
1761
2534
  return null;
@@ -1763,31 +2536,47 @@ export class Controller {
1763
2536
  await study.commit(c.id, this.caseDeltas(c), this.caseRatings(c), null);
1764
2537
  return study;
1765
2538
  };
1766
- /** Save the active study as a `.pio.json` package and download it. */
1767
- saveStudyPackage = async (c) => {
2539
+ /** Save the materialized case as a retained PowerIO module. */
2540
+ saveCaseModule = async (c) => {
2541
+ const study = await this.syncedStudy(c);
2542
+ if (!study)
2543
+ return;
2544
+ try {
2545
+ downloadText(await study.saveModule(), `${this.caseFileStem(c)}.pio.json`, 'application/json');
2546
+ this.app.error = null;
2547
+ }
2548
+ catch (e) {
2549
+ this.app.error = `${this.caseName(c)}: could not save PowerIO module: ${errorText(e)}`;
2550
+ }
2551
+ };
2552
+ /** Save the exact DC OPF result with the materialized problem instance it solved. */
2553
+ saveSolutionModule = async (c) => {
2554
+ if (c.formulation !== 'dcopf') {
2555
+ this.app.error = `${this.caseName(c)}: exact solution modules require the DC OPF formulation`;
2556
+ return;
2557
+ }
1768
2558
  const study = await this.syncedStudy(c);
1769
2559
  if (!study)
1770
2560
  return;
1771
2561
  try {
1772
- downloadText(await study.savePackage(), `${this.caseFileStem(c)}.pio.json`, 'application/json');
2562
+ downloadText(await study.saveSolutionModule(), `${this.caseFileStem(c)}.solution.pio.json`, 'application/json');
1773
2563
  this.app.error = null;
1774
2564
  }
1775
2565
  catch (e) {
1776
- this.app.error = `${this.caseName(c)}: could not save study: ${errorText(e)}`;
2566
+ this.app.error = `${this.caseName(c)}: could not save exact solution: ${errorText(e)}`;
1777
2567
  }
1778
2568
  };
1779
- /** Export the active study's committed state to `format` and download it. Returns the
1780
- * writer's fidelity warnings so the caller can surface them. */
1781
- exportStudyAs = async (c, format) => {
2569
+ /** Export the active study's committed state to `format` and download it.
2570
+ * Returns this output operation's structured diagnostics. */
2571
+ exportCaseAs = async (c, format) => {
1782
2572
  const study = await this.syncedStudy(c);
1783
2573
  if (!study)
1784
2574
  return [];
1785
2575
  try {
1786
- const packageJson = await study.savePackage();
1787
- const exported = await exportStudy(packageJson, studyCommitIndex(packageJson), format);
2576
+ const exported = await study.export(format);
1788
2577
  downloadText(exported.text, `${this.caseFileStem(c)}.${exported.extension}`, 'text/plain');
1789
2578
  this.app.error = null;
1790
- return exported.warnings;
2579
+ return exported.diagnostics;
1791
2580
  }
1792
2581
  catch (e) {
1793
2582
  this.app.error = `${this.caseName(c)}: could not export study: ${errorText(e)}`;
@@ -1797,7 +2586,7 @@ export class Controller {
1797
2586
  /** A filesystem-safe base name for a downloaded case file. */
1798
2587
  caseFileStem(c) {
1799
2588
  const base = this.isBackendCase(c) ? c.name : c.label || c.fileName.replace(/\.[^.]+$/, '');
1800
- return base.replace(/[^\w.-]+/g, '_') || 'study';
2589
+ return base.replace(/[^\w.-]+/g, '_') || 'case';
1801
2590
  }
1802
2591
  setDemandRangeMode = (mode) => {
1803
2592
  this.app.demandRangeMode = mode;
@@ -1812,11 +2601,13 @@ export class Controller {
1812
2601
  };
1813
2602
  // First-order engine preview for the live drag. Uses the case's already-built
1814
2603
  // Study (no re-parse, no re-solve) to paint predicted per-bus
1815
- // ΔLMP and the predicted Δobjective. A no-op when the Study isn't built yet or
2604
+ // nodal value changes and the predicted objective change. A no-op when the Study isn't built yet or
1816
2605
  // can't preview (browser fallback path, server-only cases): the map then
1817
2606
  // falls back to the JS sensitivity-times-step preview.
1818
2607
  runPreview = (c, bus, value) => {
1819
- // Fast path: the committed ∂LMP/∂d column (already solved at the committed point),
2608
+ if (c.formulation === 'acpf')
2609
+ return;
2610
+ // Fast path: the committed price/demand column (already solved at the committed point),
1820
2611
  // scaled by the demand step, is the same first-order linearization the engine preview
1821
2612
  // returns — without rebuilding the differentiable KKT every drag frame. That engine
1822
2613
  // preview is ~80 ms/frame on the largest case (CATS, ~8870 buses), which blocked the
@@ -1828,12 +2619,21 @@ export class Controller {
1828
2619
  const delta = new Map();
1829
2620
  for (const v of col.values)
1830
2621
  delta.set(v.bus, v.value * step);
1831
- this.app.previewLmp = { caseId: c.id, target: { bus }, delta };
1832
- const lmpAtBus = c.solution?.lmp.find((l) => l.bus === bus)?.usd_per_mwh ?? null;
2622
+ this.app.previewPrices = {
2623
+ caseId: c.id,
2624
+ target: { bus },
2625
+ delta,
2626
+ units: previewUnits(col.units)
2627
+ };
2628
+ const priceAtBus = c.solution?.prices.find((price) => price.bus === bus)?.value ?? null;
1833
2629
  this.previewObjective =
1834
- lmpAtBus === null
2630
+ priceAtBus === null
1835
2631
  ? null
1836
- : { caseId: c.id, target: { bus }, objectiveDelta: lmpAtBus * step };
2632
+ : {
2633
+ caseId: c.id,
2634
+ target: { bus },
2635
+ objectiveDelta: priceAtBus * step
2636
+ };
1837
2637
  return;
1838
2638
  }
1839
2639
  // Fallback (no committed column yet): the engine's first-order preview, which
@@ -1864,23 +2664,26 @@ export class Controller {
1864
2664
  if (!study)
1865
2665
  continue;
1866
2666
  try {
1867
- const { lmp, objectiveDelta } = await study.preview(this.previewDeltas(c, bus, value), this.caseRatings(c));
2667
+ const { prices, objectiveDelta, units } = await study.preview(this.previewDeltas(c, bus, value), this.caseRatings(c));
1868
2668
  // Apply only while this point is still the live drag target: a
1869
2669
  // preview that resolves after a re-selection or a commit is stale.
1870
- if (!this.app.previewActive ||
1871
- this.activeSolvable !== c ||
1872
- this.app.selectedBus !== bus)
2670
+ if (!this.app.previewActive || this.activeSolvable !== c || this.app.selectedBus !== bus)
1873
2671
  continue;
1874
2672
  const delta = new Map();
1875
- for (const e of lmp)
1876
- delta.set(e.bus, e.usd_per_mwh);
1877
- this.app.previewLmp = { caseId: c.id, target: { bus }, delta };
2673
+ for (const e of prices)
2674
+ delta.set(e.bus, e.value);
2675
+ this.app.previewPrices = {
2676
+ caseId: c.id,
2677
+ target: { bus },
2678
+ delta,
2679
+ units: units?.replaceAll('objective_unit', 'objective units') ?? 'objective units/MW'
2680
+ };
1878
2681
  this.previewObjective =
1879
2682
  objectiveDelta === null ? null : { caseId: c.id, target: { bus }, objectiveDelta };
1880
2683
  }
1881
2684
  catch {
1882
2685
  if (this.activeSolvable === c && this.app.selectedBus === bus) {
1883
- this.app.previewLmp = null;
2686
+ this.app.previewPrices = null;
1884
2687
  this.previewObjective = null;
1885
2688
  }
1886
2689
  }
@@ -1891,7 +2694,7 @@ export class Controller {
1891
2694
  }
1892
2695
  }
1893
2696
  setSliderPreview = (value) => {
1894
- if (value === undefined)
2697
+ if (value === undefined || this.sliderDisabled)
1895
2698
  return;
1896
2699
  this.app.previewActive = true;
1897
2700
  this.app.previewDeltaMw = value;
@@ -1911,12 +2714,14 @@ export class Controller {
1911
2714
  return ratings;
1912
2715
  }
1913
2716
  // First-order engine preview for the live rating drag, mirroring runPreview: the
1914
- // committed ∂LMP/∂rating column scaled by the rating step paints predicted per-bus
1915
- // ΔLMP without touching the engine per frame. The predicted Δobjective comes from
2717
+ // committed price/rating column scaled by the rating step paints predicted per-bus
2718
+ // nodal value changes without touching the engine per frame. The predicted objective change comes from
1916
2719
  // a per-MW slope taken once from Study.preview at a +1 MW step off the committed
1917
2720
  // point (preview is replacement-absolute, so the absolute ratings map is built),
1918
2721
  // then scaled by the live step; null when no Study slope is available.
1919
2722
  runRatingPreview = (c, branch, value) => {
2723
+ if (c.formulation === 'acpf')
2724
+ return;
1920
2725
  const committedAtBranch = this.caseRatings(c)[branch] ?? 0;
1921
2726
  const step = (Math.abs(value) < 0.25 ? 0 : value) - committedAtBranch;
1922
2727
  const col = c.sensitivity;
@@ -1924,7 +2729,12 @@ export class Controller {
1924
2729
  const delta = new Map();
1925
2730
  for (const v of col.values)
1926
2731
  delta.set(v.bus, v.value * step);
1927
- this.app.previewLmp = { caseId: c.id, target: { branch }, delta };
2732
+ this.app.previewPrices = {
2733
+ caseId: c.id,
2734
+ target: { branch },
2735
+ delta,
2736
+ units: previewUnits(col.units)
2737
+ };
1928
2738
  }
1929
2739
  let cached = this.ratingSlope;
1930
2740
  if (cached?.caseId !== c.id || cached.branch !== branch) {
@@ -1996,7 +2806,7 @@ export class Controller {
1996
2806
  commitRating = async (value) => {
1997
2807
  const c = this.activeSolvable;
1998
2808
  const branch = this.app.selectedBranch;
1999
- if (!c || branch === null)
2809
+ if (!c || branch === null || isDisplayOnlyElement(this.selectedBranchData))
2000
2810
  return;
2001
2811
  // Refresh the engine preview at the commit value (a typed value may not have
2002
2812
  // driven a drag), then score the commit with the engine's predicted Δobjective.
@@ -2009,6 +2819,7 @@ export class Controller {
2009
2819
  }
2010
2820
  c.predictedObjective = this.predictedDeltaObj;
2011
2821
  c.ratings = this.previewRatings(c, branch, value);
2822
+ this.bumpRevision(c);
2012
2823
  // The slope was taken at the old committed point; the next drag re-derives it.
2013
2824
  this.ratingSlope = null;
2014
2825
  this.app.previewRatingMw = value;