@tellegen/svelte 0.2.0 → 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 (66) hide show
  1. package/README.md +36 -5
  2. package/dist/TellegenMap.svelte +325 -163
  3. package/dist/TellegenProvider.svelte +4 -0
  4. package/dist/TellegenShell.svelte +19 -13
  5. package/dist/api.d.ts +8 -1
  6. package/dist/api.js +6 -2
  7. package/dist/colors.d.ts +7 -7
  8. package/dist/colors.js +13 -13
  9. package/dist/components/AppHeader.svelte +23 -11
  10. package/dist/components/BindingLines.svelte +3 -6
  11. package/dist/components/BusPicker.svelte +8 -2
  12. package/dist/components/ControlPanel.svelte +106 -343
  13. package/dist/components/DemandSlider.svelte +21 -23
  14. package/dist/components/DiagramCanvas.svelte +486 -0
  15. package/dist/components/DiagramCanvas.svelte.d.ts +13 -0
  16. package/dist/components/DisplayControls.svelte +4 -2
  17. package/dist/components/FormulationSelector.svelte +8 -3
  18. package/dist/components/LocalCaseDetails.svelte +55 -45
  19. package/dist/components/McResults.svelte +365 -0
  20. package/dist/components/McResults.svelte.d.ts +11 -0
  21. package/dist/components/ModelDetails.svelte +230 -0
  22. package/dist/components/ModelDetails.svelte.d.ts +7 -0
  23. package/dist/components/MulticonductorDetails.svelte +227 -78
  24. package/dist/components/NetworkStats.svelte +20 -18
  25. package/dist/components/NotificationCenter.svelte +243 -0
  26. package/dist/components/NotificationCenter.svelte.d.ts +3 -0
  27. package/dist/components/PanelCard.svelte +319 -0
  28. package/dist/components/PanelCard.svelte.d.ts +9 -0
  29. package/dist/components/PanelFrame.svelte +46 -0
  30. package/dist/components/PanelFrame.svelte.d.ts +16 -0
  31. package/dist/components/PanelHost.svelte +212 -0
  32. package/dist/components/PanelHost.svelte.d.ts +3 -0
  33. package/dist/components/PowerFlowDetails.svelte +99 -0
  34. package/dist/components/PowerFlowDetails.svelte.d.ts +3 -0
  35. package/dist/components/RatingSlider.svelte +5 -9
  36. package/dist/components/SavedNetworkDetails.svelte +214 -0
  37. package/dist/components/SavedNetworkDetails.svelte.d.ts +3 -0
  38. package/dist/components/SensitivityReadout.svelte +24 -16
  39. package/dist/components/SolveCard.svelte +46 -49
  40. package/dist/components/TopMovers.svelte +8 -3
  41. package/dist/components/TopMovers.svelte.d.ts +2 -17
  42. package/dist/components.d.ts +2 -0
  43. package/dist/components.js +2 -0
  44. package/dist/controller.svelte.d.ts +54 -33
  45. package/dist/controller.svelte.js +927 -233
  46. package/dist/diagram-labels.d.ts +9 -0
  47. package/dist/diagram-labels.js +40 -0
  48. package/dist/display.d.ts +3 -7
  49. package/dist/display.js +31 -27
  50. package/dist/format.js +5 -4
  51. package/dist/index.d.ts +10 -4
  52. package/dist/index.js +7 -2
  53. package/dist/map.d.ts +1 -1
  54. package/dist/map.js +1 -1
  55. package/dist/multiconductor.d.ts +11 -2
  56. package/dist/multiconductor.js +107 -14
  57. package/dist/notices.svelte.d.ts +29 -0
  58. package/dist/notices.svelte.js +82 -0
  59. package/dist/panels.svelte.d.ts +65 -0
  60. package/dist/panels.svelte.js +215 -0
  61. package/dist/state.svelte.d.ts +90 -23
  62. package/dist/state.svelte.js +94 -20
  63. package/dist/synthetic-layout.js +1 -0
  64. package/package.json +22 -9
  65. package/dist/study-package.d.ts +0 -8
  66. package/dist/study-package.js +0 -17
@@ -3,11 +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
6
  import { distExtensionFormat, isGeoFileName } from './drop-classify.js';
8
7
  import { DropBatchGate, readDropFileBytes, validateDropBatch } from './drop-limits.js';
9
- import { buildGeographicView, placeMultiView } from './multiconductor.js';
10
- import { applyDisplayGeo, applyGeo, applyLayout, createStudy, exportStudy, extractGeo, FORMULATIONS, formatOf, ingestCase, ingestDistCaseBytes, ingestJsonDrop, isDisplayFile, isPermanentEngineFailure, parseDisplay, parseGeo } from '@tellegen/engine';
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';
11
10
  import { errorText, extent, formulationLabel, rgbaCss } from './format.js';
12
11
  const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
13
12
  // Open on South Carolina by default: it has the most interesting price action.
@@ -22,6 +21,11 @@ const COMPUTE_OFF_NOTICE = 'solving in this browser did not complete, and server
22
21
  * starts anyway: a missing map or a cue that never appears must not stall the
23
22
  * selection. */
24
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
+ }
25
29
  /** A lowered analysis row is view-only only when a current producer says so;
26
30
  * absence keeps payloads from older compatible engine/server versions editable. */
27
31
  export function isDisplayOnlyElement(element) {
@@ -61,6 +65,7 @@ function downloadText(text, filename, mime) {
61
65
  export class Controller {
62
66
  app;
63
67
  api;
68
+ mcTransport;
64
69
  abort = null;
65
70
  // While set (epoch ms), the server sensitivity fallback is rate limited: skip
66
71
  // the request and show the rate-limit copy instead of burning the budget on a
@@ -70,11 +75,12 @@ export class Controller {
70
75
  // Latched once any server fallback answers 403: compute is disabled on this
71
76
  // deploy, so later fallbacks show the notice instead of firing doomed requests.
72
77
  serverComputeOff = false;
73
- // 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
74
79
  // when the Study is created, so a drag re-solves (commit) and previews without
75
80
  // re-parsing. Kept in a WeakMap off the reactive/raw case payloads — the wasm
76
81
  // handle is neither serialized nor part of any $state.
77
82
  caseStudies = new WeakMap();
83
+ detachedCaseCaptures = new WeakMap();
78
84
  // Latch a permanent sensitivity-module failure per case so we don't retry
79
85
  // createStudy — and the same permanent error — on every drag. Transient
80
86
  // failures are not latched.
@@ -113,6 +119,7 @@ export class Controller {
113
119
  constructor(app, options = {}) {
114
120
  this.app = app;
115
121
  this.api = options.api ?? createApiClient({ apiBase: options.apiBase });
122
+ this.mcTransport = options.mcTransport ?? browserWasmTransport;
116
123
  }
117
124
  // ===== helpers =====
118
125
  isBackendCase(c) {
@@ -140,6 +147,9 @@ export class Controller {
140
147
  isPerturbed(c) {
141
148
  return c?.perturbed ?? false;
142
149
  }
150
+ bumpRevision(c) {
151
+ c.revisionGeneration += 1;
152
+ }
143
153
  setNearbyRangeAnchor(c, bus, delta = this.caseDeltas(c)[bus] ?? 0) {
144
154
  this.nearbyRangeAnchor = { caseId: c.id, bus, delta };
145
155
  }
@@ -149,6 +159,7 @@ export class Controller {
149
159
  // the sensitivity seq the caller guards its async writes with.
150
160
  beginBusSelection(c, busId) {
151
161
  this.abort?.abort();
162
+ this.bumpRevision(c);
152
163
  const ac = new AbortController();
153
164
  this.abort = ac;
154
165
  const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
@@ -170,6 +181,7 @@ export class Controller {
170
181
  // but the selection lands on a branch and the demand preview fields are cleared.
171
182
  beginBranchSelection(c, branchId) {
172
183
  this.abort?.abort();
184
+ this.bumpRevision(c);
173
185
  const ac = new AbortController();
174
186
  this.abort = ac;
175
187
  const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
@@ -219,45 +231,50 @@ export class Controller {
219
231
  this.hiddenDefaults = new Set(hidden);
220
232
  }
221
233
  localNetwork(c) {
222
- if (!c.summary || !c.view)
234
+ const view = c.displayMode === 'diagram' ? c.diagram?.view : c.view;
235
+ if (!c.summary || !view)
223
236
  return null;
224
237
  return {
225
238
  id: c.id,
226
239
  name: c.label,
227
240
  base_mva: c.summary.base_mva,
228
241
  synthetic_coords: c.coordsKind !== 'file' && c.coordsKind !== 'geofile',
229
- buses: c.view.buses,
230
- 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
231
246
  };
232
247
  }
233
248
  maybeStartLocalSolve(id) {
234
249
  const c = this.app.localCases.find((lc) => lc.id === id);
235
- if (!c?.networkJson || !c.view || !c.summary)
250
+ if (!c?.studyInputJson || (!c.view && !c.diagram) || !c.summary)
236
251
  return;
237
252
  c.network = this.localNetwork(c) ?? c.network ?? null;
238
- if (c.networkJson && c.network && !c.solution)
253
+ if (c.network && !c.solution)
239
254
  this.runSolve(c, null);
240
255
  }
241
- // 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
242
257
  // (after free) if either changed — so picking a new formulation re-parses and re-solves
243
258
  // under that formulation. Returns null when the sens module can't load; the caller then
244
259
  // falls back to the server, surfacing solveFallbackReason.
245
- async getStudy(c, networkJson) {
260
+ async getStudy(c, studyInputJson) {
246
261
  const latched = this.studyUnavailable.get(c);
247
262
  if (latched) {
248
263
  c.solveFallbackReason ??= latched;
249
264
  return null;
250
265
  }
251
266
  const cached = this.caseStudies.get(c);
252
- if (cached && cached.networkJson === networkJson && cached.formulation === c.formulation)
267
+ if (cached && cached.studyInputJson === studyInputJson && cached.formulation === c.formulation)
253
268
  return cached.study;
254
- // Coalesce concurrent builds for the same (case, networkJson, formulation): a
269
+ // Coalesce concurrent builds for the same (case, module, formulation): a
255
270
  // bus-select sensitivity fetch and a solve can both reach here before either
256
271
  // createStudy resolves. Without this each builds its own wasm Study and the
257
272
  // later set() orphans the earlier one (a leaked handle), and the cache can end
258
273
  // up holding an instance the live solve never committed on.
259
274
  const inflight = this.studyBuilds.get(c);
260
- if (inflight && inflight.networkJson === networkJson && inflight.formulation === c.formulation)
275
+ if (inflight &&
276
+ inflight.studyInputJson === studyInputJson &&
277
+ inflight.formulation === c.formulation)
261
278
  return inflight.promise;
262
279
  // Different params, or no build pending: drop any stale Study before rebuilding.
263
280
  if (cached) {
@@ -270,7 +287,7 @@ export class Controller {
270
287
  const token = {};
271
288
  const build = (async () => {
272
289
  try {
273
- const study = await createStudy(networkJson, formulation);
290
+ const study = await createStudy(studyInputJson, formulation);
274
291
  const baseSolution = await study.currentSolution();
275
292
  // The case may have switched formulation or been disposed (removed, or its
276
293
  // list reloaded) while building; don't cache a now-unwanted Study, and free
@@ -281,7 +298,7 @@ export class Controller {
281
298
  }
282
299
  this.caseStudies.set(c, {
283
300
  study,
284
- networkJson,
301
+ studyInputJson,
285
302
  formulation,
286
303
  baseSolution
287
304
  });
@@ -303,21 +320,21 @@ export class Controller {
303
320
  }
304
321
  })();
305
322
  this.studyBuilds.set(c, {
306
- networkJson,
323
+ studyInputJson,
307
324
  formulation,
308
325
  token,
309
326
  promise: build
310
327
  });
311
328
  return build;
312
329
  }
313
- // 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
314
331
  // the browser. Every column, bus or branch, any formulation, comes from the Study's
315
332
  // exact re-solve at the case's operating point. The column is null (with the reason on
316
333
  // `solveFallbackReason`) when the Study can't be built; the selection paths then
317
334
  // reconcile via the server where a DC endpoint exists. Throws only on a hard browser
318
335
  // error.
319
- async browserSensitivity(c, networkJson, target) {
320
- const study = await this.getStudy(c, networkJson);
336
+ async browserSensitivity(c, studyInputJson, target) {
337
+ const study = await this.getStudy(c, studyInputJson);
321
338
  return study ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target) : null;
322
339
  }
323
340
  // Release a case's Study (if any) when the case is removed.
@@ -360,21 +377,21 @@ export class Controller {
360
377
  this.app.previewDeltaMw = null;
361
378
  this.app.previewRatingMw = null;
362
379
  // The committed solution supersedes the live engine preview.
363
- this.app.previewLmp = null;
380
+ this.app.previewPrices = null;
364
381
  this.previewObjective = null;
365
382
  }
366
383
  }
367
- // Fetch and cache the raw powerio Network JSON for the browser solver.
368
- // Returns null when it can't be loaded, so callers fall back to the server.
369
- async ensureNetworkJson(c) {
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;
370
389
  if (!this.isBackendCase(c))
371
- return c.networkJson ?? null;
372
- if (c.networkJson)
373
- return c.networkJson;
390
+ return null;
374
391
  try {
375
- const json = await this.api.getCaseNetworkJson(c.id);
376
- c.networkJson = json;
377
- return json;
392
+ const moduleJson = await this.api.getCaseModuleJson(c.id);
393
+ c.studyInputJson = moduleJson;
394
+ return moduleJson;
378
395
  }
379
396
  catch (e) {
380
397
  c.solveFallbackReason = `case fetch failed: ${errorText(e)}`;
@@ -424,8 +441,12 @@ export class Controller {
424
441
  buses: c.network.buses.length,
425
442
  branches: c.network.branches.length,
426
443
  objective: c.solution?.objective ?? null,
427
- deltaObjective: c.solution && c.baseSolution ? c.solution.objective - c.baseSolution.objective : null,
428
- binding: c.solution ? c.solution.flows.filter((f) => f.loading >= 0.999).length : null
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
429
450
  };
430
451
  });
431
452
  displayOptions = $derived(displayMetaFor(this.activeSolvable));
@@ -437,7 +458,7 @@ export class Controller {
437
458
  return null;
438
459
  // Drive the values off the resolved mode, not app.displayMode, so the
439
460
  // displayOptions[0] fallback holds while displayMode is stale relative to the
440
- // formulation (e.g. leaving SOCWR before +page resets it to 'lmp').
461
+ // formulation (e.g. leaving SOCWR before +page resets it to price).
441
462
  return {
442
463
  ...activeMeta,
443
464
  values: displaySeriesFor(this.activeSolvable, activeMeta.mode)
@@ -451,7 +472,7 @@ export class Controller {
451
472
  return null;
452
473
  const domain = scalarDomain(this.activeDisplay.mode, values);
453
474
  const { min, max } = extent(values);
454
- const flatThreshold = this.activeDisplay.mode === 'lmp' ? 1 : 1e-5;
475
+ const flatThreshold = this.activeDisplay.mode === 'price' ? 1 : 1e-5;
455
476
  return {
456
477
  lo: { value: domain.lo, clamped: min < domain.lo - flatThreshold / 20 },
457
478
  hi: { value: domain.hi, clamped: max > domain.hi + flatThreshold / 20 },
@@ -470,8 +491,8 @@ export class Controller {
470
491
  return null;
471
492
  return c.network.branches.find((b) => b.id === this.app.selectedBranch) ?? null;
472
493
  });
473
- // The sensitivity target of the current selection: the selected bus (∂LMP/∂d),
474
- // else the selected branch (∂LMP/∂rating), else null. Selections are mutually
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
475
496
  // exclusive, so bus-first ordering is only a tiebreak for impossible states.
476
497
  selectionTarget = $derived.by(() => this.app.selectedBus !== null
477
498
  ? { bus: this.app.selectedBus }
@@ -545,9 +566,9 @@ export class Controller {
545
566
  const c = this.activeSolvable;
546
567
  if (!c?.solution || this.app.selectedBus === null)
547
568
  return null;
548
- 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;
549
570
  });
550
- // 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
551
572
  // first-order fallback uses for a second-order objective estimate. Zero when no
552
573
  // sensitivity column is loaded for the bus.
553
574
  selfSens = $derived.by(() => {
@@ -559,13 +580,13 @@ export class Controller {
559
580
  // engine preview (Study.preview at the committed point, plus the committed part);
560
581
  // fall back to a second-order gradient estimate when no Study preview is available
561
582
  // (server-only cases, or a browser that can't load the sensitivity module): the
562
- // exact committed part plus lmp·step + S_bb·step²/2 along the gradient. The
563
- // gradient fallback is demand-only — a rating step has no LMP-at-bus analogue —
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 —
564
585
  // so a branch selection without an engine slope shows no prediction.
565
586
  predictedDeltaObj = $derived.by(() => {
566
587
  const c = this.activeSolvable;
567
588
  const target = this.selectionTarget;
568
- if (!c?.solution || !c.baseSolution || target === null)
589
+ if (c?.solution?.objective == null || c.baseSolution?.objective == null || target === null)
569
590
  return null;
570
591
  const committedPart = c.solution.objective - c.baseSolution.objective;
571
592
  if (this.previewObjective &&
@@ -580,7 +601,10 @@ export class Controller {
580
601
  });
581
602
  gradientScore = $derived.by(() => {
582
603
  const c = this.activeSolvable;
583
- 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)
584
608
  return null;
585
609
  const exact = c.solution.objective - c.baseSolution.objective;
586
610
  return { pred: c.predictedObjective, exact };
@@ -634,7 +658,9 @@ export class Controller {
634
658
  this.app.activeLocalId = null;
635
659
  this.app.placingLocalId = null;
636
660
  this.app.activeCaseId =
637
- 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;
638
664
  const active = this.app.active;
639
665
  if (active)
640
666
  await this.loadBackendCase(active, true);
@@ -654,6 +680,10 @@ export class Controller {
654
680
  return this.loading;
655
681
  };
656
682
  loadBackendCase = async (c, frame = false) => {
683
+ if (c.unavailableReason) {
684
+ this.fail(`${c.name}: ${c.unavailableReason}`);
685
+ return;
686
+ }
657
687
  if (c.network && c.solution && c.baseSolution) {
658
688
  if (frame && this.app.activeCaseId === c.id)
659
689
  this.app.requestFrame(c.id);
@@ -665,7 +695,7 @@ export class Controller {
665
695
  const [network, dcBaseSolution] = await Promise.all([
666
696
  c.network ? Promise.resolve(c.network) : this.api.getNetwork(c.id),
667
697
  // The server caches only the DC OPF base solution. Other formulations
668
- // 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
669
699
  // voltage fields all come from the selected formulation.
670
700
  requestedFormulation === 'dcopf' ? this.api.getSolution(c.id) : Promise.resolve(null)
671
701
  ]);
@@ -700,10 +730,19 @@ export class Controller {
700
730
  this.app.placingMultiId = null;
701
731
  }
702
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;
703
742
  this.app.activeLocalId = null;
704
743
  this.app.placingLocalId = null;
705
744
  this.leaveMulti();
706
- if (this.app.activeCaseId !== id) {
745
+ if (inspectedSavedState || this.app.activeCaseId !== id) {
707
746
  this.clearSelection();
708
747
  this.app.activeCaseId = id;
709
748
  }
@@ -739,6 +778,7 @@ export class Controller {
739
778
  await this.hydrateFallback(this.app.removeCase(c.id));
740
779
  };
741
780
  activateLocal = (c) => {
781
+ this.app.studyView = null;
742
782
  this.clearSelection();
743
783
  // Mirror activateCase's reset: a local and a backend case are mutually
744
784
  // exclusive, so drop the backend selection. Otherwise app.active (derived
@@ -747,8 +787,10 @@ export class Controller {
747
787
  this.app.activeCaseId = null;
748
788
  this.leaveMulti();
749
789
  this.app.activeLocalId = c.id;
750
- this.app.placingLocalId = c.coordsKind === 'synthetic_pending' ? c.id : null;
751
- 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)
752
794
  this.app.requestFrame(c.id);
753
795
  this.maybeStartLocalSolve(c.id);
754
796
  };
@@ -764,11 +806,16 @@ export class Controller {
764
806
  await this.hydrateFallback(this.app.removeLocal(c.id));
765
807
  };
766
808
  addAndActivateLocal = (c) => {
809
+ this.app.studyView = null;
767
810
  this.clearSelection();
768
811
  this.app.activeCaseId = null;
769
812
  this.leaveMulti();
770
813
  this.app.addLocal(c);
771
- 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)
772
819
  this.app.requestFrame(c.id);
773
820
  this.maybeStartLocalSolve(c.id);
774
821
  };
@@ -790,18 +837,18 @@ export class Controller {
790
837
  };
791
838
  /** Write the case's on-screen layout into its network payload
792
839
  * (`Bus.location` with `synthetic` provenance — the engine also accepts
793
- * `manual`, which round trips through restored packages), so saves,
840
+ * `manual`, which round trips through saved PowerIO modules), so saves,
794
841
  * exports, and the `.geo.json` download carry it. Best effort: on an
795
842
  * engine failure the view still renders, only the layout's persistence is
796
843
  * lost. */
797
844
  stampLayout = async (c) => {
798
- if (!c.networkJson || !c.view)
845
+ if (!c.studyInputJson || !c.view)
799
846
  return;
800
847
  try {
801
848
  const coords = Object.fromEntries(c.view.buses.map((b) => [b.id, [b.lon, b.lat]]));
802
- const stamped = await applyLayout(c.networkJson, coords, 'synthetic');
803
- c.networkJson = stamped.network_json;
804
- await this.syncCachedStudy(c, stamped.network_json, [stamped.layer]);
849
+ const stamped = await applyLayout(c.studyInputJson, coords, 'synthetic');
850
+ c.studyInputJson = stamped.module_json;
851
+ await this.syncStudyGeo(c, [stamped.layer]);
805
852
  }
806
853
  catch {
807
854
  // The on-screen view is unaffected; only persistence was lost.
@@ -811,10 +858,12 @@ export class Controller {
811
858
  * GeoJSON with provenance stamped (`synthetic`/`manual` for layouts), the
812
859
  * document powerio and tellegen both read back. */
813
860
  downloadGeoLayer = async (c) => {
814
- if (!c.networkJson)
861
+ if (!c.studyInputJson)
815
862
  return;
816
863
  try {
817
- 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');
818
867
  this.app.error = null;
819
868
  }
820
869
  catch (e) {
@@ -830,23 +879,46 @@ export class Controller {
830
879
  * wins on shared elements). The engine owns matching and the network becomes
831
880
  * the single owner of the coordinates: matched points land in
832
881
  * `Bus.location`, routes in `Branch.route`, and the refreshed
833
- * `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
834
883
  * carries exactly what is on screen. A live Study is synced the same way.
835
884
  * Throws when a layer matches nothing. */
836
885
  applyGeoLayers = async (c, layers) => {
837
- if (!c.networkJson || layers.length === 0)
886
+ if (!c.studyInputJson || layers.length === 0)
838
887
  return;
839
888
  let payload = null;
840
- 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;
841
913
  const warnings = [];
842
914
  let routes = 0;
843
915
  let unmatched = 0;
844
916
  for (const l of layers) {
845
- payload = await applyGeo(networkJson, l.layer);
846
- networkJson = payload.network_json;
917
+ payload = await applyGeo(moduleJson, l.layer);
918
+ moduleJson = payload.module_json;
847
919
  routes += payload.report.matched_branches;
848
920
  unmatched += payload.report.unmatched_features;
849
- 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}`));
850
922
  }
851
923
  if (!payload)
852
924
  return;
@@ -856,15 +928,16 @@ export class Controller {
856
928
  ...(unmatched > 0 ? [`${unmatched} feature(s) matched no case element`] : []),
857
929
  ...warnings
858
930
  ]);
859
- await this.syncCachedStudy(c, networkJson, layers.map((l) => l.layer));
931
+ await this.syncStudyGeo(c, layers.map((l) => l.layer));
860
932
  };
861
933
  /** Adopt an engine geo apply onto the case: the refreshed payload becomes
862
934
  * the case's network and view, provenance flips to the geographic source,
863
935
  * and the warnings panel gets the placement summary. */
864
936
  adoptGeoPayload(c, payload, sourceLabel, placedFrom, extraWarnings) {
865
- c.networkJson = payload.network_json;
937
+ c.studyInputJson = payload.module_json;
866
938
  c.view = payload.view;
867
939
  c.coordsKind = 'geofile';
940
+ c.displayMode = payload.view?.coordinate_space === 'diagram' ? 'diagram' : 'geographic';
868
941
  c.syntheticCenter = undefined;
869
942
  c.geoSource = sourceLabel;
870
943
  const placedCanonicalBuses = payload.view?.buses.filter((bus) => bus.editable !== false).length ?? 0;
@@ -874,39 +947,239 @@ export class Controller {
874
947
  ...payload.warnings
875
948
  ];
876
949
  }
877
- /** Keep a live Study's base network in step with a geo change (locations
878
- * are metadata the model never reads, so nothing re-solves), and re-key its
879
- * cache entry so the next solve reuses it instead of rebuilding. */
880
- syncCachedStudy = async (c, networkJson, layers) => {
950
+ /** Update an existing numerical Study after the base input receives geographic data. */
951
+ syncStudyGeo = async (c, layers) => {
881
952
  const cached = this.caseStudies.get(c);
882
953
  if (!cached)
883
954
  return;
884
- cached.networkJson = networkJson;
885
- for (const layer of layers)
886
- 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
+ }
887
968
  };
888
- applyGeoLayersToExisting = async (layers) => {
889
- const target = (this.app.activeLocal?.networkJson ? this.app.activeLocal : null) ??
890
- this.app.localCases.find((c) => c.coordsKind === 'synthetic_pending' && c.networkJson) ??
891
- [...this.app.localCases].reverse().find((c) => c.networkJson);
892
- if (!target?.networkJson) {
893
- this.app.error =
894
- 'drop a case file with the geographic file, or select a parsed local case first';
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';
1078
+ return;
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';
895
1083
  return;
896
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;
897
1103
  try {
898
- 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();
899
1163
  this.app.activeCaseId = null;
900
1164
  this.leaveMulti();
1165
+ if (target !== source)
1166
+ this.app.addLocal(target);
901
1167
  this.app.activeLocalId = target.id;
902
1168
  this.app.placingLocalId = null;
1169
+ target.network = this.localNetwork(target);
1170
+ this.bumpRevision(target);
903
1171
  this.app.requestFrame(target.id);
904
1172
  this.maybeStartLocalSolve(target.id);
905
- this.app.error = null;
1173
+ this.app.error = placement.error;
906
1174
  }
907
- catch (e) {
908
- 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);
909
1179
  }
1180
+ }
1181
+ applyGeoLayersToExisting = async (layers) => {
1182
+ await this.attachSidecarsToSelected(layers);
910
1183
  };
911
1184
  /** Ask the map to frame the selected branch and wait for the camera to land,
912
1185
  * capped by FOCUS_SETTLE_CAP_MS and cut short by the selection's abort. */
@@ -946,13 +1219,15 @@ export class Controller {
946
1219
  if (ac.signal.aborted)
947
1220
  return;
948
1221
  }
1222
+ if (c.formulation === 'acpf')
1223
+ return;
949
1224
  try {
950
1225
  // The column from the browser Study (under the case's formulation). DC OPF
951
1226
  // may reconcile a null column via the server; AC OPF / SOCWR are browser
952
1227
  // only, so a null column there is terminal.
953
- const networkJson = await this.ensureNetworkJson(c);
954
- if (networkJson) {
955
- const sensitivity = await this.browserSensitivity(c, networkJson, target);
1228
+ const studyInputJson = await this.ensureStudyInputJson(c);
1229
+ if (studyInputJson) {
1230
+ const sensitivity = await this.browserSensitivity(c, studyInputJson, target);
956
1231
  if (!ac.signal.aborted && sensitivity) {
957
1232
  this.acceptSensitivity(c, sensitivity, target, sensitivitySeq);
958
1233
  handled = true;
@@ -1005,7 +1280,7 @@ export class Controller {
1005
1280
  computeOffNotice(c) {
1006
1281
  return this.studyUnavailable.has(c) ? WASM_REQUIRED_NOTICE : COMPUTE_OFF_NOTICE;
1007
1282
  }
1008
- /** The one server ∂LMP/∂parameter request a selection may make: the
1283
+ /** The one server nodal-value/parameter request a selection may make: the
1009
1284
  * reconciliation path when the browser Study produced no column for a DC OPF
1010
1285
  * case. Never retries on its own: a second request inside the same rate limit
1011
1286
  * window is a guaranteed rejection, and the retry button covers the user
@@ -1071,7 +1346,7 @@ export class Controller {
1071
1346
  };
1072
1347
  selectLocalBus = async (localId, busId) => {
1073
1348
  const c = this.app.localCases.find((lc) => lc.id === localId);
1074
- if (!c?.networkJson || !c.network)
1349
+ if (!c?.studyInputJson || !c.network)
1075
1350
  return;
1076
1351
  this.app.activeCaseId = null;
1077
1352
  this.leaveMulti();
@@ -1079,16 +1354,20 @@ export class Controller {
1079
1354
  this.app.placingLocalId = null;
1080
1355
  const { ac, sensitivitySeq } = this.beginBusSelection(c, busId);
1081
1356
  try {
1082
- const sensitivity = await this.browserSensitivity(c, c.networkJson, {
1357
+ if (c.formulation === 'acpf')
1358
+ return;
1359
+ const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1083
1360
  bus: busId
1084
1361
  });
1085
1362
  if (!ac.signal.aborted)
1086
1363
  this.acceptSensitivity(c, sensitivity, { bus: busId }, sensitivitySeq);
1087
1364
  if (!ac.signal.aborted && !sensitivity) {
1088
- // 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
1089
1366
  // Study could not be built); local cases have no server fallback, so say so
1090
- // instead of leaving the panel in LMP view with no explanation.
1091
- this.app.error = `${c.label}: ${formulationLabel(c.formulation)} sensitivity unavailable in the browser${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ' (no dLMP/dd column for this bus)'}`;
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)'}`;
1092
1371
  }
1093
1372
  }
1094
1373
  catch (e) {
@@ -1112,7 +1391,7 @@ export class Controller {
1112
1391
  failColumnUnavailable(c, target, retry) {
1113
1392
  const what = 'bus' in target
1114
1393
  ? `${formulationLabel(c.formulation)} sensitivity`
1115
- : '∂LMP/∂rating sensitivity';
1394
+ : 'price/rating sensitivity';
1116
1395
  this.fail(`${this.caseName(c)}: ${what} unavailable${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`, retry);
1117
1396
  }
1118
1397
  /** `focus` frames the selected line on the map first; the sensitivity solve
@@ -1120,7 +1399,7 @@ export class Controller {
1120
1399
  selectBranch = (caseId, branchId, opts = {}) => this.selectTarget(caseId, { branch: branchId }, opts.focus ?? false);
1121
1400
  selectLocalBranch = async (localId, branchId, opts = {}) => {
1122
1401
  const c = this.app.localCases.find((lc) => lc.id === localId);
1123
- if (!c?.networkJson || !c.network)
1402
+ if (!c?.studyInputJson || !c.network)
1124
1403
  return;
1125
1404
  this.app.activeCaseId = null;
1126
1405
  this.leaveMulti();
@@ -1134,7 +1413,9 @@ export class Controller {
1134
1413
  if (ac.signal.aborted)
1135
1414
  return;
1136
1415
  }
1137
- const sensitivity = await this.browserSensitivity(c, c.networkJson, {
1416
+ if (c.formulation === 'acpf')
1417
+ return;
1418
+ const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
1138
1419
  branch: branchId
1139
1420
  });
1140
1421
  if (ac.signal.aborted)
@@ -1160,11 +1441,13 @@ export class Controller {
1160
1441
  this.abort?.abort();
1161
1442
  const c = this.app.active;
1162
1443
  if (c) {
1444
+ this.bumpRevision(c);
1163
1445
  c.sensitivitySeq++;
1164
1446
  c.sensitivity = null;
1165
1447
  }
1166
1448
  const lc = this.app.activeLocal;
1167
1449
  if (lc) {
1450
+ this.bumpRevision(lc);
1168
1451
  lc.sensitivitySeq = (lc.sensitivitySeq ?? 0) + 1;
1169
1452
  lc.sensitivity = null;
1170
1453
  }
@@ -1180,7 +1463,7 @@ export class Controller {
1180
1463
  this.app.previewDeltaMw = null;
1181
1464
  this.app.previewRatingMw = null;
1182
1465
  this.app.previewActive = false;
1183
- this.app.previewLmp = null;
1466
+ this.app.previewPrices = null;
1184
1467
  this.previewObjective = null;
1185
1468
  this.ratingSlope = null;
1186
1469
  this.app.demandRangeMode = 'local';
@@ -1188,12 +1471,14 @@ export class Controller {
1188
1471
  this.app.sensitivityLoading = false;
1189
1472
  };
1190
1473
  // Exact solve in the browser (wasm). The build-once Study commits the new
1191
- // operating point without re-parsing, returning the ∂LMP/∂parameter column for
1192
- // the selection target in the same solve; on a Study failure or missing network
1193
- // JSON it reconciles via the server stream (backend cases). Rating edits never
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
1194
1477
  // fall back: the server solves at base ratings, so a Study failure there is
1195
1478
  // terminal.
1196
1479
  runSolve = (c, target) => {
1480
+ if (c.formulation === 'acpf')
1481
+ target = null;
1197
1482
  // Cancel this case's own previous server stream, if any (backend only).
1198
1483
  if (this.isBackendCase(c)) {
1199
1484
  c.closeStream?.();
@@ -1207,11 +1492,11 @@ export class Controller {
1207
1492
  c.solveFallbackReason = null;
1208
1493
  c.iterations = [];
1209
1494
  c.solveMs = null;
1210
- this.ensureNetworkJson(c).then(async (networkJson) => {
1495
+ this.ensureStudyInputJson(c).then(async (studyInputJson) => {
1211
1496
  if (seq !== (c.solveSeq ?? 0))
1212
1497
  return;
1213
- if (!networkJson) {
1214
- c.solveFallbackReason ??= 'browser network JSON unavailable';
1498
+ if (!studyInputJson) {
1499
+ c.solveFallbackReason ??= 'PowerIO module unavailable';
1215
1500
  if (this.hasRatingEdits(c)) {
1216
1501
  c.solving = false;
1217
1502
  this.app.error = this.ratingEditsFallbackError(c);
@@ -1220,15 +1505,15 @@ export class Controller {
1220
1505
  if (this.isBackendCase(c))
1221
1506
  return this.serverSolve(c, target, seq);
1222
1507
  c.solving = false;
1223
- 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`;
1224
1509
  return;
1225
1510
  }
1226
1511
  const t0 = performance.now();
1227
1512
  c.solveBackend = 'clarabel-wasm';
1228
1513
  // Build-once Study path: commit the new operating point (no re-parse). The
1229
- // ∂LMP/∂parameter column for the selection target is computed in the same
1514
+ // selected nodal value sensitivity column is computed in the same
1230
1515
  // solve and comes back with it, so there is no second solve to reconcile it.
1231
- const study = await this.getStudy(c, networkJson);
1516
+ const study = await this.getStudy(c, studyInputJson);
1232
1517
  if (seq !== (c.solveSeq ?? 0))
1233
1518
  return;
1234
1519
  if (study) {
@@ -1366,6 +1651,7 @@ export class Controller {
1366
1651
  this.runPreview(c, bus, value);
1367
1652
  c.predictedObjective = this.predictedDeltaObj;
1368
1653
  c.deltas = this.previewDeltas(c, bus, value);
1654
+ this.bumpRevision(c);
1369
1655
  this.app.previewDeltaMw = value;
1370
1656
  this.app.previewActive = true;
1371
1657
  this.runSolve(c, { bus });
@@ -1385,8 +1671,9 @@ export class Controller {
1385
1671
  resetCase = (c) => {
1386
1672
  c.deltas = {};
1387
1673
  c.ratings = {};
1674
+ this.bumpRevision(c);
1388
1675
  c.predictedObjective = null;
1389
- this.app.previewLmp = null;
1676
+ this.app.previewPrices = null;
1390
1677
  this.previewObjective = null;
1391
1678
  this.ratingSlope = null;
1392
1679
  this.app.previewDeltaMw = this.app.selectedBus === null ? null : 0;
@@ -1399,19 +1686,19 @@ export class Controller {
1399
1686
  c.solution = c.baseSolution;
1400
1687
  this.runSolve(c, this.selectionTarget);
1401
1688
  };
1402
- // Switch the active case to a new OPF formulation. Solving every formulation stays
1403
- // entirely in the browser via the Study (nothing is routed to the server), so this
1404
- // disposes the old Study — `getStudy` rebuilds it for the new formulation, re-parsing
1405
- // and re-solving the base — then re-solves at the committed demand. The base solution
1406
- // is dropped so it is recaptured under the new formulation (a DC and an AC objective
1407
- // are not comparable). A no-op when the choice is unchanged.
1689
+ /** Rebuild the numerical Study for a selected calculation at the current demand. */
1408
1690
  changeFormulation = (c, next) => {
1409
1691
  if (c.formulation === next)
1410
1692
  return;
1411
- // 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
+ }
1412
1697
  if (FORMULATIONS.find((f) => f.id === next)?.disabled)
1413
1698
  return;
1414
1699
  c.formulation = next;
1700
+ this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
1701
+ this.bumpRevision(c);
1415
1702
  // The committed point carries over (same demand), but the model and its solution do
1416
1703
  // not; drop the Study and the cached solutions so they rebuild under `next`.
1417
1704
  this.disposeStudy(c);
@@ -1420,7 +1707,7 @@ export class Controller {
1420
1707
  c.iterations = [];
1421
1708
  c.solveMs = null;
1422
1709
  c.predictedObjective = null;
1423
- this.app.previewLmp = null;
1710
+ this.app.previewPrices = null;
1424
1711
  this.previewObjective = null;
1425
1712
  this.ratingSlope = null;
1426
1713
  this.app.error = null;
@@ -1460,10 +1747,8 @@ export class Controller {
1460
1747
  }
1461
1748
  };
1462
1749
  ingestFileBatch = async (list) => {
1750
+ const sidecarTarget = this.app.activeMulti ?? this.app.activeLocal ?? this.app.active;
1463
1751
  let parsedCaseCount = 0;
1464
- // True once a dropped balanced case took the geographic sidecars. When no
1465
- // case parses, the selected existing case may take them at the end.
1466
- let geoLayersConsumed = false;
1467
1752
  // Routable `.json` content (saved packages, model JSON, and transmission or
1468
1753
  // distribution documents) shares the extension with geographic files. Route
1469
1754
  // it up front, but stage recognized payloads until the geographic/display
@@ -1475,9 +1760,7 @@ export class Controller {
1475
1760
  for (const file of list) {
1476
1761
  if (file.name.toLowerCase().endsWith('.json')) {
1477
1762
  const route = await this.routeJsonDrop(file);
1478
- if (route.outcome === 'balanced' ||
1479
- route.outcome === 'restored' ||
1480
- route.outcome === 'multiconductor') {
1763
+ if (route.outcome === 'balanced' || route.outcome === 'multiconductor') {
1481
1764
  parsedCaseCount++;
1482
1765
  routedJson.push({ file, route });
1483
1766
  continue;
@@ -1499,7 +1782,7 @@ export class Controller {
1499
1782
  geoLayers.push({
1500
1783
  name: file.name,
1501
1784
  layer: parsed.layer,
1502
- warnings: parsed.warnings
1785
+ diagnostics: parsed.diagnostics
1503
1786
  });
1504
1787
  this.app.error = null;
1505
1788
  }
@@ -1543,16 +1826,21 @@ export class Controller {
1543
1826
  try {
1544
1827
  for (const { file, route } of routedJson) {
1545
1828
  if (route.outcome === 'multiconductor') {
1546
- this.addMultiCase(file.name, route.payload);
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
+ }
1547
1838
  continue;
1548
1839
  }
1549
- if (route.outcome !== 'balanced' && route.outcome !== 'restored')
1840
+ if (route.outcome !== 'balanced')
1550
1841
  continue;
1551
- const local = route.outcome === 'restored'
1552
- ? this.localFromPackage(file.name, route.payload)
1553
- : this.localFromBalancedPayload(file.name, route.payload);
1842
+ const local = this.localFromBalancedPayload(file.name, route.payload);
1554
1843
  const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
1555
- geoLayersConsumed ||= placement.geoLayersConsumed;
1556
1844
  this.addAndActivateLocal(local);
1557
1845
  this.app.error = placement.error;
1558
1846
  }
@@ -1564,8 +1852,17 @@ export class Controller {
1564
1852
  // OpenDSS routes by extension into the multiconductor viewing path.
1565
1853
  const distFormat = distExtensionFormat(file.name);
1566
1854
  if (distFormat) {
1567
- if (await this.ingestDistFile(file, distFormat))
1855
+ if (await this.ingestDistFile(file, distFormat)) {
1568
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
+ }
1569
1866
  continue;
1570
1867
  }
1571
1868
  const format = formatOf(file.name);
@@ -1576,7 +1873,7 @@ export class Controller {
1576
1873
  this.app.parsingFile = true;
1577
1874
  try {
1578
1875
  const bytes = new Uint8Array(await file.arrayBuffer());
1579
- const { network_json, topology, view, ...summary } = await ingestCase(bytes, format);
1876
+ const { module_json, topology, view, ...summary } = await ingestCase(bytes, format);
1580
1877
  if (format === 'aux' && (summary.n_branch === 0 || summary.n_gen === 0)) {
1581
1878
  this.app.error = `${file.name}: aux parsed, but no complete network; drop the matching .m or .raw case file`;
1582
1879
  continue;
@@ -1590,13 +1887,12 @@ export class Controller {
1590
1887
  label,
1591
1888
  fileName: file.name,
1592
1889
  summary,
1593
- networkJson: network_json,
1890
+ studyInputJson: module_json,
1594
1891
  topology,
1595
1892
  coordsKind: summary.coords_kind,
1596
1893
  view
1597
1894
  });
1598
1895
  const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
1599
- geoLayersConsumed ||= placement.geoLayersConsumed;
1600
1896
  this.addAndActivateLocal(local);
1601
1897
  parsedCaseCount++;
1602
1898
  this.app.error = placement.error; // a successful parse clears a prior file's error
@@ -1608,28 +1904,13 @@ export class Controller {
1608
1904
  this.app.parsingFile = false;
1609
1905
  }
1610
1906
  }
1611
- // A .pwd no case consumed becomes a substation point preview entry, at
1612
- // the approximate positions the engine projected.
1613
- for (const d of displays.filter((entry) => !entry.consumed)) {
1614
- const points = d.preview.substations.map((s) => ({
1615
- number: s.number,
1616
- name: s.name,
1617
- lon: s.lon,
1618
- lat: s.lat
1619
- }));
1620
- this.addAndActivateLocal(new LocalCase({
1621
- id: `local-${++this.localSeq}`,
1622
- label: d.file.name.replace(/\.[^.]+$/, ''),
1623
- fileName: d.file.name,
1624
- summary: null,
1625
- view: null,
1626
- substations: { points, approximate: true }
1627
- }));
1628
- }
1629
- // When the drop carried only sidecars, apply them to the selected existing
1630
- // case. Every balanced case parsed in this batch already took them above.
1631
- if (geoLayers.length > 0 && !geoLayersConsumed && parsedCaseCount === 0) {
1632
- await this.applyGeoLayersToExisting(geoLayers);
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`;
1633
1914
  }
1634
1915
  };
1635
1916
  /** Apply co-dropped geographic data before a local case becomes active and
@@ -1637,43 +1918,91 @@ export class Controller {
1637
1918
  * the first matching PowerWorld display. A failed geographic join keeps the
1638
1919
  * parsed case and returns the user-facing placement error. */
1639
1920
  prepareDroppedLocal = async (local, geoLayers, displays) => {
1640
- if (geoLayers.length > 0 && local.networkJson) {
1921
+ let geoLayersConsumed = false;
1922
+ let error = null;
1923
+ if (geoLayers.length > 0 && local.studyInputJson) {
1641
1924
  try {
1642
1925
  await this.applyGeoLayers(local, geoLayers);
1643
- return { geoLayersConsumed: true, error: null };
1926
+ geoLayersConsumed = true;
1644
1927
  }
1645
1928
  catch (e) {
1646
- return {
1647
- geoLayersConsumed: false,
1648
- error: `${geoLayers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`
1649
- };
1929
+ error = `${geoLayers.map((layer) => layer.name).join(' + ')}: ${errorText(e)}`;
1650
1930
  }
1651
1931
  }
1652
- if (local.coordsKind === 'synthetic_pending' && local.networkJson) {
1932
+ if (local.studyInputJson && displays.length > 0) {
1653
1933
  await this.fillFromDisplaySibling(local, displays);
1654
1934
  }
1655
- return { geoLayersConsumed: false, error: null };
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 };
1656
1970
  };
1657
- /** Fill a coordinate-less case's positions from a co-dropped PowerWorld
1658
- * `.pwd`: the display's substation points project to approximate
1659
- * longitude/latitude and join onto buses through the `SubNum` extras key.
1660
- * The first display that joins wins; one that matches nothing stays
1661
- * available as its own preview entry, and the case stays placeable. */
1971
+ /** Retain diagram geometry separately from the electrical case's geographic data. */
1662
1972
  fillFromDisplaySibling = async (c, displays) => {
1663
- if (!c.networkJson)
1973
+ if (!c.studyInputJson)
1664
1974
  return;
1665
1975
  for (const d of displays.filter((entry) => !entry.consumed)) {
1666
1976
  try {
1667
- const payload = await applyDisplayGeo(c.networkJson, d.bytes);
1668
- this.adoptGeoPayload(c, payload, d.file.name, `${d.file.name} substations`, payload.report.notes);
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];
1669
1992
  d.consumed = true;
1670
- return;
1671
1993
  }
1672
- catch {
1673
- // No SubNum join against this display; try the next one.
1994
+ catch (error) {
1995
+ c.geoWarnings = [errorText(error)];
1674
1996
  }
1675
1997
  }
1676
1998
  };
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
+ };
1677
2006
  /** Route a dropped `.json` by its content. Recognized payloads return without
1678
2007
  * mutating case state so co-dropped sidecars can be applied first; unrouted
1679
2008
  * content returns the bytes already read for the geographic fallback. */
@@ -1694,15 +2023,20 @@ export class Controller {
1694
2023
  this.app.error = `${file.name}: JSON markers name both transmission and distribution formats`;
1695
2024
  return { outcome: 'failed' };
1696
2025
  }
1697
- if (result.kind === 'balanced-package') {
2026
+ if (result.kind === 'transmission') {
1698
2027
  this.app.error = null;
1699
- return { outcome: 'restored', payload: result.payload };
2028
+ return { outcome: 'balanced', payload: result.payload };
1700
2029
  }
1701
- if (result.kind === 'model-json' || result.kind === 'transmission') {
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.
1702
2033
  this.app.error = null;
1703
- return { outcome: 'balanced', payload: result.payload };
2034
+ const payload = result.payload;
2035
+ if ('model' in payload)
2036
+ return { outcome: 'multiconductor', payload };
2037
+ return { outcome: 'balanced', payload };
1704
2038
  }
1705
- if (result.kind === 'multiconductor-package' || result.kind === 'distribution') {
2039
+ if (result.kind === 'distribution') {
1706
2040
  this.app.error = null;
1707
2041
  return { outcome: 'multiconductor', payload: result.payload };
1708
2042
  }
@@ -1734,14 +2068,14 @@ export class Controller {
1734
2068
  this.app.parsingFile = false;
1735
2069
  }
1736
2070
  };
1737
- /** Build a `MulticonductorCase` from an ingest payload and make it active.
1738
- * Geographic cases place immediately; planar/synthetic cases enter placement
1739
- * (a map click or "place on map") so the user picks their center. */
2071
+ /** Retain parsed electrical inputs and draw each declared coordinate space directly. */
1740
2072
  addMultiCase(fileName, payload) {
1741
2073
  const { graph, ...summary } = payload;
1742
2074
  const coordsKind = payload.coords_kind;
1743
2075
  const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1744
- const view = coordsKind === 'geographic' ? buildGeographicView(graph) : null;
2076
+ const view = coordsKind === 'geographic'
2077
+ ? buildGeographicView(graph, payload.geo_layer)
2078
+ : buildDiagramView(graph, payload.geo_layer);
1745
2079
  const c = new MulticonductorCase({
1746
2080
  id: `dist-${++this.localSeq}`,
1747
2081
  label,
@@ -1751,12 +2085,291 @@ export class Controller {
1751
2085
  coordsKind,
1752
2086
  view
1753
2087
  });
2088
+ this.app.studyView = null;
2089
+ this.clearSelection();
1754
2090
  this.app.addMulti(c);
1755
2091
  if (c.placed)
1756
2092
  this.app.requestFrame(c.id);
2093
+ return c;
1757
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
+ };
1758
2370
  /** Make a multiconductor case active, framing it when it is placed. */
1759
2371
  activateMulti = (c) => {
2372
+ this.app.studyView = null;
1760
2373
  this.clearSelection();
1761
2374
  this.app.activeCaseId = null;
1762
2375
  this.app.activeLocalId = null;
@@ -1814,6 +2427,11 @@ export class Controller {
1814
2427
  };
1815
2428
  removeMultiCase = async (c, event) => {
1816
2429
  event?.stopPropagation();
2430
+ c.solveAbort?.abort();
2431
+ if (c.mcEditTimer !== null)
2432
+ clearTimeout(c.mcEditTimer);
2433
+ c.mcSession?.free();
2434
+ c.mcSession = null;
1817
2435
  if (this.app.activeMultiId === c.id) {
1818
2436
  c.selectedBusId = null;
1819
2437
  c.selectedEdgeId = null;
@@ -1828,53 +2446,89 @@ export class Controller {
1828
2446
  else
1829
2447
  this.placeLocalCase(lon, lat);
1830
2448
  };
1831
- /** Build a local case from a bare balanced ingest payload without activating
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
1832
2505
  * it yet; co-dropped placement data is applied before the first solve. */
1833
2506
  localFromBalancedPayload = (fileName, payload) => {
1834
- const { network_json, topology, view, ...summary } = payload;
2507
+ const { module_json, topology, view, formulation, ...summary } = payload;
1835
2508
  const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1836
2509
  return new LocalCase({
1837
2510
  id: `local-${++this.localSeq}`,
1838
2511
  label,
1839
2512
  fileName,
2513
+ formulation,
2514
+ declaredFormulation: formulation,
1840
2515
  summary,
1841
- networkJson: network_json,
1842
- topology,
1843
- coordsKind: summary.coords_kind,
1844
- view
1845
- });
1846
- };
1847
- /** Build a local case from a saved package, including its edit state and
1848
- * formulation, without activating it until sidecars have been applied. */
1849
- localFromPackage = (fileName, pkg) => {
1850
- const { network_json, topology, view, formulation, deltas, rates, ...summary } = pkg;
1851
- const id = `local-${++this.localSeq}`;
1852
- const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1853
- const local = new LocalCase({
1854
- id,
1855
- label,
1856
- fileName,
1857
- summary,
1858
- networkJson: network_json,
2516
+ studyInputJson: module_json,
1859
2517
  topology,
1860
2518
  coordsKind: summary.coords_kind,
1861
2519
  view
1862
2520
  });
1863
- local.deltas = deltas;
1864
- local.ratings = rates;
1865
- local.formulation = formulation;
1866
- return local;
1867
2521
  };
1868
2522
  /** Build (or reuse) the case's Study and commit it at the current edit state, so a
1869
2523
  * save or export captures exactly what is on screen. Null (with an error set) when no
1870
- * network or Study is available. */
2524
+ * PowerIO module or Study is available. */
1871
2525
  syncedStudy = async (c) => {
1872
- const networkJson = await this.ensureNetworkJson(c);
1873
- if (!networkJson) {
1874
- this.app.error = `${this.caseName(c)}: no network available to save`;
2526
+ const studyInputJson = await this.ensureStudyInputJson(c);
2527
+ if (!studyInputJson) {
2528
+ this.app.error = `${this.caseName(c)}: no PowerIO module available to save`;
1875
2529
  return null;
1876
2530
  }
1877
- const study = await this.getStudy(c, networkJson);
2531
+ const study = await this.getStudy(c, studyInputJson);
1878
2532
  if (!study) {
1879
2533
  this.app.error = `${this.caseName(c)}: the browser study is unavailable`;
1880
2534
  return null;
@@ -1882,31 +2536,47 @@ export class Controller {
1882
2536
  await study.commit(c.id, this.caseDeltas(c), this.caseRatings(c), null);
1883
2537
  return study;
1884
2538
  };
1885
- /** Save the active study as a `.pio.json` package and download it. */
1886
- 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
+ }
1887
2558
  const study = await this.syncedStudy(c);
1888
2559
  if (!study)
1889
2560
  return;
1890
2561
  try {
1891
- downloadText(await study.savePackage(), `${this.caseFileStem(c)}.pio.json`, 'application/json');
2562
+ downloadText(await study.saveSolutionModule(), `${this.caseFileStem(c)}.solution.pio.json`, 'application/json');
1892
2563
  this.app.error = null;
1893
2564
  }
1894
2565
  catch (e) {
1895
- 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)}`;
1896
2567
  }
1897
2568
  };
1898
- /** Export the active study's committed state to `format` and download it. Returns the
1899
- * writer's fidelity warnings so the caller can surface them. */
1900
- exportStudyAs = async (c, format) => {
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) => {
1901
2572
  const study = await this.syncedStudy(c);
1902
2573
  if (!study)
1903
2574
  return [];
1904
2575
  try {
1905
- const packageJson = await study.savePackage();
1906
- const exported = await exportStudy(packageJson, studyCommitIndex(packageJson), format);
2576
+ const exported = await study.export(format);
1907
2577
  downloadText(exported.text, `${this.caseFileStem(c)}.${exported.extension}`, 'text/plain');
1908
2578
  this.app.error = null;
1909
- return exported.warnings;
2579
+ return exported.diagnostics;
1910
2580
  }
1911
2581
  catch (e) {
1912
2582
  this.app.error = `${this.caseName(c)}: could not export study: ${errorText(e)}`;
@@ -1916,7 +2586,7 @@ export class Controller {
1916
2586
  /** A filesystem-safe base name for a downloaded case file. */
1917
2587
  caseFileStem(c) {
1918
2588
  const base = this.isBackendCase(c) ? c.name : c.label || c.fileName.replace(/\.[^.]+$/, '');
1919
- return base.replace(/[^\w.-]+/g, '_') || 'study';
2589
+ return base.replace(/[^\w.-]+/g, '_') || 'case';
1920
2590
  }
1921
2591
  setDemandRangeMode = (mode) => {
1922
2592
  this.app.demandRangeMode = mode;
@@ -1931,11 +2601,13 @@ export class Controller {
1931
2601
  };
1932
2602
  // First-order engine preview for the live drag. Uses the case's already-built
1933
2603
  // Study (no re-parse, no re-solve) to paint predicted per-bus
1934
- // ΔLMP and the predicted Δobjective. A no-op when the Study isn't built yet or
2604
+ // nodal value changes and the predicted objective change. A no-op when the Study isn't built yet or
1935
2605
  // can't preview (browser fallback path, server-only cases): the map then
1936
2606
  // falls back to the JS sensitivity-times-step preview.
1937
2607
  runPreview = (c, bus, value) => {
1938
- // 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),
1939
2611
  // scaled by the demand step, is the same first-order linearization the engine preview
1940
2612
  // returns — without rebuilding the differentiable KKT every drag frame. That engine
1941
2613
  // preview is ~80 ms/frame on the largest case (CATS, ~8870 buses), which blocked the
@@ -1947,12 +2619,21 @@ export class Controller {
1947
2619
  const delta = new Map();
1948
2620
  for (const v of col.values)
1949
2621
  delta.set(v.bus, v.value * step);
1950
- this.app.previewLmp = { caseId: c.id, target: { bus }, delta };
1951
- const lmpAtBus = c.solution?.lmp.find((l) => l.bus === bus)?.usd_per_mwh ?? null;
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;
1952
2629
  this.previewObjective =
1953
- lmpAtBus === null
2630
+ priceAtBus === null
1954
2631
  ? null
1955
- : { caseId: c.id, target: { bus }, objectiveDelta: lmpAtBus * step };
2632
+ : {
2633
+ caseId: c.id,
2634
+ target: { bus },
2635
+ objectiveDelta: priceAtBus * step
2636
+ };
1956
2637
  return;
1957
2638
  }
1958
2639
  // Fallback (no committed column yet): the engine's first-order preview, which
@@ -1983,21 +2664,26 @@ export class Controller {
1983
2664
  if (!study)
1984
2665
  continue;
1985
2666
  try {
1986
- 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));
1987
2668
  // Apply only while this point is still the live drag target: a
1988
2669
  // preview that resolves after a re-selection or a commit is stale.
1989
2670
  if (!this.app.previewActive || this.activeSolvable !== c || this.app.selectedBus !== bus)
1990
2671
  continue;
1991
2672
  const delta = new Map();
1992
- for (const e of lmp)
1993
- delta.set(e.bus, e.usd_per_mwh);
1994
- this.app.previewLmp = { caseId: c.id, target: { bus }, delta };
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
+ };
1995
2681
  this.previewObjective =
1996
2682
  objectiveDelta === null ? null : { caseId: c.id, target: { bus }, objectiveDelta };
1997
2683
  }
1998
2684
  catch {
1999
2685
  if (this.activeSolvable === c && this.app.selectedBus === bus) {
2000
- this.app.previewLmp = null;
2686
+ this.app.previewPrices = null;
2001
2687
  this.previewObjective = null;
2002
2688
  }
2003
2689
  }
@@ -2028,12 +2714,14 @@ export class Controller {
2028
2714
  return ratings;
2029
2715
  }
2030
2716
  // First-order engine preview for the live rating drag, mirroring runPreview: the
2031
- // committed ∂LMP/∂rating column scaled by the rating step paints predicted per-bus
2032
- // ΔLMP without touching the engine per frame. The predicted Δobjective comes from
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
2033
2719
  // a per-MW slope taken once from Study.preview at a +1 MW step off the committed
2034
2720
  // point (preview is replacement-absolute, so the absolute ratings map is built),
2035
2721
  // then scaled by the live step; null when no Study slope is available.
2036
2722
  runRatingPreview = (c, branch, value) => {
2723
+ if (c.formulation === 'acpf')
2724
+ return;
2037
2725
  const committedAtBranch = this.caseRatings(c)[branch] ?? 0;
2038
2726
  const step = (Math.abs(value) < 0.25 ? 0 : value) - committedAtBranch;
2039
2727
  const col = c.sensitivity;
@@ -2041,7 +2729,12 @@ export class Controller {
2041
2729
  const delta = new Map();
2042
2730
  for (const v of col.values)
2043
2731
  delta.set(v.bus, v.value * step);
2044
- 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
+ };
2045
2738
  }
2046
2739
  let cached = this.ratingSlope;
2047
2740
  if (cached?.caseId !== c.id || cached.branch !== branch) {
@@ -2126,6 +2819,7 @@ export class Controller {
2126
2819
  }
2127
2820
  c.predictedObjective = this.predictedDeltaObj;
2128
2821
  c.ratings = this.previewRatings(c, branch, value);
2822
+ this.bumpRevision(c);
2129
2823
  // The slope was taken at the old committed point; the next drag re-derives it.
2130
2824
  this.ratingSlope = null;
2131
2825
  this.app.previewRatingMw = value;