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