@tellegen/svelte 0.1.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 (121) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +70 -0
  3. package/dist/Sparkline.svelte +100 -0
  4. package/dist/Sparkline.svelte.d.ts +8 -0
  5. package/dist/Sparkline.svelte.d.ts.map +1 -0
  6. package/dist/TellegenMap.svelte +1430 -0
  7. package/dist/TellegenMap.svelte.d.ts +14 -0
  8. package/dist/TellegenMap.svelte.d.ts.map +1 -0
  9. package/dist/TellegenProvider.svelte +38 -0
  10. package/dist/TellegenProvider.svelte.d.ts +9 -0
  11. package/dist/TellegenProvider.svelte.d.ts.map +1 -0
  12. package/dist/TellegenShell.svelte +126 -0
  13. package/dist/TellegenShell.svelte.d.ts +4 -0
  14. package/dist/TellegenShell.svelte.d.ts.map +1 -0
  15. package/dist/TellegenViewer.svelte +19 -0
  16. package/dist/TellegenViewer.svelte.d.ts +4 -0
  17. package/dist/TellegenViewer.svelte.d.ts.map +1 -0
  18. package/dist/api.d.ts +44 -0
  19. package/dist/api.d.ts.map +1 -0
  20. package/dist/api.js +137 -0
  21. package/dist/colors.d.ts +51 -0
  22. package/dist/colors.d.ts.map +1 -0
  23. package/dist/colors.js +140 -0
  24. package/dist/components/AppFooter.svelte +73 -0
  25. package/dist/components/AppFooter.svelte.d.ts +19 -0
  26. package/dist/components/AppFooter.svelte.d.ts.map +1 -0
  27. package/dist/components/AppHeader.svelte +405 -0
  28. package/dist/components/AppHeader.svelte.d.ts +4 -0
  29. package/dist/components/AppHeader.svelte.d.ts.map +1 -0
  30. package/dist/components/BindingLines.svelte +101 -0
  31. package/dist/components/BindingLines.svelte.d.ts +4 -0
  32. package/dist/components/BindingLines.svelte.d.ts.map +1 -0
  33. package/dist/components/BusPicker.svelte +227 -0
  34. package/dist/components/BusPicker.svelte.d.ts +4 -0
  35. package/dist/components/BusPicker.svelte.d.ts.map +1 -0
  36. package/dist/components/ControlPanel.svelte +154 -0
  37. package/dist/components/ControlPanel.svelte.d.ts +19 -0
  38. package/dist/components/ControlPanel.svelte.d.ts.map +1 -0
  39. package/dist/components/DemandSlider.svelte +271 -0
  40. package/dist/components/DemandSlider.svelte.d.ts +4 -0
  41. package/dist/components/DemandSlider.svelte.d.ts.map +1 -0
  42. package/dist/components/DisplayControls.svelte +81 -0
  43. package/dist/components/DisplayControls.svelte.d.ts +19 -0
  44. package/dist/components/DisplayControls.svelte.d.ts.map +1 -0
  45. package/dist/components/DropZone.svelte +46 -0
  46. package/dist/components/DropZone.svelte.d.ts +19 -0
  47. package/dist/components/DropZone.svelte.d.ts.map +1 -0
  48. package/dist/components/FormulationSelector.svelte +82 -0
  49. package/dist/components/FormulationSelector.svelte.d.ts +19 -0
  50. package/dist/components/FormulationSelector.svelte.d.ts.map +1 -0
  51. package/dist/components/LocalCaseDetails.svelte +253 -0
  52. package/dist/components/LocalCaseDetails.svelte.d.ts +4 -0
  53. package/dist/components/LocalCaseDetails.svelte.d.ts.map +1 -0
  54. package/dist/components/MulticonductorDetails.svelte +280 -0
  55. package/dist/components/MulticonductorDetails.svelte.d.ts +19 -0
  56. package/dist/components/MulticonductorDetails.svelte.d.ts.map +1 -0
  57. package/dist/components/NetworkStats.svelte +75 -0
  58. package/dist/components/NetworkStats.svelte.d.ts +19 -0
  59. package/dist/components/NetworkStats.svelte.d.ts.map +1 -0
  60. package/dist/components/PlacementCue.svelte +35 -0
  61. package/dist/components/PlacementCue.svelte.d.ts +19 -0
  62. package/dist/components/PlacementCue.svelte.d.ts.map +1 -0
  63. package/dist/components/RatingSlider.svelte +273 -0
  64. package/dist/components/RatingSlider.svelte.d.ts +4 -0
  65. package/dist/components/RatingSlider.svelte.d.ts.map +1 -0
  66. package/dist/components/RestoreDefaultsButton.svelte +34 -0
  67. package/dist/components/RestoreDefaultsButton.svelte.d.ts +19 -0
  68. package/dist/components/RestoreDefaultsButton.svelte.d.ts.map +1 -0
  69. package/dist/components/SensitivityReadout.svelte +126 -0
  70. package/dist/components/SensitivityReadout.svelte.d.ts +4 -0
  71. package/dist/components/SensitivityReadout.svelte.d.ts.map +1 -0
  72. package/dist/components/SizeLegend.svelte +55 -0
  73. package/dist/components/SizeLegend.svelte.d.ts +19 -0
  74. package/dist/components/SizeLegend.svelte.d.ts.map +1 -0
  75. package/dist/components/SolveCard.svelte +84 -0
  76. package/dist/components/SolveCard.svelte.d.ts +4 -0
  77. package/dist/components/SolveCard.svelte.d.ts.map +1 -0
  78. package/dist/components/TopMovers.svelte +68 -0
  79. package/dist/components/TopMovers.svelte.d.ts +19 -0
  80. package/dist/components/TopMovers.svelte.d.ts.map +1 -0
  81. package/dist/components.d.ts +19 -0
  82. package/dist/components.d.ts.map +1 -0
  83. package/dist/components.js +18 -0
  84. package/dist/context.svelte.d.ts +20 -0
  85. package/dist/context.svelte.d.ts.map +1 -0
  86. package/dist/context.svelte.js +43 -0
  87. package/dist/controller.svelte.d.ts +334 -0
  88. package/dist/controller.svelte.d.ts.map +1 -0
  89. package/dist/controller.svelte.js +2026 -0
  90. package/dist/display.d.ts +31 -0
  91. package/dist/display.d.ts.map +1 -0
  92. package/dist/display.js +63 -0
  93. package/dist/drop-classify.d.ts +48 -0
  94. package/dist/drop-classify.d.ts.map +1 -0
  95. package/dist/drop-classify.js +92 -0
  96. package/dist/format.d.ts +30 -0
  97. package/dist/format.d.ts.map +1 -0
  98. package/dist/format.js +49 -0
  99. package/dist/index.d.ts +21 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +12 -0
  102. package/dist/map.d.ts +9 -0
  103. package/dist/map.d.ts.map +1 -0
  104. package/dist/map.js +4 -0
  105. package/dist/multiconductor.d.ts +63 -0
  106. package/dist/multiconductor.d.ts.map +1 -0
  107. package/dist/multiconductor.js +255 -0
  108. package/dist/state.svelte.d.ts +258 -0
  109. package/dist/state.svelte.d.ts.map +1 -0
  110. package/dist/state.svelte.js +323 -0
  111. package/dist/study-package.d.ts +9 -0
  112. package/dist/study-package.d.ts.map +1 -0
  113. package/dist/study-package.js +17 -0
  114. package/dist/styles.css +393 -0
  115. package/dist/synthetic-layout.d.ts +12 -0
  116. package/dist/synthetic-layout.d.ts.map +1 -0
  117. package/dist/synthetic-layout.js +271 -0
  118. package/dist/types.d.ts +6 -0
  119. package/dist/types.d.ts.map +1 -0
  120. package/dist/types.js +1 -0
  121. package/package.json +67 -0
@@ -0,0 +1,2026 @@
1
+ import { ApiError, createApiClient } from './api.js';
2
+ import { previewScaleFor, scalarDomain, sensFlatColor, sensitivityDomain } from './colors.js';
3
+ import { caseDeltas as sharedCaseDeltas, caseRatings as sharedCaseRatings, displayMetaFor, displaySeriesFor } from './display.js';
4
+ import { CaseState, LocalCase, MulticonductorCase } from './state.svelte.js';
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';
10
+ import { errorText, extent, formulationLabel, rgbaCss } from './format.js';
11
+ const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
12
+ // Open on South Carolina by default: it has the most interesting price action.
13
+ const DEFAULT_CASE_ID = 'case500';
14
+ /** Terminal copy when nothing can solve. Two facts decide the wording: whether
15
+ * this browser's engine failure is known permanent, and that the server
16
+ * declines compute. Server compute exists in the codebase but ships disabled;
17
+ * interest routes to email until it is offered. */
18
+ const WASM_REQUIRED_NOTICE = 'this browser cannot run the WebAssembly engine that solves locally, and server side compute is disabled in this demo. it may be offered later; to express interest, email Samuel Talkington at talks@umich.edu';
19
+ const COMPUTE_OFF_NOTICE = 'solving in this browser did not complete, and server side compute is disabled in this demo. retry to run the browser engine again, or email Samuel Talkington at talks@umich.edu to express interest in server compute';
20
+ /** Upper bound on waiting for a camera fly-to before the sensitivity solve
21
+ * starts anyway: a missing map or a cue that never appears must not stall the
22
+ * selection. */
23
+ const FOCUS_SETTLE_CAP_MS = 1200;
24
+ function delayUntilFocusSettles(ms, signal) {
25
+ if (ms <= 0 || signal.aborted)
26
+ return Promise.resolve();
27
+ return new Promise((resolve) => {
28
+ let timeout;
29
+ const finish = () => {
30
+ clearTimeout(timeout);
31
+ signal.removeEventListener('abort', finish);
32
+ resolve();
33
+ };
34
+ timeout = setTimeout(finish, ms);
35
+ signal.addEventListener('abort', finish, { once: true });
36
+ });
37
+ }
38
+ /** Trigger a browser download of in-memory text. Case files never leave the machine:
39
+ * a save is a local Blob download. */
40
+ function downloadText(text, filename, mime) {
41
+ const url = URL.createObjectURL(new Blob([text], { type: mime }));
42
+ const anchor = document.createElement('a');
43
+ anchor.href = url;
44
+ anchor.download = filename;
45
+ anchor.rel = 'noopener';
46
+ document.body.appendChild(anchor);
47
+ anchor.click();
48
+ // Defer cleanup: revoking the object URL in the same tick as click() can cancel
49
+ // an in-flight download in some browsers.
50
+ setTimeout(() => {
51
+ anchor.remove();
52
+ URL.revokeObjectURL(url);
53
+ }, 0);
54
+ }
55
+ export class Controller {
56
+ app;
57
+ api;
58
+ abort = null;
59
+ // While set (epoch ms), the server sensitivity fallback is rate limited: skip
60
+ // the request and show the rate-limit copy instead of burning the budget on a
61
+ // guaranteed rejection. Covers one rate limit window. Not $state; nothing
62
+ // renders it.
63
+ sensitivity429Until = 0;
64
+ // Latched once any server fallback answers 403: compute is disabled on this
65
+ // deploy, so later fallbacks show the notice instead of firing doomed requests.
66
+ serverComputeOff = false;
67
+ // Build-once browser Study per case: the network is parsed and the model built
68
+ // when the Study is created, so a drag re-solves (commit) and previews without
69
+ // re-parsing. Kept in a WeakMap off the reactive/raw case payloads — the wasm
70
+ // handle is neither serialized nor part of any $state.
71
+ caseStudies = new WeakMap();
72
+ // Latch a permanent sensitivity-module failure per case so we don't retry
73
+ // createStudy — and the same permanent error — on every drag. Transient
74
+ // failures are not latched.
75
+ studyUnavailable = new WeakMap();
76
+ // In-flight Study builds, keyed per case, so two overlapping getStudy calls for
77
+ // the same case share one createStudy instead of each building (and leaking) a
78
+ // wasm Study. Cleared once the build settles or the case is disposed.
79
+ studyBuilds = new WeakMap();
80
+ // Local case id counter. An instance field (not a module global) so it is scoped
81
+ // to this controller — the singleton-free shape the rest of the app relies on.
82
+ localSeq = 0;
83
+ // In-flight initial case-list load, so a remount during the first fetch dedupes
84
+ // instead of firing a second concurrent load().
85
+ loading = null;
86
+ nearbyRangeAnchor = $state(null);
87
+ // Default cases the user has closed; drives the restore affordance. Seeded in load().
88
+ hiddenDefaults = $state(new Set());
89
+ casesLoaded = $state(false);
90
+ loadingBackendCase = $state(null);
91
+ showFileDropUi = $state(true);
92
+ // Predicted objective change vs the committed point for the live preview. The
93
+ // engine (Study.preview) owns this for browser-solvable cases; null when no
94
+ // engine preview applies (server or browser fallback path), where the first-order fallback
95
+ // below fills in. Scoped to the case + selection target it was computed for.
96
+ // Set by runPreview / runRatingPreview.
97
+ previewObjective = $state.raw(null);
98
+ // Per-MW objective slope for the rating drag, cached per (case, branch, committed
99
+ // point) so each drag frame stays O(buses): the engine's first-order preview at a
100
+ // +1 MW rating step is taken once, then scaled by the live step. Invalidated on
101
+ // commit, selection, and formulation change. Not $state; only runRatingPreview
102
+ // reads it, and previewObjective carries the rendered value.
103
+ ratingSlope = null;
104
+ constructor(app, options = {}) {
105
+ this.app = app;
106
+ this.api = options.api ?? createApiClient({ apiBase: options.apiBase });
107
+ }
108
+ // ===== helpers =====
109
+ isBackendCase(c) {
110
+ return c instanceof CaseState;
111
+ }
112
+ isActiveSolveCase(c) {
113
+ return this.isBackendCase(c) ? this.app.activeCaseId === c.id : this.app.activeLocalId === c.id;
114
+ }
115
+ caseDeltas(c) {
116
+ return sharedCaseDeltas(c);
117
+ }
118
+ caseRatings(c) {
119
+ return sharedCaseRatings(c);
120
+ }
121
+ /** True when the case carries any nonzero committed rating delta. Rating edits
122
+ * solve only through the browser Study; the server fallback would silently
123
+ * drop them, so callers gate on this before falling back. */
124
+ hasRatingEdits(c) {
125
+ return Object.values(this.caseRatings(c)).some((mw) => mw !== 0);
126
+ }
127
+ /** True when two selection targets name the same bus or branch. */
128
+ sameTarget(a, b) {
129
+ return 'bus' in a ? 'bus' in b && a.bus === b.bus : 'branch' in b && a.branch === b.branch;
130
+ }
131
+ isPerturbed(c) {
132
+ return c?.perturbed ?? false;
133
+ }
134
+ setNearbyRangeAnchor(c, bus, delta = this.caseDeltas(c)[bus] ?? 0) {
135
+ this.nearbyRangeAnchor = { caseId: c.id, bus, delta };
136
+ }
137
+ // Shared reset for both bus-select entry points: cancel any in-flight request,
138
+ // bump the per-case sensitivity token, and clear the selection/preview state so
139
+ // the panel starts the new selection clean. Returns the live AbortController and
140
+ // the sensitivity seq the caller guards its async writes with.
141
+ beginBusSelection(c, busId) {
142
+ this.abort?.abort();
143
+ const ac = new AbortController();
144
+ this.abort = ac;
145
+ const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
146
+ c.sensitivitySeq = sensitivitySeq;
147
+ this.app.error = null;
148
+ this.app.selectedBus = busId;
149
+ this.app.selectedBranch = null;
150
+ this.app.previewDeltaMw = null;
151
+ this.app.previewRatingMw = null;
152
+ this.app.previewActive = false;
153
+ this.app.demandRangeMode = 'local';
154
+ this.setNearbyRangeAnchor(c, busId);
155
+ this.ratingSlope = null;
156
+ this.app.sensitivityLoading = true;
157
+ c.sensitivity = null;
158
+ return { ac, sensitivitySeq };
159
+ }
160
+ // The branch-select counterpart of beginBusSelection: same abort/seq discipline,
161
+ // but the selection lands on a branch and the demand preview fields are cleared.
162
+ beginBranchSelection(c, branchId) {
163
+ this.abort?.abort();
164
+ const ac = new AbortController();
165
+ this.abort = ac;
166
+ const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
167
+ c.sensitivitySeq = sensitivitySeq;
168
+ this.app.error = null;
169
+ this.app.selectedBranch = branchId;
170
+ this.app.selectedBus = null;
171
+ this.app.previewDeltaMw = null;
172
+ this.app.previewRatingMw = null;
173
+ this.app.previewActive = false;
174
+ this.app.demandRangeMode = 'local';
175
+ this.nearbyRangeAnchor = null;
176
+ this.ratingSlope = null;
177
+ this.app.sensitivityLoading = true;
178
+ c.sensitivity = null;
179
+ return { ac, sensitivitySeq };
180
+ }
181
+ /** The display name of a case: a backend case's `name`, a local case's `label`. */
182
+ caseName(c) {
183
+ return this.isBackendCase(c) ? c.name : c.label;
184
+ }
185
+ readHiddenDefaultCases() {
186
+ if (typeof localStorage === 'undefined')
187
+ return new Set();
188
+ try {
189
+ const parsed = JSON.parse(localStorage.getItem(HIDDEN_DEFAULT_CASES_KEY) ?? '[]');
190
+ return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []);
191
+ }
192
+ catch {
193
+ return new Set();
194
+ }
195
+ }
196
+ writeHiddenDefaultCases(ids) {
197
+ if (typeof localStorage === 'undefined')
198
+ return;
199
+ try {
200
+ localStorage.setItem(HIDDEN_DEFAULT_CASES_KEY, JSON.stringify([...ids].sort()));
201
+ }
202
+ catch {
203
+ // Current session removal still works; persistence is best effort.
204
+ }
205
+ }
206
+ rememberHiddenDefaultCase(id) {
207
+ const hidden = this.readHiddenDefaultCases();
208
+ hidden.add(id);
209
+ this.writeHiddenDefaultCases(hidden);
210
+ this.hiddenDefaults = new Set(hidden);
211
+ }
212
+ localNetwork(c) {
213
+ if (!c.summary || !c.view)
214
+ return null;
215
+ return {
216
+ id: c.id,
217
+ name: c.label,
218
+ base_mva: c.summary.base_mva,
219
+ synthetic_coords: c.coordsKind !== 'file' && c.coordsKind !== 'geofile',
220
+ buses: c.view.buses,
221
+ branches: c.view.branches
222
+ };
223
+ }
224
+ maybeStartLocalSolve(id) {
225
+ const c = this.app.localCases.find((lc) => lc.id === id);
226
+ if (!c?.networkJson || !c.view || !c.summary)
227
+ return;
228
+ c.network = this.localNetwork(c) ?? c.network ?? null;
229
+ if (c.networkJson && c.network && !c.solution)
230
+ this.runSolve(c, null);
231
+ }
232
+ // The case's Study, building it once for `(networkJson, formulation)` and rebuilding
233
+ // (after free) if either changed — so picking a new formulation re-parses and re-solves
234
+ // under that formulation. Returns null when the sens module can't load; the caller then
235
+ // falls back to the server, surfacing solveFallbackReason.
236
+ async getStudy(c, networkJson) {
237
+ const latched = this.studyUnavailable.get(c);
238
+ if (latched) {
239
+ c.solveFallbackReason ??= latched;
240
+ return null;
241
+ }
242
+ const cached = this.caseStudies.get(c);
243
+ if (cached && cached.networkJson === networkJson && cached.formulation === c.formulation)
244
+ return cached.study;
245
+ // Coalesce concurrent builds for the same (case, networkJson, formulation): a
246
+ // bus-select sensitivity fetch and a solve can both reach here before either
247
+ // createStudy resolves. Without this each builds its own wasm Study and the
248
+ // later set() orphans the earlier one (a leaked handle), and the cache can end
249
+ // up holding an instance the live solve never committed on.
250
+ const inflight = this.studyBuilds.get(c);
251
+ if (inflight && inflight.networkJson === networkJson && inflight.formulation === c.formulation)
252
+ return inflight.promise;
253
+ // Different params, or no build pending: drop any stale Study before rebuilding.
254
+ if (cached) {
255
+ cached.study.free();
256
+ this.caseStudies.delete(c);
257
+ }
258
+ const formulation = c.formulation;
259
+ // Identity for this build attempt: the post-await guards compare against it
260
+ // (rather than the promise) so a superseding build or a disposal wins the cache.
261
+ const token = {};
262
+ const build = (async () => {
263
+ try {
264
+ const study = await createStudy(networkJson, formulation);
265
+ const baseSolution = await study.currentSolution();
266
+ // The case may have switched formulation or been disposed (removed, or its
267
+ // list reloaded) while building; don't cache a now-unwanted Study, and free
268
+ // the orphan so its wasm memory is released.
269
+ if (c.formulation !== formulation || this.studyBuilds.get(c)?.token !== token) {
270
+ study.free();
271
+ return null;
272
+ }
273
+ this.caseStudies.set(c, { study, networkJson, formulation, baseSolution });
274
+ return study;
275
+ }
276
+ catch (e) {
277
+ const message = errorText(e);
278
+ // Only a genuine browser-capability failure is permanent; latch it so the
279
+ // case stays on the fallback path. Transient errors stay retryable.
280
+ if (isPermanentEngineFailure(message)) {
281
+ this.studyUnavailable.set(c, 'browser study wasm is not supported by this browser');
282
+ }
283
+ c.solveFallbackReason ??= `browser study unavailable: ${message}`;
284
+ return null;
285
+ }
286
+ finally {
287
+ if (this.studyBuilds.get(c)?.token === token)
288
+ this.studyBuilds.delete(c);
289
+ }
290
+ })();
291
+ this.studyBuilds.set(c, { networkJson, formulation, token, promise: build });
292
+ return build;
293
+ }
294
+ // The ∂LMP/∂parameter column at `target` for the case's active formulation, solved in
295
+ // the browser. Every column, bus or branch, any formulation, comes from the Study's
296
+ // exact re-solve at the case's operating point. The column is null (with the reason on
297
+ // `solveFallbackReason`) when the Study can't be built; the selection paths then
298
+ // reconcile via the server where a DC endpoint exists. Throws only on a hard browser
299
+ // 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;
305
+ }
306
+ // Release a case's Study (if any) when the case is removed.
307
+ disposeStudy(c) {
308
+ const cached = this.caseStudies.get(c);
309
+ if (cached) {
310
+ cached.study.free();
311
+ this.caseStudies.delete(c);
312
+ }
313
+ // Cancel any in-flight build: when it resolves it sees its entry gone and frees.
314
+ this.studyBuilds.delete(c);
315
+ }
316
+ acceptSensitivity(c, col, target, sensitivitySeq) {
317
+ if (!col || target === null)
318
+ return;
319
+ // The column must name the target it was requested for, and the target must
320
+ // still be the live selection.
321
+ const matches = 'bus' in target
322
+ ? col.bus === target.bus && this.app.selectedBus === target.bus
323
+ : col.branch === target.branch && this.app.selectedBranch === target.branch;
324
+ if (!matches)
325
+ return;
326
+ if (!this.isActiveSolveCase(c))
327
+ return;
328
+ if (sensitivitySeq !== undefined && sensitivitySeq !== (c.sensitivitySeq ?? 0))
329
+ return;
330
+ c.sensitivity = col;
331
+ }
332
+ finishSolve(c, seq, target) {
333
+ if (seq !== (c.solveSeq ?? 0))
334
+ return;
335
+ c.solving = false;
336
+ const selectionLive = target !== null &&
337
+ this.isActiveSolveCase(c) &&
338
+ ('bus' in target
339
+ ? this.app.selectedBus === target.bus
340
+ : this.app.selectedBranch === target.branch);
341
+ if (selectionLive) {
342
+ this.app.previewActive = false;
343
+ this.app.previewDeltaMw = null;
344
+ this.app.previewRatingMw = null;
345
+ // The committed solution supersedes the live engine preview.
346
+ this.app.previewLmp = null;
347
+ this.previewObjective = null;
348
+ }
349
+ }
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) {
353
+ if (!this.isBackendCase(c))
354
+ return c.networkJson ?? null;
355
+ if (c.networkJson)
356
+ return c.networkJson;
357
+ try {
358
+ const json = await this.api.getCaseNetworkJson(c.id);
359
+ c.networkJson = json;
360
+ return json;
361
+ }
362
+ catch (e) {
363
+ c.solveFallbackReason = `case fetch failed: ${errorText(e)}`;
364
+ return null;
365
+ }
366
+ }
367
+ // Deltas the live preview would commit: the committed deltas with the slider's
368
+ // value at the selected bus, applying commitDelta's dead zone so a tiny nudge
369
+ // reads as "no change at this bus".
370
+ previewDeltas(c, bus, value) {
371
+ const deltas = { ...this.caseDeltas(c) };
372
+ if (Math.abs(value) < 0.25)
373
+ delete deltas[bus];
374
+ else
375
+ deltas[bus] = value;
376
+ return deltas;
377
+ }
378
+ demandBounds(mode, bus, center) {
379
+ if (!bus)
380
+ return { min: 0, max: 0, span: 0 };
381
+ // Floor, not ceil: -ceil(demand) can push base + delta below zero for
382
+ // non-integer demand, which the server rejects (400) and which is
383
+ // physically meaningless (demand cannot go negative).
384
+ const physicalMin = -Math.floor(bus.demand_mw);
385
+ const physicalMax = Math.max(Math.ceil(bus.demand_mw), 50);
386
+ if (mode === 'full')
387
+ return { min: physicalMin, max: physicalMax, span: physicalMax - physicalMin };
388
+ const span = Math.max(5, Math.min(25, 0.1 * Math.max(bus.demand_mw, 50)));
389
+ return {
390
+ min: Math.max(physicalMin, center - span),
391
+ max: Math.min(physicalMax, center + span),
392
+ span
393
+ };
394
+ }
395
+ // ===== view-model =====
396
+ activeSolvable = $derived.by(() => this.app.active ?? (this.app.activeLocal?.network ? this.app.activeLocal : null));
397
+ activeFormulation = $derived(this.activeSolvable?.formulation ?? 'dcopf');
398
+ networkStats = $derived.by(() => {
399
+ const c = this.activeSolvable;
400
+ if (!c?.network)
401
+ return null;
402
+ return {
403
+ buses: c.network.buses.length,
404
+ branches: c.network.branches.length,
405
+ 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
408
+ };
409
+ });
410
+ displayOptions = $derived(displayMetaFor(this.activeSolvable));
411
+ activeDisplay = $derived.by(() => {
412
+ const activeMeta = this.displayOptions.find((option) => option.mode === this.app.displayMode) ??
413
+ this.displayOptions[0] ??
414
+ null;
415
+ if (!activeMeta)
416
+ return null;
417
+ // Drive the values off the resolved mode, not app.displayMode, so the
418
+ // 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) };
421
+ });
422
+ displayStats = $derived.by(() => {
423
+ if (!this.activeDisplay)
424
+ return null;
425
+ const values = this.activeDisplay.values.map((entry) => entry.value).filter(Number.isFinite);
426
+ if (values.length === 0)
427
+ return null;
428
+ const domain = scalarDomain(this.activeDisplay.mode, values);
429
+ const { min, max } = extent(values);
430
+ const flatThreshold = this.activeDisplay.mode === 'lmp' ? 1 : 1e-5;
431
+ return {
432
+ lo: { value: domain.lo, clamped: min < domain.lo - flatThreshold / 20 },
433
+ hi: { value: domain.hi, clamped: max > domain.hi + flatThreshold / 20 },
434
+ uniform: max - min < flatThreshold ? values[0] : null
435
+ };
436
+ });
437
+ selectedBusData = $derived.by(() => {
438
+ const c = this.activeSolvable;
439
+ if (!c?.network || this.app.selectedBus === null)
440
+ return null;
441
+ return c.network.buses.find((b) => b.id === this.app.selectedBus) ?? null;
442
+ });
443
+ selectedBranchData = $derived.by(() => {
444
+ const c = this.activeSolvable;
445
+ if (!c?.network || this.app.selectedBranch === null)
446
+ return null;
447
+ return c.network.branches.find((b) => b.id === this.app.selectedBranch) ?? null;
448
+ });
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
451
+ // exclusive, so bus-first ordering is only a tiebreak for impossible states.
452
+ selectionTarget = $derived.by(() => this.app.selectedBus !== null
453
+ ? { bus: this.app.selectedBus }
454
+ : this.app.selectedBranch !== null
455
+ ? { branch: this.app.selectedBranch }
456
+ : null);
457
+ committedDelta = $derived.by(() => this.activeSolvable && this.app.selectedBus !== null
458
+ ? (this.caseDeltas(this.activeSolvable)[this.app.selectedBus] ?? 0)
459
+ : 0);
460
+ sliderValue = $derived.by(() => this.app.previewDeltaMw ?? this.committedDelta);
461
+ nearbyRangeCenter = $derived.by(() => {
462
+ const c = this.activeSolvable;
463
+ const bus = this.app.selectedBus;
464
+ if (!c || bus === null)
465
+ return this.committedDelta;
466
+ if (this.nearbyRangeAnchor?.caseId === c.id && this.nearbyRangeAnchor.bus === bus) {
467
+ return this.nearbyRangeAnchor.delta;
468
+ }
469
+ return this.committedDelta;
470
+ });
471
+ sliderBounds = $derived.by(() => this.demandBounds(this.app.demandRangeMode, this.selectedBusData, this.nearbyRangeCenter));
472
+ sliderMin = $derived(this.sliderBounds.min);
473
+ sliderMax = $derived(this.sliderBounds.max);
474
+ committedRating = $derived.by(() => this.activeSolvable && this.app.selectedBranch !== null
475
+ ? (this.caseRatings(this.activeSolvable)[this.app.selectedBranch] ?? 0)
476
+ : 0);
477
+ ratingSliderValue = $derived.by(() => this.app.previewRatingMw ?? this.committedRating);
478
+ // Rating slider bounds, absolute around zero delta: ±20% of the base rating,
479
+ // clamped to [5, 50] MW, and never below 1 - rate so the committed limit
480
+ // rate + Δ stays at least 1 MW. Disabled when no branch is selected or the
481
+ // line's rating is synthesized (rate_mw <= 0), which has no physical limit
482
+ // to perturb.
483
+ ratingBounds = $derived.by(() => {
484
+ const b = this.selectedBranchData;
485
+ if (!b || b.rate_mw <= 0)
486
+ return { min: 0, max: 0, disabled: true };
487
+ 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 };
489
+ });
490
+ selectedSensitivity = $derived.by(() => {
491
+ const c = this.activeSolvable;
492
+ if (!c?.sensitivity)
493
+ return null;
494
+ if (this.app.selectedBus !== null) {
495
+ return c.sensitivity.bus === this.app.selectedBus ? c.sensitivity : null;
496
+ }
497
+ if (this.app.selectedBranch !== null) {
498
+ return c.sensitivity.branch === this.app.selectedBranch ? c.sensitivity : null;
499
+ }
500
+ return null;
501
+ });
502
+ sensSummary = $derived.by(() => this.selectedSensitivity
503
+ ? sensitivityDomain(this.selectedSensitivity.values.map((v) => v.value))
504
+ : null);
505
+ flatSensBackground = $derived(this.sensSummary ? rgbaCss(sensFlatColor(this.sensSummary)) : '');
506
+ // The fixed preview normalization for a drag. Both inputs are stable while
507
+ // dragging (slider bounds are anchored per selection; the committed value moves
508
+ // only on commit), so the scale never shifts under the pointer and intensity is
509
+ // linear in the step. A branch selection normalizes over the rating slider's
510
+ // deflection instead of the demand slider's.
511
+ previewMaxAbsStep = $derived.by(() => this.app.selectedBranch !== null
512
+ ? Math.max(Math.abs(this.ratingBounds.min - this.committedRating), Math.abs(this.ratingBounds.max - this.committedRating))
513
+ : Math.max(Math.abs(this.sliderMin - this.committedDelta), Math.abs(this.sliderMax - this.committedDelta)));
514
+ previewScale = $derived.by(() => this.sensSummary ? previewScaleFor(this.sensSummary, this.previewMaxAbsStep) : null);
515
+ selectedLmp = $derived.by(() => {
516
+ const c = this.activeSolvable;
517
+ if (!c?.solution || this.app.selectedBus === null)
518
+ return null;
519
+ return c.solution.lmp.find((e) => e.bus === this.app.selectedBus)?.usd_per_mwh ?? null;
520
+ });
521
+ // Self-sensitivity ∂LMP_bb/∂d at the selected bus: the curvature term the
522
+ // first-order fallback uses for a second-order objective estimate. Zero when no
523
+ // sensitivity column is loaded for the bus.
524
+ selfSens = $derived.by(() => {
525
+ if (!this.selectedSensitivity || this.app.selectedBus === null)
526
+ return 0;
527
+ return this.selectedSensitivity.values.find((v) => v.bus === this.app.selectedBus)?.value ?? 0;
528
+ });
529
+ // Predicted objective change vs base for the demand/rating readout. Prefer the
530
+ // engine preview (Study.preview at the committed point, plus the committed part);
531
+ // fall back to a second-order gradient estimate when no Study preview is available
532
+ // (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 —
535
+ // so a branch selection without an engine slope shows no prediction.
536
+ predictedDeltaObj = $derived.by(() => {
537
+ const c = this.activeSolvable;
538
+ const target = this.selectionTarget;
539
+ if (!c?.solution || !c.baseSolution || target === null)
540
+ return null;
541
+ const committedPart = c.solution.objective - c.baseSolution.objective;
542
+ if (this.previewObjective &&
543
+ this.previewObjective.caseId === c.id &&
544
+ this.sameTarget(this.previewObjective.target, target)) {
545
+ return committedPart + this.previewObjective.objectiveDelta;
546
+ }
547
+ if ('branch' in target || this.selectedLmp === null)
548
+ return null;
549
+ const step = this.sliderValue - this.committedDelta;
550
+ return committedPart + this.selectedLmp * step + 0.5 * this.selfSens * step * step;
551
+ });
552
+ gradientScore = $derived.by(() => {
553
+ const c = this.activeSolvable;
554
+ if (!c?.solution || !c.baseSolution || c.predictedObjective == null || c.solving)
555
+ return null;
556
+ const exact = c.solution.objective - c.baseSolution.objective;
557
+ return { pred: c.predictedObjective, exact };
558
+ });
559
+ topMovers = $derived.by(() => {
560
+ if (!this.selectedSensitivity || this.sensSummary?.flat)
561
+ return [];
562
+ return [...this.selectedSensitivity.values]
563
+ .filter((v) => v.bus !== this.app.selectedBus)
564
+ .sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
565
+ .slice(0, 5);
566
+ });
567
+ showMoverSlot = $derived(Boolean(this.selectedSensitivity && !this.sensSummary?.flat));
568
+ previewing = $derived.by(() => Boolean(this.activeSolvable?.solving ||
569
+ this.app.previewActive ||
570
+ (this.app.previewDeltaMw !== null &&
571
+ Math.abs(this.sliderValue - this.committedDelta) >= 0.25) ||
572
+ (this.app.previewRatingMw !== null &&
573
+ Math.abs(this.ratingSliderValue - this.committedRating) >= 0.25)));
574
+ // ===== actions =====
575
+ load = () => {
576
+ // A remount can call load() again before the first getCases() resolves; dedupe
577
+ // so two concurrent loads can't double-fetch the case list and double-fit the map.
578
+ if (this.loading)
579
+ return this.loading;
580
+ this.loading = (async () => {
581
+ // Learn the deploy's compute gate up front so fallback paths pick honest
582
+ // copy and skip doomed requests (the SSE stream cannot see a 403). On
583
+ // failure assume enabled; the 403 latch in fetchServerColumn still
584
+ // catches a disabled deploy.
585
+ void this.api
586
+ .getComputeStatus()
587
+ .then((s) => {
588
+ this.serverComputeOff = !s.enabled;
589
+ })
590
+ .catch(() => { });
591
+ try {
592
+ const summaries = await this.api.getCases();
593
+ const hidden = this.readHiddenDefaultCases();
594
+ this.hiddenDefaults = new Set(hidden);
595
+ // Tear down the cases being replaced so their wasm Studies are freed and any
596
+ // live server stream is closed; the bulk reload otherwise orphans them (the
597
+ // Study WeakMaps key off the old objects, so their free() never runs).
598
+ for (const old of this.app.cases) {
599
+ old.closeStream?.();
600
+ old.closeStream = null;
601
+ old.solveSeq++;
602
+ this.disposeStudy(old);
603
+ }
604
+ this.app.cases = summaries.filter((s) => !hidden.has(s.id)).map((s) => new CaseState(s));
605
+ this.app.activeLocalId = null;
606
+ this.app.placingLocalId = null;
607
+ this.app.activeCaseId =
608
+ this.app.cases.find((c) => c.id === DEFAULT_CASE_ID)?.id ?? this.app.cases[0]?.id ?? null;
609
+ const active = this.app.active;
610
+ if (active)
611
+ await this.loadBackendCase(active, true);
612
+ else
613
+ this.app.requestFrame('all');
614
+ // Mark loaded only on success, so a failed first load is retried on the next
615
+ // mount (the page guards the call with `if (!ctrl.casesLoaded)`).
616
+ this.casesLoaded = true;
617
+ }
618
+ catch (e) {
619
+ this.fail(errorText(e), this.load);
620
+ }
621
+ finally {
622
+ this.loading = null;
623
+ }
624
+ })();
625
+ return this.loading;
626
+ };
627
+ loadBackendCase = async (c, frame = false) => {
628
+ if (c.network && c.solution && c.baseSolution) {
629
+ if (frame && this.app.activeCaseId === c.id)
630
+ this.app.requestFrame(c.id);
631
+ return;
632
+ }
633
+ this.loadingBackendCase = c.id;
634
+ const requestedFormulation = c.formulation;
635
+ try {
636
+ const [network, dcBaseSolution] = await Promise.all([
637
+ c.network ? Promise.resolve(c.network) : this.api.getNetwork(c.id),
638
+ // The server caches only the DC OPF base solution. Other formulations
639
+ // must hydrate from the browser Study so their cost, prices, flows, and
640
+ // voltage fields all come from the selected formulation.
641
+ requestedFormulation === 'dcopf' ? this.api.getSolution(c.id) : Promise.resolve(null)
642
+ ]);
643
+ if (!this.app.byId(c.id))
644
+ return;
645
+ c.network = network;
646
+ if (dcBaseSolution && c.formulation === requestedFormulation) {
647
+ c.baseSolution = dcBaseSolution;
648
+ c.solution = dcBaseSolution;
649
+ }
650
+ if (frame && this.app.activeCaseId === c.id)
651
+ this.app.requestFrame(c.id);
652
+ if (this.isActiveSolveCase(c) && c.formulation !== 'dcopf' && !c.solution && !c.solving) {
653
+ this.runSolve(c, this.selectionTarget);
654
+ }
655
+ }
656
+ catch (e) {
657
+ if (this.app.byId(c.id)) {
658
+ this.fail(`${c.name}: ${errorText(e)}`, () => this.loadBackendCase(c, true));
659
+ }
660
+ }
661
+ finally {
662
+ if (this.loadingBackendCase === c.id)
663
+ this.loadingBackendCase = null;
664
+ }
665
+ };
666
+ /** Leave the multiconductor view: a backend or local case becoming active is
667
+ * mutually exclusive with a multi case, so drop the multi's active/pending ids.
668
+ * The multi's per-bus expansion stays on the case for when it is reactivated. */
669
+ leaveMulti() {
670
+ this.app.activeMultiId = null;
671
+ this.app.placingMultiId = null;
672
+ }
673
+ activateCase = async (id) => {
674
+ this.app.activeLocalId = null;
675
+ this.app.placingLocalId = null;
676
+ this.leaveMulti();
677
+ if (this.app.activeCaseId !== id) {
678
+ this.clearSelection();
679
+ this.app.activeCaseId = id;
680
+ }
681
+ const c = this.app.byId(id);
682
+ if (c)
683
+ await this.loadBackendCase(c, true);
684
+ };
685
+ // Hydrate whichever case a removal promoted to active: a backend case needs its
686
+ // network/solution loaded (re-framing the map onto it), a local needs a browser
687
+ // solve. `none` (no case promoted, or the active case was untouched) is a no-op.
688
+ hydrateFallback = async (t) => {
689
+ if (t.kind === 'backend') {
690
+ const c = this.app.byId(t.id);
691
+ if (c)
692
+ await this.loadBackendCase(c, true);
693
+ }
694
+ else if (t.kind === 'local') {
695
+ this.maybeStartLocalSolve(t.id);
696
+ }
697
+ };
698
+ removeBackendCase = async (c, event) => {
699
+ event?.stopPropagation();
700
+ this.rememberHiddenDefaultCase(c.id);
701
+ // Tear down this case's own in-flight server stream whether or not it is the
702
+ // active case (a non-active case can still hold a live stream), and bump the
703
+ // seq so any detached handler no-ops.
704
+ c.closeStream?.();
705
+ c.closeStream = null;
706
+ c.solveSeq++;
707
+ this.disposeStudy(c);
708
+ if (this.app.activeCaseId === c.id)
709
+ this.clearSelection();
710
+ await this.hydrateFallback(this.app.removeCase(c.id));
711
+ };
712
+ activateLocal = (c) => {
713
+ this.clearSelection();
714
+ // Mirror activateCase's reset: a local and a backend case are mutually
715
+ // exclusive, so drop the backend selection. Otherwise app.active (derived
716
+ // from activeCaseId) stays set and its solve card keeps hovering over the
717
+ // local view.
718
+ this.app.activeCaseId = null;
719
+ this.leaveMulti();
720
+ this.app.activeLocalId = c.id;
721
+ this.app.placingLocalId = c.coordsKind === 'synthetic_pending' ? c.id : null;
722
+ if (c.view || c.substations)
723
+ this.app.requestFrame(c.id);
724
+ this.maybeStartLocalSolve(c.id);
725
+ };
726
+ removeLocalCase = async (c, event) => {
727
+ event?.stopPropagation();
728
+ if (this.app.activeLocalId === c.id) {
729
+ // Local cases solve in the browser only (no server stream); the seq bump
730
+ // invalidates any in-flight browser solve.
731
+ c.solveSeq = (c.solveSeq ?? 0) + 1;
732
+ this.clearSelection();
733
+ }
734
+ this.disposeStudy(c);
735
+ await this.hydrateFallback(this.app.removeLocal(c.id));
736
+ };
737
+ addAndActivateLocal = (c) => {
738
+ this.clearSelection();
739
+ this.app.activeCaseId = null;
740
+ this.leaveMulti();
741
+ this.app.addLocal(c);
742
+ if (c.view || c.substations)
743
+ this.app.requestFrame(c.id);
744
+ this.maybeStartLocalSolve(c.id);
745
+ };
746
+ placeLocalCase = async (lon, lat) => {
747
+ const id = this.app.placingLocalId;
748
+ const c = id ? this.app.localCases.find((lc) => lc.id === id) : null;
749
+ if (!c?.topology)
750
+ return;
751
+ c.view = placeSyntheticTopology(c.topology, { lon, lat });
752
+ c.coordsKind = 'synthetic';
753
+ c.syntheticCenter = { lon, lat };
754
+ this.app.placingLocalId = null;
755
+ this.app.activeLocalId = c.id;
756
+ this.app.requestFrame(c.id);
757
+ // Persist the layout before the solve kicks off, so the Study builds from
758
+ // the stamped payload and a save carries the placement.
759
+ await this.stampLayout(c);
760
+ this.maybeStartLocalSolve(c.id);
761
+ };
762
+ /** Write the case's on-screen layout into its network payload
763
+ * (`Bus.location` with `synthetic` provenance — the engine also accepts
764
+ * `manual`, which round trips through restored packages), so saves,
765
+ * exports, and the `.geo.json` download carry it. Best effort: on an
766
+ * engine failure the view still renders, only the layout's persistence is
767
+ * lost. */
768
+ stampLayout = async (c) => {
769
+ if (!c.networkJson || !c.view)
770
+ return;
771
+ try {
772
+ 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]);
776
+ }
777
+ catch {
778
+ // The on-screen view is unaffected; only persistence was lost.
779
+ }
780
+ };
781
+ /** Download the case's coordinates as a `.geo.json` layer: canonical
782
+ * GeoJSON with provenance stamped (`synthetic`/`manual` for layouts), the
783
+ * document powerio and tellegen both read back. */
784
+ downloadGeoLayer = async (c) => {
785
+ if (!c.networkJson)
786
+ return;
787
+ try {
788
+ downloadText(await extractGeo(c.networkJson), `${this.caseFileStem(c)}.geo.json`, 'application/geo+json');
789
+ this.app.error = null;
790
+ }
791
+ catch (e) {
792
+ this.app.error = `${this.caseName(c)}: could not export the layout: ${errorText(e)}`;
793
+ }
794
+ };
795
+ moveLocalCase = (c) => {
796
+ this.app.activeCaseId = null;
797
+ this.app.activeLocalId = c.id;
798
+ this.app.placingLocalId = c.id;
799
+ };
800
+ /** Apply parsed geo layers onto a local case, in drop order (a later file
801
+ * wins on shared elements). The engine owns matching and the network becomes
802
+ * the single owner of the coordinates: matched points land in
803
+ * `Bus.location`, routes in `Branch.route`, and the refreshed
804
+ * `network_json` replaces the case payload — so a saved package or export
805
+ * carries exactly what is on screen. A live Study is synced the same way.
806
+ * Throws when a layer matches nothing. */
807
+ applyGeoLayers = async (c, layers) => {
808
+ if (!c.networkJson || layers.length === 0)
809
+ return;
810
+ let payload = null;
811
+ let networkJson = c.networkJson;
812
+ const warnings = [];
813
+ let routes = 0;
814
+ let unmatched = 0;
815
+ for (const l of layers) {
816
+ payload = await applyGeo(networkJson, l.layer);
817
+ networkJson = payload.network_json;
818
+ routes += payload.report.matched_branches;
819
+ unmatched += payload.report.unmatched_features;
820
+ warnings.push(...l.warnings.map((w) => `${l.name}: ${w}`), ...payload.report.notes.map((n) => `${l.name}: ${n}`));
821
+ }
822
+ if (!payload)
823
+ return;
824
+ const sourceLabel = layers.map((l) => l.name).join(' + ');
825
+ this.adoptGeoPayload(c, payload, sourceLabel, sourceLabel, [
826
+ ...(routes > 0 ? [`${routes} branch route(s) matched from geographic file data`] : []),
827
+ ...(unmatched > 0 ? [`${unmatched} feature(s) matched no case element`] : []),
828
+ ...warnings
829
+ ]);
830
+ await this.syncCachedStudy(c, networkJson, layers.map((l) => l.layer));
831
+ };
832
+ /** Adopt an engine geo apply onto the case: the refreshed payload becomes
833
+ * the case's network and view, provenance flips to the geographic source,
834
+ * and the warnings panel gets the placement summary. */
835
+ adoptGeoPayload(c, payload, sourceLabel, placedFrom, extraWarnings) {
836
+ c.networkJson = payload.network_json;
837
+ c.view = payload.view;
838
+ c.coordsKind = 'geofile';
839
+ c.syntheticCenter = undefined;
840
+ c.geoSource = sourceLabel;
841
+ c.geoWarnings = [
842
+ `${payload.view?.buses.length ?? 0} of ${payload.n_bus} buses placed from ${placedFrom}`,
843
+ ...extraWarnings,
844
+ ...payload.warnings
845
+ ];
846
+ }
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) => {
851
+ const cached = this.caseStudies.get(c);
852
+ if (!cached)
853
+ return;
854
+ cached.networkJson = networkJson;
855
+ for (const layer of layers)
856
+ await cached.study.applyGeoLayer(layer);
857
+ };
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';
865
+ return;
866
+ }
867
+ try {
868
+ await this.applyGeoLayers(target, layers);
869
+ this.app.activeCaseId = null;
870
+ this.leaveMulti();
871
+ this.app.activeLocalId = target.id;
872
+ this.app.placingLocalId = null;
873
+ this.app.requestFrame(target.id);
874
+ this.maybeStartLocalSolve(target.id);
875
+ this.app.error = null;
876
+ }
877
+ catch (e) {
878
+ this.app.error = `${layers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`;
879
+ }
880
+ };
881
+ /** Ask the map to frame the selected branch and wait for the camera to land,
882
+ * capped by FOCUS_SETTLE_CAP_MS and cut short by the selection's abort. */
883
+ awaitFocus(caseId, branchId, ac) {
884
+ return Promise.race([
885
+ this.app.requestFrame({ caseId, branchId }),
886
+ delayUntilFocusSettles(FOCUS_SETTLE_CAP_MS, ac.signal)
887
+ ]);
888
+ }
889
+ /** One selection flow for bus and branch targets on backend cases: optional
890
+ * camera focus first (so the fly-to is not janked by the solve), the browser
891
+ * Study column, then the one server reconciliation for DC OPF without rating
892
+ * edits. */
893
+ selectTarget = async (caseId, target, focus) => {
894
+ this.app.activeLocalId = null;
895
+ this.app.placingLocalId = null;
896
+ this.leaveMulti();
897
+ if (this.app.activeCaseId !== caseId) {
898
+ this.clearSelection();
899
+ this.app.activeCaseId = caseId;
900
+ }
901
+ const c = this.app.byId(caseId);
902
+ if (!c)
903
+ return;
904
+ const { ac, sensitivitySeq } = 'bus' in target
905
+ ? this.beginBusSelection(c, target.bus)
906
+ : this.beginBranchSelection(c, target.branch);
907
+ const retry = () => 'bus' in target
908
+ ? this.selectBus(caseId, target.bus)
909
+ : this.selectBranch(caseId, target.branch);
910
+ // True once the browser path fully settled the selection (column accepted or
911
+ // a terminal message shown); false leaves DC OPF one server reconciliation.
912
+ let handled = false;
913
+ try {
914
+ if (focus && 'branch' in target) {
915
+ await this.awaitFocus(caseId, target.branch, ac);
916
+ if (ac.signal.aborted)
917
+ return;
918
+ }
919
+ try {
920
+ // The column from the browser Study (under the case's formulation). DC OPF
921
+ // may reconcile a null column via the server; AC OPF / SOCWR are browser
922
+ // 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);
926
+ if (!ac.signal.aborted && sensitivity) {
927
+ this.acceptSensitivity(c, sensitivity, target, sensitivitySeq);
928
+ handled = true;
929
+ }
930
+ else if (!ac.signal.aborted && c.formulation !== 'dcopf') {
931
+ this.failColumnUnavailable(c, target, retry);
932
+ handled = true;
933
+ }
934
+ }
935
+ else if (c.formulation !== 'dcopf') {
936
+ if (!ac.signal.aborted)
937
+ this.failColumnUnavailable(c, target, retry);
938
+ handled = true;
939
+ }
940
+ }
941
+ catch (e) {
942
+ // The browser path threw; the DC OPF server reconciliation below still
943
+ // applies. AC OPF / SOCWR have no server fallback (nothing is solved on
944
+ // the server), so surface the error there instead of leaving the click
945
+ // looking dead.
946
+ if (c.formulation !== 'dcopf') {
947
+ if (!ac.signal.aborted && sensitivitySeq === (c.sensitivitySeq ?? 0)) {
948
+ this.fail(`${this.caseName(c)}: ${errorText(e)}`, retry);
949
+ }
950
+ handled = true;
951
+ }
952
+ }
953
+ if (!handled && !ac.signal.aborted && c.formulation === 'dcopf') {
954
+ if (this.hasRatingEdits(c)) {
955
+ // The server solves at base ratings, so its column would come from the
956
+ // wrong operating point once rating edits are committed. Fail instead
957
+ // of silently reconciling there.
958
+ this.fail(this.ratingEditsFallbackError(c), retry);
959
+ }
960
+ else {
961
+ await this.fetchServerColumn(c, target, ac, sensitivitySeq);
962
+ }
963
+ }
964
+ }
965
+ finally {
966
+ if (this.abort === ac)
967
+ this.app.sensitivityLoading = false;
968
+ }
969
+ };
970
+ selectBus = (caseId, busId) => this.selectTarget(caseId, { bus: busId }, false);
971
+ /** The compute-off terminal copy. Name the browser engine as the cause only
972
+ * when its failure is known permanent; otherwise the miss may have been
973
+ * transient (a wasm chunk or case JSON fetch), so say what is known and keep
974
+ * the retry meaningful. */
975
+ computeOffNotice(c) {
976
+ return this.studyUnavailable.has(c) ? WASM_REQUIRED_NOTICE : COMPUTE_OFF_NOTICE;
977
+ }
978
+ /** The one server ∂LMP/∂parameter request a selection may make: the
979
+ * reconciliation path when the browser Study produced no column for a DC OPF
980
+ * case. Never retries on its own: a second request inside the same rate limit
981
+ * window is a guaranteed rejection, and the retry button covers the user
982
+ * need. A 429 opens a cooldown for the rest of the window; the server's own
983
+ * compute-disabled 403 latches serverComputeOff for the session (matched on
984
+ * `serverMessage` so a proxy or WAF 403 cannot latch a false diagnosis). */
985
+ fetchServerColumn = async (c, target, ac, sensitivitySeq) => {
986
+ const retryOp = 'bus' in target
987
+ ? () => this.selectBus(c.id, target.bus)
988
+ : () => this.selectBranch(c.id, target.branch);
989
+ if (this.serverComputeOff) {
990
+ this.fail(this.computeOffNotice(c), retryOp);
991
+ return;
992
+ }
993
+ if (Date.now() < this.sensitivity429Until) {
994
+ this.fail('rate limited; wait a few seconds and try again', retryOp);
995
+ return;
996
+ }
997
+ try {
998
+ const col = 'bus' in target
999
+ ? await this.api.getSensitivity(c.id, target.bus, c.deltas, ac.signal)
1000
+ : await this.api.getBranchSensitivity(c.id, target.branch, c.deltas, ac.signal);
1001
+ if (!ac.signal.aborted) {
1002
+ // The solution on screen came from the browser or the served base
1003
+ // solve; the column came from the server. Label the mix.
1004
+ c.solveBackend = 'clarabel-wasm-server-sensitivity';
1005
+ this.acceptSensitivity(c, col, target, sensitivitySeq);
1006
+ }
1007
+ }
1008
+ catch (e) {
1009
+ if (e instanceof DOMException)
1010
+ return;
1011
+ if (e instanceof ApiError &&
1012
+ e.status === 403 &&
1013
+ e.serverMessage === 'server compute is disabled') {
1014
+ this.serverComputeOff = true;
1015
+ if (!ac.signal.aborted && sensitivitySeq === (c.sensitivitySeq ?? 0)) {
1016
+ this.fail(this.computeOffNotice(c), retryOp);
1017
+ }
1018
+ return;
1019
+ }
1020
+ if (e instanceof ApiError && e.status === 429) {
1021
+ this.sensitivity429Until = Date.now() + 10_000;
1022
+ }
1023
+ // A late failure must not clobber a newer selection's state.
1024
+ if (!ac.signal.aborted && sensitivitySeq === (c.sensitivitySeq ?? 0)) {
1025
+ this.fail(errorText(e), retryOp);
1026
+ }
1027
+ }
1028
+ };
1029
+ /** Show an error with an optional one-click re-run of the failed operation. */
1030
+ fail(message, retry = null) {
1031
+ this.app.error = message;
1032
+ this.app.errorRetry = retry;
1033
+ }
1034
+ /** The panel's retry button: re-run the failed operation when one is known,
1035
+ * else reload the case list. An explicit retry always ends a 429 cooldown. */
1036
+ retryError = () => {
1037
+ const op = this.app.errorRetry;
1038
+ this.app.error = null;
1039
+ this.sensitivity429Until = 0;
1040
+ (op ?? this.load)();
1041
+ };
1042
+ selectLocalBus = async (localId, busId) => {
1043
+ const c = this.app.localCases.find((lc) => lc.id === localId);
1044
+ if (!c?.networkJson || !c.network)
1045
+ return;
1046
+ this.app.activeCaseId = null;
1047
+ this.leaveMulti();
1048
+ this.app.activeLocalId = localId;
1049
+ this.app.placingLocalId = null;
1050
+ const { ac, sensitivitySeq } = this.beginBusSelection(c, busId);
1051
+ try {
1052
+ const sensitivity = await this.browserSensitivity(c, c.networkJson, { bus: busId });
1053
+ if (!ac.signal.aborted)
1054
+ this.acceptSensitivity(c, sensitivity, { bus: busId }, sensitivitySeq);
1055
+ if (!ac.signal.aborted && !sensitivity) {
1056
+ // A null column means the solve ran but produced no dLMP/dd for this bus (or the
1057
+ // 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)'}`;
1060
+ }
1061
+ }
1062
+ catch (e) {
1063
+ if (!ac.signal.aborted) {
1064
+ this.fail(`${c.label}: ${errorText(e)}`, () => this.selectLocalBus(localId, busId));
1065
+ }
1066
+ }
1067
+ finally {
1068
+ if (this.abort === ac)
1069
+ this.app.sensitivityLoading = false;
1070
+ }
1071
+ };
1072
+ /** The terminal message when a case with committed rating edits cannot use the
1073
+ * browser Study: the server fallback solves at base ratings, so there is no
1074
+ * correct fallback to take. */
1075
+ ratingEditsFallbackError(c) {
1076
+ return `${this.caseName(c)}: line rating edits solve only in the browser engine${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`;
1077
+ }
1078
+ /** The terminal message when the browser produced no column and the server
1079
+ * cannot reconcile (non-DC formulation, or a local case with no server). */
1080
+ failColumnUnavailable(c, target, retry) {
1081
+ const what = 'bus' in target
1082
+ ? `${formulationLabel(c.formulation)} sensitivity`
1083
+ : '∂LMP/∂rating sensitivity';
1084
+ this.fail(`${this.caseName(c)}: ${what} unavailable${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`, retry);
1085
+ }
1086
+ /** `focus` frames the selected line on the map first; the sensitivity solve
1087
+ * waits for the camera to land so the fly-to stays smooth. */
1088
+ selectBranch = (caseId, branchId, opts = {}) => this.selectTarget(caseId, { branch: branchId }, opts.focus ?? false);
1089
+ selectLocalBranch = async (localId, branchId, opts = {}) => {
1090
+ const c = this.app.localCases.find((lc) => lc.id === localId);
1091
+ if (!c?.networkJson || !c.network)
1092
+ return;
1093
+ this.app.activeCaseId = null;
1094
+ this.leaveMulti();
1095
+ this.app.activeLocalId = localId;
1096
+ this.app.placingLocalId = null;
1097
+ const { ac, sensitivitySeq } = this.beginBranchSelection(c, branchId);
1098
+ const retry = () => this.selectLocalBranch(localId, branchId);
1099
+ try {
1100
+ if (opts.focus) {
1101
+ await this.awaitFocus(localId, branchId, ac);
1102
+ if (ac.signal.aborted)
1103
+ return;
1104
+ }
1105
+ const sensitivity = await this.browserSensitivity(c, c.networkJson, { branch: branchId });
1106
+ if (ac.signal.aborted)
1107
+ return;
1108
+ if (sensitivity) {
1109
+ this.acceptSensitivity(c, sensitivity, { branch: branchId }, sensitivitySeq);
1110
+ }
1111
+ else if (sensitivitySeq === (c.sensitivitySeq ?? 0)) {
1112
+ this.failColumnUnavailable(c, { branch: branchId }, retry);
1113
+ }
1114
+ }
1115
+ catch (e) {
1116
+ if (!ac.signal.aborted && sensitivitySeq === (c.sensitivitySeq ?? 0)) {
1117
+ this.fail(`${c.label}: ${errorText(e)}`, retry);
1118
+ }
1119
+ }
1120
+ finally {
1121
+ if (this.abort === ac)
1122
+ this.app.sensitivityLoading = false;
1123
+ }
1124
+ };
1125
+ clearSelection = () => {
1126
+ this.abort?.abort();
1127
+ const c = this.app.active;
1128
+ if (c) {
1129
+ c.sensitivitySeq++;
1130
+ c.sensitivity = null;
1131
+ }
1132
+ const lc = this.app.activeLocal;
1133
+ if (lc) {
1134
+ lc.sensitivitySeq = (lc.sensitivitySeq ?? 0) + 1;
1135
+ lc.sensitivity = null;
1136
+ }
1137
+ // Multiconductor cases carry their own bus selection (a string id); clear it
1138
+ // so an Escape or empty-map click collapses the expanded terminal detail.
1139
+ const mc = this.app.activeMulti;
1140
+ if (mc)
1141
+ mc.selectedBusId = null;
1142
+ this.app.selectedBus = null;
1143
+ this.app.selectedBranch = null;
1144
+ this.app.previewDeltaMw = null;
1145
+ this.app.previewRatingMw = null;
1146
+ this.app.previewActive = false;
1147
+ this.app.previewLmp = null;
1148
+ this.previewObjective = null;
1149
+ this.ratingSlope = null;
1150
+ this.app.demandRangeMode = 'local';
1151
+ this.nearbyRangeAnchor = null;
1152
+ this.app.sensitivityLoading = false;
1153
+ };
1154
+ // Exact solve in the browser (wasm). The build-once Study commits the new
1155
+ // operating point without re-parsing, returning the ∂LMP/∂parameter column for
1156
+ // the selection target in the same solve; on a Study failure or missing network
1157
+ // JSON it reconciles via the server stream (backend cases). Rating edits never
1158
+ // fall back: the server solves at base ratings, so a Study failure there is
1159
+ // terminal.
1160
+ runSolve = (c, target) => {
1161
+ // Cancel this case's own previous server stream, if any (backend only).
1162
+ if (this.isBackendCase(c)) {
1163
+ c.closeStream?.();
1164
+ c.closeStream = null;
1165
+ }
1166
+ c.solveSeq = (c.solveSeq ?? 0) + 1;
1167
+ const seq = c.solveSeq;
1168
+ this.app.error = null;
1169
+ c.solving = true;
1170
+ c.solveBackend = null;
1171
+ c.solveFallbackReason = null;
1172
+ c.iterations = [];
1173
+ c.solveMs = null;
1174
+ this.ensureNetworkJson(c).then(async (networkJson) => {
1175
+ if (seq !== (c.solveSeq ?? 0))
1176
+ return;
1177
+ if (!networkJson) {
1178
+ c.solveFallbackReason ??= 'browser network JSON unavailable';
1179
+ if (this.hasRatingEdits(c)) {
1180
+ c.solving = false;
1181
+ this.app.error = this.ratingEditsFallbackError(c);
1182
+ return;
1183
+ }
1184
+ if (this.isBackendCase(c))
1185
+ return this.serverSolve(c, target, seq);
1186
+ c.solving = false;
1187
+ this.app.error = `${c.label}: local case has no browser network JSON`;
1188
+ return;
1189
+ }
1190
+ const t0 = performance.now();
1191
+ c.solveBackend = 'clarabel-wasm';
1192
+ // Build-once Study path: commit the new operating point (no re-parse). The
1193
+ // ∂LMP/∂parameter column for the selection target is computed in the same
1194
+ // solve and comes back with it, so there is no second solve to reconcile it.
1195
+ const study = await this.getStudy(c, networkJson);
1196
+ if (seq !== (c.solveSeq ?? 0))
1197
+ return;
1198
+ if (study) {
1199
+ try {
1200
+ const cached = this.caseStudies.get(c);
1201
+ if (!c.baseSolution && cached?.study === study)
1202
+ c.baseSolution = cached.baseSolution;
1203
+ const { solution, iterations, sensitivity } = await study.commit(c.id, this.caseDeltas(c), this.caseRatings(c), target);
1204
+ if (seq !== (c.solveSeq ?? 0))
1205
+ return;
1206
+ c.solution = solution;
1207
+ c.iterations = iterations;
1208
+ if (!c.baseSolution && Object.keys(this.caseDeltas(c)).length === 0)
1209
+ c.baseSolution = solution;
1210
+ c.solveMs = Math.round(performance.now() - t0);
1211
+ // The commit carried the sensitivity column; accept it for the selection
1212
+ // through the same seq-guarded setter every sensitivity source goes through.
1213
+ if (target !== null && sensitivity)
1214
+ this.acceptSensitivity(c, sensitivity, target);
1215
+ this.finishSolve(c, seq, target);
1216
+ return;
1217
+ }
1218
+ catch (e) {
1219
+ if (seq !== (c.solveSeq ?? 0))
1220
+ return;
1221
+ const msg = errorText(e);
1222
+ // For AC OPF / SOCWR (browser-only) an infeasible operating point is an
1223
+ // expected outcome — demand pushed past what the network can serve — not a
1224
+ // broken Study. Keep the Study (it solves other points) and say so plainly,
1225
+ // instead of the misleading "study unavailable".
1226
+ if (c.formulation !== 'dcopf' && /infeasible/i.test(msg)) {
1227
+ c.solving = false;
1228
+ this.app.previewActive = false;
1229
+ this.app.previewDeltaMw = null;
1230
+ this.app.previewRatingMw = null;
1231
+ this.app.error = `${this.caseName(c)}: ${formulationLabel(c.formulation)} has no feasible solution at this demand`;
1232
+ return;
1233
+ }
1234
+ // Any other commit failure is unexpected; drop the Study so the next solve
1235
+ // rebuilds, then fall through to the server fallback.
1236
+ this.disposeStudy(c);
1237
+ c.solveFallbackReason ??= `browser study commit failed: ${msg}`;
1238
+ }
1239
+ }
1240
+ // The server stream below only solves DC OPF, so it is valid only for the
1241
+ // DC formulation. For AC OPF / SOCWR the Study is the sole solver (nothing
1242
+ // is solved on the server), so a Study failure there is terminal: surface
1243
+ // it rather than silently returning a DC solution.
1244
+ if (c.formulation !== 'dcopf') {
1245
+ if (seq !== (c.solveSeq ?? 0))
1246
+ return;
1247
+ c.solving = false;
1248
+ const why = c.solveFallbackReason ?? 'browser study unavailable';
1249
+ this.app.error = `${this.caseName(c)}: ${formulationLabel(c.formulation)} runs only in the browser Study, which is unavailable: ${why}`;
1250
+ return;
1251
+ }
1252
+ // Neither the server stream nor any other path can apply rating edits;
1253
+ // falling through would silently solve at base ratings, so stop here instead.
1254
+ if (this.hasRatingEdits(c)) {
1255
+ if (seq !== (c.solveSeq ?? 0))
1256
+ return;
1257
+ c.solving = false;
1258
+ this.app.error = this.ratingEditsFallbackError(c);
1259
+ return;
1260
+ }
1261
+ // Fallback: the server stream for backend cases. The Study is the only
1262
+ // browser solver, so a local case with a failed Study is terminal.
1263
+ if (seq !== (c.solveSeq ?? 0))
1264
+ return;
1265
+ if (this.isBackendCase(c)) {
1266
+ this.serverSolve(c, target, seq);
1267
+ return;
1268
+ }
1269
+ c.solving = false;
1270
+ this.app.error = `${c.label}: browser solve unavailable${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`;
1271
+ });
1272
+ };
1273
+ serverSolve = (c, target, seq = c.solveSeq) => {
1274
+ // Known-disabled server compute: show the notice instead of opening a
1275
+ // stream that answers 403 without a readable body (EventSource hides it).
1276
+ if (this.serverComputeOff) {
1277
+ if (seq !== c.solveSeq)
1278
+ return;
1279
+ c.solving = false;
1280
+ if (this.isActiveSolveCase(c)) {
1281
+ this.app.previewActive = false;
1282
+ this.app.previewDeltaMw = null;
1283
+ this.app.previewRatingMw = null;
1284
+ }
1285
+ this.fail(this.computeOffNotice(c), () => this.runSolve(c, target));
1286
+ return;
1287
+ }
1288
+ c.solveBackend = 'rust-server';
1289
+ c.solveFallbackReason ??= 'browser solve unavailable';
1290
+ // The server computes bus columns only; a branch target streams no sensitivity.
1291
+ const sensBus = target && 'bus' in target ? target.bus : null;
1292
+ c.closeStream = this.api.openSolveStream(c.id, c.deltas, sensBus, {
1293
+ onsolution: (sol) => {
1294
+ if (seq !== c.solveSeq)
1295
+ return;
1296
+ c.solution = sol;
1297
+ c.iterations = sol.iterations ?? [];
1298
+ c.solveMs = sol.solve_ms;
1299
+ },
1300
+ onsensitivity: (col) => {
1301
+ if (seq !== c.solveSeq)
1302
+ return;
1303
+ this.acceptSensitivity(c, col, target);
1304
+ },
1305
+ onfail: (msg) => {
1306
+ if (seq !== c.solveSeq)
1307
+ return;
1308
+ c.solving = false;
1309
+ // Only the active case owns the global preview fields (mirror finishSolve); a
1310
+ // background case's fallback failure must not collapse the active case's preview.
1311
+ if (this.isActiveSolveCase(c)) {
1312
+ this.app.previewActive = false;
1313
+ this.app.previewDeltaMw = null;
1314
+ this.app.previewRatingMw = null;
1315
+ }
1316
+ this.fail(`${this.caseName(c)}: ${formulationLabel(c.formulation)} ${msg}`, () => this.runSolve(c, target));
1317
+ },
1318
+ ondone: () => {
1319
+ this.finishSolve(c, seq, target);
1320
+ }
1321
+ });
1322
+ };
1323
+ commitDelta = (value) => {
1324
+ const c = this.activeSolvable;
1325
+ const bus = this.app.selectedBus;
1326
+ if (!c || bus === null)
1327
+ return;
1328
+ // Refresh the engine preview at the commit value (a typed value may not have
1329
+ // driven a drag), then score the commit with the engine's predicted Δobjective.
1330
+ this.runPreview(c, bus, value);
1331
+ c.predictedObjective = this.predictedDeltaObj;
1332
+ c.deltas = this.previewDeltas(c, bus, value);
1333
+ this.app.previewDeltaMw = value;
1334
+ this.app.previewActive = true;
1335
+ this.runSolve(c, { bus });
1336
+ };
1337
+ finishDemandInput = (value) => {
1338
+ if (Math.abs(value - this.committedDelta) < 0.25) {
1339
+ if (!this.activeSolvable?.solving) {
1340
+ this.app.previewActive = false;
1341
+ this.app.previewDeltaMw = null;
1342
+ }
1343
+ return;
1344
+ }
1345
+ this.commitDelta(value);
1346
+ };
1347
+ resetCase = (c) => {
1348
+ c.deltas = {};
1349
+ c.ratings = {};
1350
+ c.predictedObjective = null;
1351
+ this.app.previewLmp = null;
1352
+ this.previewObjective = null;
1353
+ this.ratingSlope = null;
1354
+ this.app.previewDeltaMw = this.app.selectedBus === null ? null : 0;
1355
+ this.app.previewRatingMw = this.app.selectedBranch === null ? null : 0;
1356
+ this.app.previewActive = this.app.selectedBus !== null || this.app.selectedBranch !== null;
1357
+ this.app.demandRangeMode = 'local';
1358
+ if (this.app.selectedBus !== null)
1359
+ this.setNearbyRangeAnchor(c, this.app.selectedBus, 0);
1360
+ if (c.baseSolution)
1361
+ c.solution = c.baseSolution;
1362
+ this.runSolve(c, this.selectionTarget);
1363
+ };
1364
+ // Switch the active case to a new OPF formulation. Solving every formulation stays
1365
+ // entirely in the browser via the Study (nothing is routed to the server), so this
1366
+ // disposes the old Study — `getStudy` rebuilds it for the new formulation, re-parsing
1367
+ // and re-solving the base — then re-solves at the committed demand. The base solution
1368
+ // is dropped so it is recaptured under the new formulation (a DC and an AC objective
1369
+ // are not comparable). A no-op when the choice is unchanged.
1370
+ changeFormulation = (c, next) => {
1371
+ if (c.formulation === next)
1372
+ return;
1373
+ // Disabled menu items (e.g. AC OPF, coming soon) are not selectable in the engine yet.
1374
+ if (FORMULATIONS.find((f) => f.id === next)?.disabled)
1375
+ return;
1376
+ c.formulation = next;
1377
+ // The committed point carries over (same demand), but the model and its solution do
1378
+ // not; drop the Study and the cached solutions so they rebuild under `next`.
1379
+ this.disposeStudy(c);
1380
+ c.baseSolution = null;
1381
+ c.solution = null;
1382
+ c.iterations = [];
1383
+ c.solveMs = null;
1384
+ c.predictedObjective = null;
1385
+ this.app.previewLmp = null;
1386
+ this.previewObjective = null;
1387
+ this.ratingSlope = null;
1388
+ this.app.error = null;
1389
+ // The current sensitivity column was computed under the old formulation; clear it so
1390
+ // the overlay recomputes (the Study returns the new column with the re-solve below).
1391
+ c.sensitivity = null;
1392
+ c.sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
1393
+ this.runSolve(c, this.selectionTarget);
1394
+ };
1395
+ /** Parse dropped files in the browser via the powerio wasm module. Case
1396
+ * files (.m, .raw, .aux) become local networks; geographic files can
1397
+ * place those networks; a PowerWorld .pwd becomes a substation point
1398
+ * preview. Files run serially; nothing uploads. */
1399
+ ingestFiles = async (files) => {
1400
+ const list = Array.from(files);
1401
+ let parsedCaseCount = 0;
1402
+ // True once a dropped case file took the geographic sidecars; a restored
1403
+ // package or an existing case only takes them when no case file did.
1404
+ let geoLayersConsumed = false;
1405
+ // Routable `.json` content (a saved study package, a multiconductor package,
1406
+ // a BMOPF/PMD document) shares the `.json` extension with a geographic file,
1407
+ // so classify and consume it up front: the geo/case split below sends every
1408
+ // `.json` to the coordinate parser, which would misread these as coordinate
1409
+ // data. Unroutable `.json` files fall through unchanged.
1410
+ const rest = [];
1411
+ let restoredAnyPackage = false;
1412
+ for (const file of list) {
1413
+ if (file.name.toLowerCase().endsWith('.json')) {
1414
+ const outcome = await this.ingestJsonDrop(file);
1415
+ if (outcome === 'restored' || outcome === 'viewed') {
1416
+ parsedCaseCount++;
1417
+ restoredAnyPackage ||= outcome === 'restored';
1418
+ continue;
1419
+ }
1420
+ if (outcome === 'failed')
1421
+ continue;
1422
+ }
1423
+ rest.push(file);
1424
+ }
1425
+ const geoLayers = [];
1426
+ for (const file of rest.filter((f) => isGeoFileName(f.name))) {
1427
+ this.app.parsingFile = true;
1428
+ try {
1429
+ const parsed = await parseGeo(new Uint8Array(await file.arrayBuffer()), file.name);
1430
+ geoLayers.push({ name: file.name, layer: parsed.layer, warnings: parsed.warnings });
1431
+ this.app.error = null;
1432
+ }
1433
+ catch (e) {
1434
+ this.app.error = `${file.name}: ${errorText(e)}`;
1435
+ }
1436
+ finally {
1437
+ this.app.parsingFile = false;
1438
+ }
1439
+ }
1440
+ // PowerWorld .pwd displays parse up front so a coordinate-less case in the
1441
+ // same drop can borrow their substation points (the SubNum join); one that
1442
+ // no case consumes becomes its own substation preview entry below.
1443
+ const displays = [];
1444
+ for (const file of rest.filter((f) => isDisplayFile(f.name))) {
1445
+ this.app.parsingFile = true;
1446
+ try {
1447
+ const bytes = new Uint8Array(await file.arrayBuffer());
1448
+ displays.push({ file, bytes, preview: await parseDisplay(bytes), consumed: false });
1449
+ this.app.error = null;
1450
+ }
1451
+ catch (e) {
1452
+ this.app.error = `${file.name}: ${errorText(e)}`;
1453
+ }
1454
+ finally {
1455
+ this.app.parsingFile = false;
1456
+ }
1457
+ }
1458
+ for (const file of rest.filter((f) => !isGeoFileName(f.name) && !isDisplayFile(f.name))) {
1459
+ // OpenDSS routes by extension into the multiconductor viewing path.
1460
+ const distFormat = distExtensionFormat(file.name);
1461
+ if (distFormat) {
1462
+ if (await this.ingestDistFile(file, distFormat))
1463
+ parsedCaseCount++;
1464
+ continue;
1465
+ }
1466
+ const format = formatOf(file.name);
1467
+ if (!format) {
1468
+ this.app.error = `${file.name}: not a case or geographic file (.m, .raw, .aux, .dss, .pwd, .csv, .json, .geojson)`;
1469
+ continue;
1470
+ }
1471
+ this.app.parsingFile = true;
1472
+ try {
1473
+ const text = await file.text();
1474
+ const { network_json, topology, view, ...summary } = await ingestCase(text, format);
1475
+ if (format === 'aux' && (summary.n_branch === 0 || summary.n_gen === 0)) {
1476
+ this.app.error = `${file.name}: aux parsed, but no complete network; drop the matching .m or .raw case file`;
1477
+ continue;
1478
+ }
1479
+ const id = `local-${++this.localSeq}`;
1480
+ const label = summary.name && summary.name !== 'case'
1481
+ ? summary.name
1482
+ : file.name.replace(/\.[^.]+$/, '');
1483
+ const local = new LocalCase({
1484
+ id,
1485
+ label,
1486
+ fileName: file.name,
1487
+ summary,
1488
+ networkJson: network_json,
1489
+ topology,
1490
+ coordsKind: summary.coords_kind,
1491
+ view
1492
+ });
1493
+ // A co-dropped geographic file places (or repositions) the network. Apply
1494
+ // it for any parsed case, not just synthetic ones, so a geo overlay
1495
+ // dropped alongside a case that already has coordinates takes effect
1496
+ // instead of being silently discarded. A failed apply keeps the parsed
1497
+ // case (placement still works) and surfaces the reason.
1498
+ let geoError = null;
1499
+ if (geoLayers.length > 0 && local.networkJson) {
1500
+ try {
1501
+ await this.applyGeoLayers(local, geoLayers);
1502
+ geoLayersConsumed = true;
1503
+ }
1504
+ catch (e) {
1505
+ geoError = `${geoLayers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`;
1506
+ }
1507
+ }
1508
+ else if (local.coordsKind === 'synthetic_pending' && local.networkJson) {
1509
+ // No sidecar: a coordinate-less case may still borrow a co-dropped
1510
+ // .pwd's substation points through the SubNum join.
1511
+ await this.fillFromDisplaySibling(local, displays);
1512
+ }
1513
+ this.addAndActivateLocal(local);
1514
+ parsedCaseCount++;
1515
+ this.app.error = geoError; // a successful parse clears a prior file's error
1516
+ }
1517
+ catch (e) {
1518
+ this.app.error = `${file.name}: ${e instanceof Error ? e.message : e}`;
1519
+ }
1520
+ finally {
1521
+ this.app.parsingFile = false;
1522
+ }
1523
+ }
1524
+ // A .pwd no case consumed becomes a substation point preview entry, at
1525
+ // the approximate positions the engine projected.
1526
+ for (const d of displays.filter((entry) => !entry.consumed)) {
1527
+ const points = d.preview.substations.map((s) => ({
1528
+ number: s.number,
1529
+ name: s.name,
1530
+ lon: s.lon,
1531
+ lat: s.lat
1532
+ }));
1533
+ this.addAndActivateLocal(new LocalCase({
1534
+ id: `local-${++this.localSeq}`,
1535
+ label: d.file.name.replace(/\.[^.]+$/, ''),
1536
+ fileName: d.file.name,
1537
+ summary: null,
1538
+ view: null,
1539
+ substations: { points, approximate: true }
1540
+ }));
1541
+ }
1542
+ // Unconsumed sidecars apply to the restored package (now the active local
1543
+ // case) so a saved study co-dropped with its coordinates places, or to an
1544
+ // existing case when nothing in the drop parsed at all.
1545
+ if (geoLayers.length > 0 && !geoLayersConsumed && (restoredAnyPackage || parsedCaseCount === 0)) {
1546
+ await this.applyGeoLayersToExisting(geoLayers);
1547
+ }
1548
+ };
1549
+ /** Fill a coordinate-less case's positions from a co-dropped PowerWorld
1550
+ * `.pwd`: the display's substation points project to approximate
1551
+ * longitude/latitude and join onto buses through the `SubNum` extras key.
1552
+ * The first display that joins wins; one that matches nothing stays
1553
+ * available as its own preview entry, and the case stays placeable. */
1554
+ fillFromDisplaySibling = async (c, displays) => {
1555
+ if (!c.networkJson)
1556
+ return;
1557
+ for (const d of displays.filter((entry) => !entry.consumed)) {
1558
+ try {
1559
+ const payload = await applyDisplayGeo(c.networkJson, d.bytes);
1560
+ this.adoptGeoPayload(c, payload, d.file.name, `${d.file.name} substations`, payload.report.notes);
1561
+ d.consumed = true;
1562
+ return;
1563
+ }
1564
+ catch {
1565
+ // No SubNum join against this display; try the next one.
1566
+ }
1567
+ }
1568
+ };
1569
+ /** Route a dropped `.json` by its content: a saved study package restores
1570
+ * (`restored`), a multiconductor package or a BMOPF/PMD distribution
1571
+ * document views (`viewed`), `failed` means the content looked routable but
1572
+ * the engine rejected it (a fail-closed message is set), and `not-package`
1573
+ * falls through to the caller's geo/case handling. `drop-classify` is the
1574
+ * single owner of the classification. */
1575
+ ingestJsonDrop = async (file) => {
1576
+ let text;
1577
+ try {
1578
+ text = await file.text();
1579
+ }
1580
+ catch {
1581
+ return 'not-package';
1582
+ }
1583
+ const kind = classifyJson(text);
1584
+ if (kind === 'not-json')
1585
+ return 'not-package';
1586
+ this.app.parsingFile = true;
1587
+ try {
1588
+ if (kind === 'balanced-package') {
1589
+ this.restoreLocalFromPackage(file.name, await loadPackage(text));
1590
+ this.app.error = null;
1591
+ return 'restored';
1592
+ }
1593
+ const format = kind === 'multiconductor-package' ? 'pio' : kind;
1594
+ const payload = await ingestDistCase(text, format);
1595
+ // BMOPF is the classifier's catch-all and its reader is liberal: an
1596
+ // arbitrary JSON object parses as an empty case (unknown fields land
1597
+ // in extras). Zero buses means this was not a distribution document;
1598
+ // leave the file to the geo sidecar path (which places coordinates
1599
+ // or reports its own precise error) instead of adding a phantom
1600
+ // empty multiconductor case.
1601
+ if (kind === 'bmopf' && payload.n_bus === 0)
1602
+ return 'not-package';
1603
+ this.addMultiCase(file.name, payload);
1604
+ this.app.error = null;
1605
+ return 'viewed';
1606
+ }
1607
+ catch (e) {
1608
+ this.app.error = `${file.name}: ${errorText(e)}`;
1609
+ return 'failed';
1610
+ }
1611
+ finally {
1612
+ this.app.parsingFile = false;
1613
+ }
1614
+ };
1615
+ /** Parse a dropped OpenDSS `.dss` file into a multiconductor viewing case.
1616
+ * Returns true when a case landed; a parse failure sets the panel error. */
1617
+ ingestDistFile = async (file, format) => {
1618
+ this.app.parsingFile = true;
1619
+ try {
1620
+ this.addMultiCase(file.name, await ingestDistCase(await file.text(), format));
1621
+ this.app.error = null;
1622
+ return true;
1623
+ }
1624
+ catch (e) {
1625
+ this.app.error = `${file.name}: ${errorText(e)}`;
1626
+ return false;
1627
+ }
1628
+ finally {
1629
+ this.app.parsingFile = false;
1630
+ }
1631
+ };
1632
+ /** Build a `MulticonductorCase` from an ingest payload and make it active.
1633
+ * Geographic cases place immediately; planar/synthetic cases enter placement
1634
+ * (a map click or "place on map") so the user picks their center. */
1635
+ addMultiCase(fileName, payload) {
1636
+ const { graph, ...summary } = payload;
1637
+ const coordsKind = payload.coords_kind;
1638
+ const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1639
+ const view = coordsKind === 'geographic' ? buildGeographicView(graph) : null;
1640
+ const c = new MulticonductorCase({
1641
+ id: `dist-${++this.localSeq}`,
1642
+ label,
1643
+ fileName,
1644
+ summary,
1645
+ graph,
1646
+ coordsKind,
1647
+ view
1648
+ });
1649
+ this.app.addMulti(c);
1650
+ if (c.placed)
1651
+ this.app.requestFrame(c.id);
1652
+ }
1653
+ /** Make a multiconductor case active, framing it when it is placed. */
1654
+ activateMulti = (c) => {
1655
+ this.clearSelection();
1656
+ this.app.activeCaseId = null;
1657
+ this.app.activeLocalId = null;
1658
+ this.app.activeMultiId = c.id;
1659
+ this.app.placingLocalId = null;
1660
+ this.app.placingMultiId = c.placed ? null : c.id;
1661
+ if (c.placed)
1662
+ this.app.requestFrame(c.id);
1663
+ };
1664
+ /** Select a bus in a multiconductor case: its terminal stack and incident
1665
+ * conductors expand. Selecting is viewing detail only — no solve. */
1666
+ selectMultiBus = (caseId, busId) => {
1667
+ const c = this.app.multiCases.find((mc) => mc.id === caseId);
1668
+ if (!c)
1669
+ return;
1670
+ this.app.activeCaseId = null;
1671
+ this.app.activeLocalId = null;
1672
+ this.app.activeMultiId = caseId;
1673
+ this.app.placingMultiId = null;
1674
+ c.selectedBusId = c.selectedBusId === busId ? null : busId;
1675
+ };
1676
+ /** Place a multiconductor case awaiting a center: fit its provided planar
1677
+ * coordinates when it carries them, else lay it out synthetically. */
1678
+ placeMultiCase = (lon, lat) => {
1679
+ const id = this.app.placingMultiId;
1680
+ const c = id ? this.app.multiCases.find((mc) => mc.id === id) : null;
1681
+ if (!c?.graph)
1682
+ return;
1683
+ c.view = placeMultiView(c.graph, { lon, lat });
1684
+ c.syntheticCenter = { lon, lat };
1685
+ this.app.placingMultiId = null;
1686
+ this.app.activeMultiId = c.id;
1687
+ this.app.requestFrame(c.id);
1688
+ };
1689
+ /** Re-enter placement for a multiconductor case, to move its layout. */
1690
+ moveMultiCase = (c) => {
1691
+ this.app.activeCaseId = null;
1692
+ this.app.activeLocalId = null;
1693
+ this.app.activeMultiId = c.id;
1694
+ this.app.placingMultiId = c.id;
1695
+ };
1696
+ removeMultiCase = async (c, event) => {
1697
+ event?.stopPropagation();
1698
+ if (this.app.activeMultiId === c.id)
1699
+ c.selectedBusId = null;
1700
+ await this.hydrateFallback(this.app.removeMulti(c.id));
1701
+ };
1702
+ /** The unified map click-to-place: a pending multiconductor case takes the
1703
+ * click first, otherwise a pending local case does. */
1704
+ placeCase = (lon, lat) => {
1705
+ if (this.app.placingMultiId)
1706
+ this.placeMultiCase(lon, lat);
1707
+ else
1708
+ this.placeLocalCase(lon, lat);
1709
+ };
1710
+ /** Build a local case from a restored study package: the same shape a dropped case
1711
+ * takes, with the saved demand/rating deltas and formulation pre-applied. */
1712
+ restoreLocalFromPackage = (fileName, pkg) => {
1713
+ const { network_json, topology, view, formulation, deltas, rates, ...summary } = pkg;
1714
+ const id = `local-${++this.localSeq}`;
1715
+ const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1716
+ const local = new LocalCase({
1717
+ id,
1718
+ label,
1719
+ fileName,
1720
+ summary,
1721
+ networkJson: network_json,
1722
+ topology,
1723
+ coordsKind: summary.coords_kind,
1724
+ view
1725
+ });
1726
+ local.deltas = deltas;
1727
+ local.ratings = rates;
1728
+ local.formulation = formulation;
1729
+ this.addAndActivateLocal(local);
1730
+ };
1731
+ /** Build (or reuse) the case's Study and commit it at the current edit state, so a
1732
+ * save or export captures exactly what is on screen. Null (with an error set) when no
1733
+ * network or Study is available. */
1734
+ syncedStudy = async (c) => {
1735
+ const networkJson = await this.ensureNetworkJson(c);
1736
+ if (!networkJson) {
1737
+ this.app.error = `${this.caseName(c)}: no network available to save`;
1738
+ return null;
1739
+ }
1740
+ const study = await this.getStudy(c, networkJson);
1741
+ if (!study) {
1742
+ this.app.error = `${this.caseName(c)}: the browser study is unavailable`;
1743
+ return null;
1744
+ }
1745
+ await study.commit(c.id, this.caseDeltas(c), this.caseRatings(c), null);
1746
+ return study;
1747
+ };
1748
+ /** Save the active study as a `.pio.json` package and download it. */
1749
+ saveStudyPackage = async (c) => {
1750
+ const study = await this.syncedStudy(c);
1751
+ if (!study)
1752
+ return;
1753
+ try {
1754
+ downloadText(await study.savePackage(), `${this.caseFileStem(c)}.pio.json`, 'application/json');
1755
+ this.app.error = null;
1756
+ }
1757
+ catch (e) {
1758
+ this.app.error = `${this.caseName(c)}: could not save study: ${errorText(e)}`;
1759
+ }
1760
+ };
1761
+ /** Export the active study's committed state to `format` and download it. Returns the
1762
+ * writer's fidelity warnings so the caller can surface them. */
1763
+ exportStudyAs = async (c, format) => {
1764
+ const study = await this.syncedStudy(c);
1765
+ if (!study)
1766
+ return [];
1767
+ try {
1768
+ const packageJson = await study.savePackage();
1769
+ const exported = await exportStudy(packageJson, studyCommitIndex(packageJson), format);
1770
+ downloadText(exported.text, `${this.caseFileStem(c)}.${exported.extension}`, 'text/plain');
1771
+ this.app.error = null;
1772
+ return exported.warnings;
1773
+ }
1774
+ catch (e) {
1775
+ this.app.error = `${this.caseName(c)}: could not export study: ${errorText(e)}`;
1776
+ return [];
1777
+ }
1778
+ };
1779
+ /** A filesystem-safe base name for a downloaded case file. */
1780
+ caseFileStem(c) {
1781
+ const base = this.isBackendCase(c) ? c.name : c.label || c.fileName.replace(/\.[^.]+$/, '');
1782
+ return base.replace(/[^\w.-]+/g, '_') || 'study';
1783
+ }
1784
+ setDemandRangeMode = (mode) => {
1785
+ this.app.demandRangeMode = mode;
1786
+ const c = this.activeSolvable;
1787
+ if (mode === 'local' && c && this.app.selectedBus !== null) {
1788
+ this.setNearbyRangeAnchor(c, this.app.selectedBus, this.sliderValue);
1789
+ }
1790
+ const bounds = this.demandBounds(mode, this.selectedBusData, mode === 'local' ? this.sliderValue : this.nearbyRangeCenter);
1791
+ if (this.app.previewDeltaMw === null)
1792
+ return;
1793
+ this.app.previewDeltaMw = Math.min(bounds.max, Math.max(bounds.min, this.app.previewDeltaMw));
1794
+ };
1795
+ // First-order engine preview for the live drag. Uses the case's already-built
1796
+ // Study (no re-parse, no re-solve) to paint predicted per-bus
1797
+ // ΔLMP and the predicted Δobjective. A no-op when the Study isn't built yet or
1798
+ // can't preview (browser fallback path, server-only cases): the map then
1799
+ // falls back to the JS sensitivity-times-step preview.
1800
+ runPreview = (c, bus, value) => {
1801
+ // Fast path: the committed ∂LMP/∂d column (already solved at the committed point),
1802
+ // scaled by the demand step, is the same first-order linearization the engine preview
1803
+ // returns — without rebuilding the differentiable KKT every drag frame. That engine
1804
+ // preview is ~80 ms/frame on the largest case (CATS, ~8870 buses), which blocked the
1805
+ // main thread and made dragging choppy; reusing the column is O(buses).
1806
+ const col = c.sensitivity;
1807
+ if (col && col.bus === bus) {
1808
+ const committedAtBus = this.caseDeltas(c)[bus] ?? 0;
1809
+ const step = (Math.abs(value) < 0.25 ? 0 : value) - committedAtBus;
1810
+ const delta = new Map();
1811
+ for (const v of col.values)
1812
+ delta.set(v.bus, v.value * step);
1813
+ this.app.previewLmp = { caseId: c.id, target: { bus }, delta };
1814
+ const lmpAtBus = c.solution?.lmp.find((l) => l.bus === bus)?.usd_per_mwh ?? null;
1815
+ this.previewObjective =
1816
+ lmpAtBus === null
1817
+ ? null
1818
+ : { caseId: c.id, target: { bus }, objectiveDelta: lmpAtBus * step };
1819
+ return;
1820
+ }
1821
+ // Fallback (no committed column yet): the engine's first-order preview, which
1822
+ // rebuilds the differentiable system. DC OPF only: the conic rebuild takes
1823
+ // seconds on the large cases. The preview is async (the engine runs in a
1824
+ // worker), so drag events queue at most one point: the newest requested
1825
+ // point runs next, intermediate points are dropped. Best effort; on any
1826
+ // failure the map keeps its own path.
1827
+ if (c.formulation !== 'dcopf' || !this.caseStudies.get(c))
1828
+ return;
1829
+ this.enginePreviewNext = { c, bus, value };
1830
+ void this.pumpEnginePreview();
1831
+ };
1832
+ // The newest demand drag point waiting on an engine preview, and whether a
1833
+ // preview call is in flight. Written by runPreview per input event; drained
1834
+ // one call at a time so previews never stack up behind a slow solve.
1835
+ enginePreviewNext = null;
1836
+ enginePreviewBusy = false;
1837
+ async pumpEnginePreview() {
1838
+ if (this.enginePreviewBusy)
1839
+ return;
1840
+ this.enginePreviewBusy = true;
1841
+ try {
1842
+ while (this.enginePreviewNext) {
1843
+ const { c, bus, value } = this.enginePreviewNext;
1844
+ this.enginePreviewNext = null;
1845
+ const study = this.caseStudies.get(c)?.study;
1846
+ if (!study)
1847
+ continue;
1848
+ try {
1849
+ const { lmp, objectiveDelta } = await study.preview(this.previewDeltas(c, bus, value), this.caseRatings(c));
1850
+ // Apply only while this point is still the live drag target: a
1851
+ // preview that resolves after a re-selection or a commit is stale.
1852
+ if (!this.app.previewActive ||
1853
+ this.activeSolvable !== c ||
1854
+ this.app.selectedBus !== bus)
1855
+ continue;
1856
+ const delta = new Map();
1857
+ for (const e of lmp)
1858
+ delta.set(e.bus, e.usd_per_mwh);
1859
+ this.app.previewLmp = { caseId: c.id, target: { bus }, delta };
1860
+ this.previewObjective =
1861
+ objectiveDelta === null ? null : { caseId: c.id, target: { bus }, objectiveDelta };
1862
+ }
1863
+ catch {
1864
+ if (this.activeSolvable === c && this.app.selectedBus === bus) {
1865
+ this.app.previewLmp = null;
1866
+ this.previewObjective = null;
1867
+ }
1868
+ }
1869
+ }
1870
+ }
1871
+ finally {
1872
+ this.enginePreviewBusy = false;
1873
+ }
1874
+ }
1875
+ setSliderPreview = (value) => {
1876
+ if (value === undefined)
1877
+ return;
1878
+ this.app.previewActive = true;
1879
+ this.app.previewDeltaMw = value;
1880
+ const c = this.activeSolvable;
1881
+ if (c && this.app.selectedBus !== null)
1882
+ this.runPreview(c, this.app.selectedBus, value);
1883
+ };
1884
+ // Ratings the live preview would commit: the committed rating deltas with the
1885
+ // slider's value at the selected branch, applying commitRating's dead zone so a
1886
+ // tiny nudge reads as "no change at this line".
1887
+ previewRatings(c, branch, value) {
1888
+ const ratings = { ...this.caseRatings(c) };
1889
+ if (Math.abs(value) < 0.25)
1890
+ delete ratings[branch];
1891
+ else
1892
+ ratings[branch] = value;
1893
+ return ratings;
1894
+ }
1895
+ // First-order engine preview for the live rating drag, mirroring runPreview: the
1896
+ // committed ∂LMP/∂rating column scaled by the rating step paints predicted per-bus
1897
+ // ΔLMP without touching the engine per frame. The predicted Δobjective comes from
1898
+ // a per-MW slope taken once from Study.preview at a +1 MW step off the committed
1899
+ // point (preview is replacement-absolute, so the absolute ratings map is built),
1900
+ // then scaled by the live step; null when no Study slope is available.
1901
+ runRatingPreview = (c, branch, value) => {
1902
+ const committedAtBranch = this.caseRatings(c)[branch] ?? 0;
1903
+ const step = (Math.abs(value) < 0.25 ? 0 : value) - committedAtBranch;
1904
+ const col = c.sensitivity;
1905
+ if (col && col.branch === branch) {
1906
+ const delta = new Map();
1907
+ for (const v of col.values)
1908
+ delta.set(v.bus, v.value * step);
1909
+ this.app.previewLmp = { caseId: c.id, target: { branch }, delta };
1910
+ }
1911
+ let cached = this.ratingSlope;
1912
+ if (cached?.caseId !== c.id || cached.branch !== branch) {
1913
+ // One slope attempt per (case, branch, committed point), cached even
1914
+ // when the preview returns nothing: the engine preview rebuilds the
1915
+ // differentiable system (~100 ms on the large cases), and a null
1916
+ // result retried on every input event froze the drag. A drag before
1917
+ // the Study exists caches nothing, so the prediction appears once
1918
+ // the Study lands.
1919
+ // The slope preview runs only for DC OPF: the conic differentiable
1920
+ // preview takes seconds on the large cases, and freezing the first
1921
+ // drag frame is not worth the Δ$ readout. SOCWR shows the column
1922
+ // preview without an objective prediction.
1923
+ const study = c.formulation === 'dcopf' ? this.caseStudies.get(c)?.study : undefined;
1924
+ if (study) {
1925
+ // Cache the entry before the async preview resolves so per-event calls
1926
+ // don't stack engine calls; the slope fills in when it lands and the
1927
+ // readout refreshes at the current drag position.
1928
+ const entry = { caseId: c.id, branch, slope: null };
1929
+ cached = entry;
1930
+ this.ratingSlope = entry;
1931
+ const pending = study
1932
+ .preview(this.caseDeltas(c), this.previewRatings(c, branch, committedAtBranch + 1))
1933
+ .then(({ objectiveDelta }) => {
1934
+ if (this.ratingSlope !== entry || objectiveDelta === null)
1935
+ return;
1936
+ this.ratingSlope = { caseId: c.id, branch, slope: objectiveDelta };
1937
+ const live = this.app.previewRatingMw;
1938
+ if (live !== null && this.activeSolvable === c && this.app.selectedBranch === branch) {
1939
+ this.runRatingPreview(c, branch, live);
1940
+ }
1941
+ })
1942
+ .catch(() => {
1943
+ // Best effort; the readout just shows no prediction.
1944
+ })
1945
+ .finally(() => {
1946
+ if (this.ratingSlopePending === pending)
1947
+ this.ratingSlopePending = null;
1948
+ });
1949
+ this.ratingSlopePending = pending;
1950
+ }
1951
+ else {
1952
+ cached = null;
1953
+ }
1954
+ }
1955
+ this.previewObjective =
1956
+ cached === null || cached.slope === null
1957
+ ? null
1958
+ : {
1959
+ caseId: c.id,
1960
+ target: { branch },
1961
+ objectiveDelta: cached.slope * step
1962
+ };
1963
+ };
1964
+ setRatingPreview = (value) => {
1965
+ if (value === undefined)
1966
+ return;
1967
+ this.app.previewActive = true;
1968
+ this.app.previewRatingMw = value;
1969
+ const c = this.activeSolvable;
1970
+ if (c && this.app.selectedBranch !== null) {
1971
+ this.runRatingPreview(c, this.app.selectedBranch, value);
1972
+ }
1973
+ };
1974
+ // The in-flight slope preview for the rating drag, so a commit that arrives
1975
+ // before it resolves (typed value: input and change in the same tick) can
1976
+ // wait for the slope instead of scoring without a prediction.
1977
+ ratingSlopePending = null;
1978
+ commitRating = async (value) => {
1979
+ const c = this.activeSolvable;
1980
+ const branch = this.app.selectedBranch;
1981
+ if (!c || branch === null)
1982
+ return;
1983
+ // Refresh the engine preview at the commit value (a typed value may not have
1984
+ // driven a drag), then score the commit with the engine's predicted Δobjective.
1985
+ this.runRatingPreview(c, branch, value);
1986
+ if (this.ratingSlopePending) {
1987
+ await this.ratingSlopePending;
1988
+ if (this.activeSolvable !== c || this.app.selectedBranch !== branch)
1989
+ return;
1990
+ this.runRatingPreview(c, branch, value);
1991
+ }
1992
+ c.predictedObjective = this.predictedDeltaObj;
1993
+ c.ratings = this.previewRatings(c, branch, value);
1994
+ // The slope was taken at the old committed point; the next drag re-derives it.
1995
+ this.ratingSlope = null;
1996
+ this.app.previewRatingMw = value;
1997
+ this.app.previewActive = true;
1998
+ this.runSolve(c, this.selectionTarget);
1999
+ };
2000
+ finishRatingInput = (value) => {
2001
+ if (Math.abs(value - this.committedRating) < 0.25) {
2002
+ if (!this.activeSolvable?.solving) {
2003
+ this.app.previewActive = false;
2004
+ this.app.previewRatingMw = null;
2005
+ }
2006
+ return;
2007
+ }
2008
+ this.commitRating(value);
2009
+ };
2010
+ restoreDefaultCases = () => {
2011
+ try {
2012
+ if (typeof localStorage !== 'undefined')
2013
+ localStorage.removeItem(HIDDEN_DEFAULT_CASES_KEY);
2014
+ }
2015
+ catch {
2016
+ // Ignore storage failures and reload from the server.
2017
+ }
2018
+ this.hiddenDefaults = new Set();
2019
+ this.load();
2020
+ };
2021
+ sliderCurrent = () => this.sliderValue;
2022
+ ratingSliderCurrent = () => this.ratingSliderValue;
2023
+ }
2024
+ export function createController(app, options = {}) {
2025
+ return new Controller(app, options);
2026
+ }