@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.
- package/LICENSE +19 -0
- package/README.md +70 -0
- package/dist/Sparkline.svelte +100 -0
- package/dist/Sparkline.svelte.d.ts +8 -0
- package/dist/Sparkline.svelte.d.ts.map +1 -0
- package/dist/TellegenMap.svelte +1430 -0
- package/dist/TellegenMap.svelte.d.ts +14 -0
- package/dist/TellegenMap.svelte.d.ts.map +1 -0
- package/dist/TellegenProvider.svelte +38 -0
- package/dist/TellegenProvider.svelte.d.ts +9 -0
- package/dist/TellegenProvider.svelte.d.ts.map +1 -0
- package/dist/TellegenShell.svelte +126 -0
- package/dist/TellegenShell.svelte.d.ts +4 -0
- package/dist/TellegenShell.svelte.d.ts.map +1 -0
- package/dist/TellegenViewer.svelte +19 -0
- package/dist/TellegenViewer.svelte.d.ts +4 -0
- package/dist/TellegenViewer.svelte.d.ts.map +1 -0
- package/dist/api.d.ts +44 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +137 -0
- package/dist/colors.d.ts +51 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +140 -0
- package/dist/components/AppFooter.svelte +73 -0
- package/dist/components/AppFooter.svelte.d.ts +19 -0
- package/dist/components/AppFooter.svelte.d.ts.map +1 -0
- package/dist/components/AppHeader.svelte +405 -0
- package/dist/components/AppHeader.svelte.d.ts +4 -0
- package/dist/components/AppHeader.svelte.d.ts.map +1 -0
- package/dist/components/BindingLines.svelte +101 -0
- package/dist/components/BindingLines.svelte.d.ts +4 -0
- package/dist/components/BindingLines.svelte.d.ts.map +1 -0
- package/dist/components/BusPicker.svelte +227 -0
- package/dist/components/BusPicker.svelte.d.ts +4 -0
- package/dist/components/BusPicker.svelte.d.ts.map +1 -0
- package/dist/components/ControlPanel.svelte +154 -0
- package/dist/components/ControlPanel.svelte.d.ts +19 -0
- package/dist/components/ControlPanel.svelte.d.ts.map +1 -0
- package/dist/components/DemandSlider.svelte +271 -0
- package/dist/components/DemandSlider.svelte.d.ts +4 -0
- package/dist/components/DemandSlider.svelte.d.ts.map +1 -0
- package/dist/components/DisplayControls.svelte +81 -0
- package/dist/components/DisplayControls.svelte.d.ts +19 -0
- package/dist/components/DisplayControls.svelte.d.ts.map +1 -0
- package/dist/components/DropZone.svelte +46 -0
- package/dist/components/DropZone.svelte.d.ts +19 -0
- package/dist/components/DropZone.svelte.d.ts.map +1 -0
- package/dist/components/FormulationSelector.svelte +82 -0
- package/dist/components/FormulationSelector.svelte.d.ts +19 -0
- package/dist/components/FormulationSelector.svelte.d.ts.map +1 -0
- package/dist/components/LocalCaseDetails.svelte +253 -0
- package/dist/components/LocalCaseDetails.svelte.d.ts +4 -0
- package/dist/components/LocalCaseDetails.svelte.d.ts.map +1 -0
- package/dist/components/MulticonductorDetails.svelte +280 -0
- package/dist/components/MulticonductorDetails.svelte.d.ts +19 -0
- package/dist/components/MulticonductorDetails.svelte.d.ts.map +1 -0
- package/dist/components/NetworkStats.svelte +75 -0
- package/dist/components/NetworkStats.svelte.d.ts +19 -0
- package/dist/components/NetworkStats.svelte.d.ts.map +1 -0
- package/dist/components/PlacementCue.svelte +35 -0
- package/dist/components/PlacementCue.svelte.d.ts +19 -0
- package/dist/components/PlacementCue.svelte.d.ts.map +1 -0
- package/dist/components/RatingSlider.svelte +273 -0
- package/dist/components/RatingSlider.svelte.d.ts +4 -0
- package/dist/components/RatingSlider.svelte.d.ts.map +1 -0
- package/dist/components/RestoreDefaultsButton.svelte +34 -0
- package/dist/components/RestoreDefaultsButton.svelte.d.ts +19 -0
- package/dist/components/RestoreDefaultsButton.svelte.d.ts.map +1 -0
- package/dist/components/SensitivityReadout.svelte +126 -0
- package/dist/components/SensitivityReadout.svelte.d.ts +4 -0
- package/dist/components/SensitivityReadout.svelte.d.ts.map +1 -0
- package/dist/components/SizeLegend.svelte +55 -0
- package/dist/components/SizeLegend.svelte.d.ts +19 -0
- package/dist/components/SizeLegend.svelte.d.ts.map +1 -0
- package/dist/components/SolveCard.svelte +84 -0
- package/dist/components/SolveCard.svelte.d.ts +4 -0
- package/dist/components/SolveCard.svelte.d.ts.map +1 -0
- package/dist/components/TopMovers.svelte +68 -0
- package/dist/components/TopMovers.svelte.d.ts +19 -0
- package/dist/components/TopMovers.svelte.d.ts.map +1 -0
- package/dist/components.d.ts +19 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +18 -0
- package/dist/context.svelte.d.ts +20 -0
- package/dist/context.svelte.d.ts.map +1 -0
- package/dist/context.svelte.js +43 -0
- package/dist/controller.svelte.d.ts +334 -0
- package/dist/controller.svelte.d.ts.map +1 -0
- package/dist/controller.svelte.js +2026 -0
- package/dist/display.d.ts +31 -0
- package/dist/display.d.ts.map +1 -0
- package/dist/display.js +63 -0
- package/dist/drop-classify.d.ts +48 -0
- package/dist/drop-classify.d.ts.map +1 -0
- package/dist/drop-classify.js +92 -0
- package/dist/format.d.ts +30 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +49 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/map.d.ts +9 -0
- package/dist/map.d.ts.map +1 -0
- package/dist/map.js +4 -0
- package/dist/multiconductor.d.ts +63 -0
- package/dist/multiconductor.d.ts.map +1 -0
- package/dist/multiconductor.js +255 -0
- package/dist/state.svelte.d.ts +258 -0
- package/dist/state.svelte.d.ts.map +1 -0
- package/dist/state.svelte.js +323 -0
- package/dist/study-package.d.ts +9 -0
- package/dist/study-package.d.ts.map +1 -0
- package/dist/study-package.js +17 -0
- package/dist/styles.css +393 -0
- package/dist/synthetic-layout.d.ts +12 -0
- package/dist/synthetic-layout.d.ts.map +1 -0
- package/dist/synthetic-layout.js +271 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- 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
|
+
}
|