@tellegen/svelte 0.2.0 → 0.4.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 +269 -5
- package/dist/Sparkline.svelte +2 -2
- package/dist/TellegenMap.svelte +518 -202
- package/dist/TellegenMap.svelte.d.ts +45 -0
- package/dist/TellegenProvider.svelte +40 -4
- package/dist/TellegenShell.svelte +24 -46
- package/dist/TellegenViewer.svelte +12 -2
- package/dist/api.d.ts +18 -3
- package/dist/api.js +8 -2
- package/dist/basemap.d.ts +192 -0
- package/dist/basemap.js +259 -0
- package/dist/colors.d.ts +25 -7
- package/dist/colors.js +39 -13
- package/dist/components/AppFooter.svelte +3 -2
- package/dist/components/AppHeader.svelte +44 -19
- package/dist/components/BindingLines.svelte +4 -7
- package/dist/components/BusPicker.svelte +11 -5
- package/dist/components/ControlPanel.svelte +106 -343
- package/dist/components/DemandSlider.svelte +36 -29
- package/dist/components/DiagramCanvas.svelte +486 -0
- package/dist/components/DiagramCanvas.svelte.d.ts +13 -0
- package/dist/components/DisplayControls.svelte +8 -2
- package/dist/components/DropZone.svelte +1 -1
- package/dist/components/FormulationSelector.svelte +9 -4
- package/dist/components/LocalCaseDetails.svelte +61 -48
- package/dist/components/McResults.svelte +428 -0
- package/dist/components/McResults.svelte.d.ts +17 -0
- package/dist/components/ModelDetails.svelte +230 -0
- package/dist/components/ModelDetails.svelte.d.ts +7 -0
- package/dist/components/MulticonductorDetails.svelte +232 -83
- package/dist/components/NetworkStats.svelte +20 -18
- package/dist/components/NotificationCenter.svelte +245 -0
- package/dist/components/NotificationCenter.svelte.d.ts +3 -0
- package/dist/components/PanelCard.svelte +384 -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 +237 -0
- package/dist/components/PanelHost.svelte.d.ts +6 -0
- package/dist/components/PlacementCue.svelte +1 -1
- package/dist/components/PowerFlowDetails.svelte +99 -0
- package/dist/components/PowerFlowDetails.svelte.d.ts +3 -0
- package/dist/components/RatingSlider.svelte +19 -14
- package/dist/components/RestoreDefaultsButton.svelte +1 -1
- package/dist/components/SavedNetworkDetails.svelte +214 -0
- package/dist/components/SavedNetworkDetails.svelte.d.ts +3 -0
- package/dist/components/SensitivityReadout.svelte +26 -18
- package/dist/components/ShellEnvironment.svelte +51 -0
- package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
- package/dist/components/SizeLegend.svelte +2 -2
- package/dist/components/SolveCard.svelte +48 -51
- package/dist/components/TopMovers.svelte +9 -4
- package/dist/components/TopMovers.svelte.d.ts +2 -17
- package/dist/components.d.ts +5 -0
- package/dist/components.js +5 -0
- package/dist/context.svelte.d.ts +8 -1
- package/dist/context.svelte.js +8 -3
- package/dist/controller.svelte.d.ts +117 -36
- package/dist/controller.svelte.js +1133 -253
- package/dist/diagram-labels.d.ts +9 -0
- package/dist/diagram-labels.js +40 -0
- package/dist/display.d.ts +3 -7
- package/dist/display.js +31 -27
- package/dist/format.d.ts +9 -0
- package/dist/format.js +17 -4
- package/dist/index.d.ts +15 -5
- package/dist/index.js +9 -2
- package/dist/map.d.ts +4 -2
- package/dist/map.js +2 -1
- package/dist/maplibre-worker.d.ts +5 -0
- package/dist/maplibre-worker.dev.d.ts +11 -0
- package/dist/maplibre-worker.dev.js +15 -0
- package/dist/maplibre-worker.js +8 -0
- package/dist/multiconductor.d.ts +11 -2
- package/dist/multiconductor.js +107 -14
- package/dist/notices.svelte.d.ts +29 -0
- package/dist/notices.svelte.js +86 -0
- package/dist/panels.svelte.d.ts +71 -0
- package/dist/panels.svelte.js +245 -0
- package/dist/slider.d.ts +12 -0
- package/dist/slider.js +31 -0
- package/dist/state.svelte.d.ts +107 -23
- package/dist/state.svelte.js +106 -20
- package/dist/styles.css +59 -6
- package/dist/synthetic-layout.js +1 -0
- package/package.json +29 -11
- package/dist/study-package.d.ts +0 -8
- package/dist/study-package.js +0 -17
|
@@ -3,15 +3,20 @@ import { previewScaleFor, scalarDomain, sensFlatColor, sensitivityDomain } from
|
|
|
3
3
|
import { caseDeltas as sharedCaseDeltas, caseRatings as sharedCaseRatings, displayMetaFor, displaySeriesFor } from './display.js';
|
|
4
4
|
import { CaseState, LocalCase, MulticonductorCase } from './state.svelte.js';
|
|
5
5
|
import { placeSyntheticTopology } from './synthetic-layout.js';
|
|
6
|
-
import { studyCommitIndex } from './study-package.js';
|
|
7
6
|
import { distExtensionFormat, isGeoFileName } from './drop-classify.js';
|
|
8
7
|
import { DropBatchGate, readDropFileBytes, validateDropBatch } from './drop-limits.js';
|
|
9
|
-
import { buildGeographicView, placeMultiView } from './multiconductor.js';
|
|
10
|
-
import { applyDisplayGeo, applyGeo, applyLayout, createStudy,
|
|
11
|
-
import { errorText, extent, formulationLabel, rgbaCss } from './format.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, mcPfResultDetail, summarizeMcPfResult } from '@tellegen/engine';
|
|
10
|
+
import { browserSolveFailure, errorText, extent, formulationLabel, rgbaCss } from './format.js';
|
|
11
|
+
import { DEAD_ZONE_MW } from './slider.js';
|
|
12
12
|
const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
|
|
13
13
|
// Open on South Carolina by default: it has the most interesting price action.
|
|
14
14
|
const DEFAULT_CASE_ID = 'case500';
|
|
15
|
+
/** Whether a local case renders or will render a bus with this id. */
|
|
16
|
+
function localHasBus(c, busId) {
|
|
17
|
+
const buses = c.view?.buses ?? c.diagram?.view.buses ?? c.topology?.buses ?? [];
|
|
18
|
+
return buses.some((b) => b.id === busId);
|
|
19
|
+
}
|
|
15
20
|
/** Terminal copy when nothing can solve. Two facts decide the wording: whether
|
|
16
21
|
* this browser's engine failure is known permanent, and that the server
|
|
17
22
|
* declines compute. Server compute exists in the codebase but ships disabled;
|
|
@@ -22,6 +27,11 @@ const COMPUTE_OFF_NOTICE = 'solving in this browser did not complete, and server
|
|
|
22
27
|
* starts anyway: a missing map or a cue that never appears must not stall the
|
|
23
28
|
* selection. */
|
|
24
29
|
const FOCUS_SETTLE_CAP_MS = 1200;
|
|
30
|
+
function previewUnits(sensitivityUnits) {
|
|
31
|
+
const numerator = sensitivityUnits.replace(/\/(?:MW|MVA)$/, '').trim();
|
|
32
|
+
const unwrapped = numerator.startsWith('(') && numerator.endsWith(')') ? numerator.slice(1, -1) : numerator;
|
|
33
|
+
return unwrapped.replaceAll('objective_unit', 'objective units');
|
|
34
|
+
}
|
|
25
35
|
/** A lowered analysis row is view-only only when a current producer says so;
|
|
26
36
|
* absence keeps payloads from older compatible engine/server versions editable. */
|
|
27
37
|
export function isDisplayOnlyElement(element) {
|
|
@@ -60,7 +70,11 @@ function downloadText(text, filename, mime) {
|
|
|
60
70
|
}
|
|
61
71
|
export class Controller {
|
|
62
72
|
app;
|
|
73
|
+
/** `null` when the controller was created with `apiBase: null`; every
|
|
74
|
+
* server path then reports "compute off" instead of fetching. */
|
|
63
75
|
api;
|
|
76
|
+
loadDefaultCases;
|
|
77
|
+
mcTransport;
|
|
64
78
|
abort = null;
|
|
65
79
|
// While set (epoch ms), the server sensitivity fallback is rate limited: skip
|
|
66
80
|
// the request and show the rate-limit copy instead of burning the budget on a
|
|
@@ -70,11 +84,12 @@ export class Controller {
|
|
|
70
84
|
// Latched once any server fallback answers 403: compute is disabled on this
|
|
71
85
|
// deploy, so later fallbacks show the notice instead of firing doomed requests.
|
|
72
86
|
serverComputeOff = false;
|
|
73
|
-
// Build-once browser Study per case: the
|
|
87
|
+
// Build-once browser Study per case: the retained PowerIO module is parsed and the model built
|
|
74
88
|
// when the Study is created, so a drag re-solves (commit) and previews without
|
|
75
89
|
// re-parsing. Kept in a WeakMap off the reactive/raw case payloads — the wasm
|
|
76
90
|
// handle is neither serialized nor part of any $state.
|
|
77
91
|
caseStudies = new WeakMap();
|
|
92
|
+
detachedCaseCaptures = new WeakMap();
|
|
78
93
|
// Latch a permanent sensitivity-module failure per case so we don't retry
|
|
79
94
|
// createStudy — and the same permanent error — on every drag. Transient
|
|
80
95
|
// failures are not latched.
|
|
@@ -113,6 +128,8 @@ export class Controller {
|
|
|
113
128
|
constructor(app, options = {}) {
|
|
114
129
|
this.app = app;
|
|
115
130
|
this.api = options.api ?? createApiClient({ apiBase: options.apiBase });
|
|
131
|
+
this.loadDefaultCases = options.loadDefaultCases ?? true;
|
|
132
|
+
this.mcTransport = options.mcTransport ?? browserWasmTransport;
|
|
116
133
|
}
|
|
117
134
|
// ===== helpers =====
|
|
118
135
|
isBackendCase(c) {
|
|
@@ -140,6 +157,9 @@ export class Controller {
|
|
|
140
157
|
isPerturbed(c) {
|
|
141
158
|
return c?.perturbed ?? false;
|
|
142
159
|
}
|
|
160
|
+
bumpRevision(c) {
|
|
161
|
+
c.revisionGeneration += 1;
|
|
162
|
+
}
|
|
143
163
|
setNearbyRangeAnchor(c, bus, delta = this.caseDeltas(c)[bus] ?? 0) {
|
|
144
164
|
this.nearbyRangeAnchor = { caseId: c.id, bus, delta };
|
|
145
165
|
}
|
|
@@ -149,6 +169,7 @@ export class Controller {
|
|
|
149
169
|
// the sensitivity seq the caller guards its async writes with.
|
|
150
170
|
beginBusSelection(c, busId) {
|
|
151
171
|
this.abort?.abort();
|
|
172
|
+
this.bumpRevision(c);
|
|
152
173
|
const ac = new AbortController();
|
|
153
174
|
this.abort = ac;
|
|
154
175
|
const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
|
|
@@ -170,6 +191,7 @@ export class Controller {
|
|
|
170
191
|
// but the selection lands on a branch and the demand preview fields are cleared.
|
|
171
192
|
beginBranchSelection(c, branchId) {
|
|
172
193
|
this.abort?.abort();
|
|
194
|
+
this.bumpRevision(c);
|
|
173
195
|
const ac = new AbortController();
|
|
174
196
|
this.abort = ac;
|
|
175
197
|
const sensitivitySeq = (c.sensitivitySeq ?? 0) + 1;
|
|
@@ -219,45 +241,50 @@ export class Controller {
|
|
|
219
241
|
this.hiddenDefaults = new Set(hidden);
|
|
220
242
|
}
|
|
221
243
|
localNetwork(c) {
|
|
222
|
-
|
|
244
|
+
const view = c.displayMode === 'diagram' ? c.diagram?.view : c.view;
|
|
245
|
+
if (!c.summary || !view)
|
|
223
246
|
return null;
|
|
224
247
|
return {
|
|
225
248
|
id: c.id,
|
|
226
249
|
name: c.label,
|
|
227
250
|
base_mva: c.summary.base_mva,
|
|
228
251
|
synthetic_coords: c.coordsKind !== 'file' && c.coordsKind !== 'geofile',
|
|
229
|
-
|
|
230
|
-
|
|
252
|
+
...(c.network?.model_details ? { model_details: c.network.model_details } : {}),
|
|
253
|
+
coordinate_space: view.coordinate_space,
|
|
254
|
+
buses: view.buses,
|
|
255
|
+
branches: view.branches
|
|
231
256
|
};
|
|
232
257
|
}
|
|
233
258
|
maybeStartLocalSolve(id) {
|
|
234
259
|
const c = this.app.localCases.find((lc) => lc.id === id);
|
|
235
|
-
if (!c?.
|
|
260
|
+
if (!c?.studyInputJson || (!c.view && !c.diagram) || !c.summary)
|
|
236
261
|
return;
|
|
237
262
|
c.network = this.localNetwork(c) ?? c.network ?? null;
|
|
238
|
-
if (c.
|
|
263
|
+
if (c.network && !c.solution)
|
|
239
264
|
this.runSolve(c, null);
|
|
240
265
|
}
|
|
241
|
-
// The case's Study, building it once for `(
|
|
266
|
+
// The case's Study, building it once for `(studyInputJson, formulation)` and rebuilding
|
|
242
267
|
// (after free) if either changed — so picking a new formulation re-parses and re-solves
|
|
243
268
|
// under that formulation. Returns null when the sens module can't load; the caller then
|
|
244
269
|
// falls back to the server, surfacing solveFallbackReason.
|
|
245
|
-
async getStudy(c,
|
|
270
|
+
async getStudy(c, studyInputJson) {
|
|
246
271
|
const latched = this.studyUnavailable.get(c);
|
|
247
272
|
if (latched) {
|
|
248
273
|
c.solveFallbackReason ??= latched;
|
|
249
274
|
return null;
|
|
250
275
|
}
|
|
251
276
|
const cached = this.caseStudies.get(c);
|
|
252
|
-
if (cached && cached.
|
|
277
|
+
if (cached && cached.studyInputJson === studyInputJson && cached.formulation === c.formulation)
|
|
253
278
|
return cached.study;
|
|
254
|
-
// Coalesce concurrent builds for the same (case,
|
|
279
|
+
// Coalesce concurrent builds for the same (case, module, formulation): a
|
|
255
280
|
// bus-select sensitivity fetch and a solve can both reach here before either
|
|
256
281
|
// createStudy resolves. Without this each builds its own wasm Study and the
|
|
257
282
|
// later set() orphans the earlier one (a leaked handle), and the cache can end
|
|
258
283
|
// up holding an instance the live solve never committed on.
|
|
259
284
|
const inflight = this.studyBuilds.get(c);
|
|
260
|
-
if (inflight &&
|
|
285
|
+
if (inflight &&
|
|
286
|
+
inflight.studyInputJson === studyInputJson &&
|
|
287
|
+
inflight.formulation === c.formulation)
|
|
261
288
|
return inflight.promise;
|
|
262
289
|
// Different params, or no build pending: drop any stale Study before rebuilding.
|
|
263
290
|
if (cached) {
|
|
@@ -270,7 +297,7 @@ export class Controller {
|
|
|
270
297
|
const token = {};
|
|
271
298
|
const build = (async () => {
|
|
272
299
|
try {
|
|
273
|
-
const study = await createStudy(
|
|
300
|
+
const study = await createStudy(studyInputJson, formulation);
|
|
274
301
|
const baseSolution = await study.currentSolution();
|
|
275
302
|
// The case may have switched formulation or been disposed (removed, or its
|
|
276
303
|
// list reloaded) while building; don't cache a now-unwanted Study, and free
|
|
@@ -281,7 +308,7 @@ export class Controller {
|
|
|
281
308
|
}
|
|
282
309
|
this.caseStudies.set(c, {
|
|
283
310
|
study,
|
|
284
|
-
|
|
311
|
+
studyInputJson,
|
|
285
312
|
formulation,
|
|
286
313
|
baseSolution
|
|
287
314
|
});
|
|
@@ -303,21 +330,21 @@ export class Controller {
|
|
|
303
330
|
}
|
|
304
331
|
})();
|
|
305
332
|
this.studyBuilds.set(c, {
|
|
306
|
-
|
|
333
|
+
studyInputJson,
|
|
307
334
|
formulation,
|
|
308
335
|
token,
|
|
309
336
|
promise: build
|
|
310
337
|
});
|
|
311
338
|
return build;
|
|
312
339
|
}
|
|
313
|
-
// The
|
|
340
|
+
// The nodal-value/parameter column at `target` for the case's active formulation, solved in
|
|
314
341
|
// the browser. Every column, bus or branch, any formulation, comes from the Study's
|
|
315
342
|
// exact re-solve at the case's operating point. The column is null (with the reason on
|
|
316
343
|
// `solveFallbackReason`) when the Study can't be built; the selection paths then
|
|
317
344
|
// reconcile via the server where a DC endpoint exists. Throws only on a hard browser
|
|
318
345
|
// error.
|
|
319
|
-
async browserSensitivity(c,
|
|
320
|
-
const study = await this.getStudy(c,
|
|
346
|
+
async browserSensitivity(c, studyInputJson, target) {
|
|
347
|
+
const study = await this.getStudy(c, studyInputJson);
|
|
321
348
|
return study ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target) : null;
|
|
322
349
|
}
|
|
323
350
|
// Release a case's Study (if any) when the case is removed.
|
|
@@ -360,21 +387,26 @@ export class Controller {
|
|
|
360
387
|
this.app.previewDeltaMw = null;
|
|
361
388
|
this.app.previewRatingMw = null;
|
|
362
389
|
// The committed solution supersedes the live engine preview.
|
|
363
|
-
this.app.
|
|
390
|
+
this.app.previewPrices = null;
|
|
364
391
|
this.previewObjective = null;
|
|
365
392
|
}
|
|
366
393
|
}
|
|
367
|
-
// Fetch and cache the
|
|
368
|
-
// Returns null when it
|
|
369
|
-
async
|
|
394
|
+
// Fetch and cache the retained PowerIO module used by every browser Study.
|
|
395
|
+
// Returns null when it cannot be loaded, so callers can report or fall back.
|
|
396
|
+
async ensureStudyInputJson(c) {
|
|
397
|
+
if (c.studyInputJson)
|
|
398
|
+
return c.studyInputJson;
|
|
370
399
|
if (!this.isBackendCase(c))
|
|
371
|
-
return
|
|
372
|
-
|
|
373
|
-
|
|
400
|
+
return null;
|
|
401
|
+
const api = this.api;
|
|
402
|
+
if (!api) {
|
|
403
|
+
c.solveFallbackReason = 'no API configured';
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
374
406
|
try {
|
|
375
|
-
const
|
|
376
|
-
c.
|
|
377
|
-
return
|
|
407
|
+
const moduleJson = await api.getCaseModuleJson(c.id);
|
|
408
|
+
c.studyInputJson = moduleJson;
|
|
409
|
+
return moduleJson;
|
|
378
410
|
}
|
|
379
411
|
catch (e) {
|
|
380
412
|
c.solveFallbackReason = `case fetch failed: ${errorText(e)}`;
|
|
@@ -386,7 +418,7 @@ export class Controller {
|
|
|
386
418
|
// reads as "no change at this bus".
|
|
387
419
|
previewDeltas(c, bus, value) {
|
|
388
420
|
const deltas = { ...this.caseDeltas(c) };
|
|
389
|
-
if (Math.abs(value) <
|
|
421
|
+
if (Math.abs(value) < DEAD_ZONE_MW)
|
|
390
422
|
delete deltas[bus];
|
|
391
423
|
else
|
|
392
424
|
deltas[bus] = value;
|
|
@@ -424,8 +456,12 @@ export class Controller {
|
|
|
424
456
|
buses: c.network.buses.length,
|
|
425
457
|
branches: c.network.branches.length,
|
|
426
458
|
objective: c.solution?.objective ?? null,
|
|
427
|
-
deltaObjective: c.solution
|
|
428
|
-
|
|
459
|
+
deltaObjective: c.solution?.objective != null && c.baseSolution?.objective != null
|
|
460
|
+
? c.solution.objective - c.baseSolution.objective
|
|
461
|
+
: null,
|
|
462
|
+
binding: c.solution && c.formulation !== 'acpf'
|
|
463
|
+
? c.solution.flows.filter((f) => f.loading >= 0.999).length
|
|
464
|
+
: null
|
|
429
465
|
};
|
|
430
466
|
});
|
|
431
467
|
displayOptions = $derived(displayMetaFor(this.activeSolvable));
|
|
@@ -437,7 +473,7 @@ export class Controller {
|
|
|
437
473
|
return null;
|
|
438
474
|
// Drive the values off the resolved mode, not app.displayMode, so the
|
|
439
475
|
// displayOptions[0] fallback holds while displayMode is stale relative to the
|
|
440
|
-
// formulation (e.g. leaving SOCWR before +page resets it to
|
|
476
|
+
// formulation (e.g. leaving SOCWR before +page resets it to price).
|
|
441
477
|
return {
|
|
442
478
|
...activeMeta,
|
|
443
479
|
values: displaySeriesFor(this.activeSolvable, activeMeta.mode)
|
|
@@ -451,7 +487,7 @@ export class Controller {
|
|
|
451
487
|
return null;
|
|
452
488
|
const domain = scalarDomain(this.activeDisplay.mode, values);
|
|
453
489
|
const { min, max } = extent(values);
|
|
454
|
-
const flatThreshold = this.activeDisplay.mode === '
|
|
490
|
+
const flatThreshold = this.activeDisplay.mode === 'price' ? 1 : 1e-5;
|
|
455
491
|
return {
|
|
456
492
|
lo: { value: domain.lo, clamped: min < domain.lo - flatThreshold / 20 },
|
|
457
493
|
hi: { value: domain.hi, clamped: max > domain.hi + flatThreshold / 20 },
|
|
@@ -470,8 +506,8 @@ export class Controller {
|
|
|
470
506
|
return null;
|
|
471
507
|
return c.network.branches.find((b) => b.id === this.app.selectedBranch) ?? null;
|
|
472
508
|
});
|
|
473
|
-
// The sensitivity target of the current selection: the selected bus (
|
|
474
|
-
// else the selected branch (
|
|
509
|
+
// The sensitivity target of the current selection: the selected bus (price/demand),
|
|
510
|
+
// else the selected branch (price/rating), else null. Selections are mutually
|
|
475
511
|
// exclusive, so bus-first ordering is only a tiebreak for impossible states.
|
|
476
512
|
selectionTarget = $derived.by(() => this.app.selectedBus !== null
|
|
477
513
|
? { bus: this.app.selectedBus }
|
|
@@ -545,9 +581,9 @@ export class Controller {
|
|
|
545
581
|
const c = this.activeSolvable;
|
|
546
582
|
if (!c?.solution || this.app.selectedBus === null)
|
|
547
583
|
return null;
|
|
548
|
-
return c.solution.
|
|
584
|
+
return c.solution.prices.find((e) => e.bus === this.app.selectedBus)?.value ?? null;
|
|
549
585
|
});
|
|
550
|
-
// Self-sensitivity
|
|
586
|
+
// Self-sensitivity of the selected bus value to demand: the curvature term the
|
|
551
587
|
// first-order fallback uses for a second-order objective estimate. Zero when no
|
|
552
588
|
// sensitivity column is loaded for the bus.
|
|
553
589
|
selfSens = $derived.by(() => {
|
|
@@ -559,13 +595,13 @@ export class Controller {
|
|
|
559
595
|
// engine preview (Study.preview at the committed point, plus the committed part);
|
|
560
596
|
// fall back to a second-order gradient estimate when no Study preview is available
|
|
561
597
|
// (server-only cases, or a browser that can't load the sensitivity module): the
|
|
562
|
-
// exact committed part plus
|
|
563
|
-
// gradient fallback is demand-only — a rating step has no
|
|
598
|
+
// exact committed part plus price·step + S_bb·step²/2 along the gradient. The
|
|
599
|
+
// gradient fallback is demand-only — a rating step has no corresponding bus term —
|
|
564
600
|
// so a branch selection without an engine slope shows no prediction.
|
|
565
601
|
predictedDeltaObj = $derived.by(() => {
|
|
566
602
|
const c = this.activeSolvable;
|
|
567
603
|
const target = this.selectionTarget;
|
|
568
|
-
if (
|
|
604
|
+
if (c?.solution?.objective == null || c.baseSolution?.objective == null || target === null)
|
|
569
605
|
return null;
|
|
570
606
|
const committedPart = c.solution.objective - c.baseSolution.objective;
|
|
571
607
|
if (this.previewObjective &&
|
|
@@ -580,7 +616,10 @@ export class Controller {
|
|
|
580
616
|
});
|
|
581
617
|
gradientScore = $derived.by(() => {
|
|
582
618
|
const c = this.activeSolvable;
|
|
583
|
-
if (
|
|
619
|
+
if (c?.solution?.objective == null ||
|
|
620
|
+
c.baseSolution?.objective == null ||
|
|
621
|
+
c.predictedObjective == null ||
|
|
622
|
+
c.solving)
|
|
584
623
|
return null;
|
|
585
624
|
const exact = c.solution.objective - c.baseSolution.objective;
|
|
586
625
|
return { pred: c.predictedObjective, exact };
|
|
@@ -597,11 +636,20 @@ export class Controller {
|
|
|
597
636
|
previewing = $derived.by(() => Boolean(this.activeSolvable?.solving ||
|
|
598
637
|
this.app.previewActive ||
|
|
599
638
|
(this.app.previewDeltaMw !== null &&
|
|
600
|
-
Math.abs(this.sliderValue - this.committedDelta) >=
|
|
639
|
+
Math.abs(this.sliderValue - this.committedDelta) >= DEAD_ZONE_MW) ||
|
|
601
640
|
(this.app.previewRatingMw !== null &&
|
|
602
|
-
Math.abs(this.ratingSliderValue - this.committedRating) >=
|
|
641
|
+
Math.abs(this.ratingSliderValue - this.committedRating) >= DEAD_ZONE_MW)));
|
|
603
642
|
// ===== actions =====
|
|
604
643
|
load = () => {
|
|
644
|
+
// Default cases off, or no backend at all: nothing to fetch. The shell
|
|
645
|
+
// still needs its loaded state and a framed map, and a host that opens
|
|
646
|
+
// its own cases must never see a request go out.
|
|
647
|
+
const api = this.api;
|
|
648
|
+
if (!this.loadDefaultCases || api === null) {
|
|
649
|
+
this.casesLoaded = true;
|
|
650
|
+
void this.app.requestFrame('all');
|
|
651
|
+
return Promise.resolve();
|
|
652
|
+
}
|
|
605
653
|
// A remount can call load() again before the first getCases() resolves; dedupe
|
|
606
654
|
// so two concurrent loads can't double-fetch the case list and double-fit the map.
|
|
607
655
|
if (this.loading)
|
|
@@ -611,14 +659,14 @@ export class Controller {
|
|
|
611
659
|
// copy and skip doomed requests (the SSE stream cannot see a 403). On
|
|
612
660
|
// failure assume enabled; the 403 latch in fetchServerColumn still
|
|
613
661
|
// catches a disabled deploy.
|
|
614
|
-
void
|
|
662
|
+
void api
|
|
615
663
|
.getComputeStatus()
|
|
616
664
|
.then((s) => {
|
|
617
665
|
this.serverComputeOff = !s.enabled;
|
|
618
666
|
})
|
|
619
667
|
.catch(() => { });
|
|
620
668
|
try {
|
|
621
|
-
const summaries = await
|
|
669
|
+
const summaries = await api.getCases();
|
|
622
670
|
const hidden = this.readHiddenDefaultCases();
|
|
623
671
|
this.hiddenDefaults = new Set(hidden);
|
|
624
672
|
// Tear down the cases being replaced so their wasm Studies are freed and any
|
|
@@ -634,7 +682,9 @@ export class Controller {
|
|
|
634
682
|
this.app.activeLocalId = null;
|
|
635
683
|
this.app.placingLocalId = null;
|
|
636
684
|
this.app.activeCaseId =
|
|
637
|
-
this.app.cases.find((c) => c.id === DEFAULT_CASE_ID
|
|
685
|
+
this.app.cases.find((c) => c.id === DEFAULT_CASE_ID && !c.unavailableReason)?.id ??
|
|
686
|
+
this.app.cases.find((c) => !c.unavailableReason)?.id ??
|
|
687
|
+
null;
|
|
638
688
|
const active = this.app.active;
|
|
639
689
|
if (active)
|
|
640
690
|
await this.loadBackendCase(active, true);
|
|
@@ -654,20 +704,29 @@ export class Controller {
|
|
|
654
704
|
return this.loading;
|
|
655
705
|
};
|
|
656
706
|
loadBackendCase = async (c, frame = false) => {
|
|
707
|
+
if (c.unavailableReason) {
|
|
708
|
+
this.fail(`${c.name}: ${c.unavailableReason}`);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
657
711
|
if (c.network && c.solution && c.baseSolution) {
|
|
658
712
|
if (frame && this.app.activeCaseId === c.id)
|
|
659
713
|
this.app.requestFrame(c.id);
|
|
660
714
|
return;
|
|
661
715
|
}
|
|
716
|
+
const api = this.api;
|
|
717
|
+
if (!api) {
|
|
718
|
+
this.fail(`${c.name}: no API configured`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
662
721
|
this.loadingBackendCase = c.id;
|
|
663
722
|
const requestedFormulation = c.formulation;
|
|
664
723
|
try {
|
|
665
724
|
const [network, dcBaseSolution] = await Promise.all([
|
|
666
|
-
c.network ? Promise.resolve(c.network) :
|
|
725
|
+
c.network ? Promise.resolve(c.network) : api.getNetwork(c.id),
|
|
667
726
|
// The server caches only the DC OPF base solution. Other formulations
|
|
668
|
-
// must hydrate from the browser Study so their
|
|
727
|
+
// must hydrate from the browser Study so their objective, nodal values, flows, and
|
|
669
728
|
// voltage fields all come from the selected formulation.
|
|
670
|
-
requestedFormulation === 'dcopf' ?
|
|
729
|
+
requestedFormulation === 'dcopf' ? api.getSolution(c.id) : Promise.resolve(null)
|
|
671
730
|
]);
|
|
672
731
|
if (!this.app.byId(c.id))
|
|
673
732
|
return;
|
|
@@ -700,10 +759,19 @@ export class Controller {
|
|
|
700
759
|
this.app.placingMultiId = null;
|
|
701
760
|
}
|
|
702
761
|
activateCase = async (id) => {
|
|
762
|
+
const candidate = this.app.byId(id);
|
|
763
|
+
if (!candidate)
|
|
764
|
+
return;
|
|
765
|
+
if (candidate.unavailableReason) {
|
|
766
|
+
this.fail(`${candidate.name}: ${candidate.unavailableReason}`);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const inspectedSavedState = this.app.studyView !== null;
|
|
770
|
+
this.app.studyView = null;
|
|
703
771
|
this.app.activeLocalId = null;
|
|
704
772
|
this.app.placingLocalId = null;
|
|
705
773
|
this.leaveMulti();
|
|
706
|
-
if (this.app.activeCaseId !== id) {
|
|
774
|
+
if (inspectedSavedState || this.app.activeCaseId !== id) {
|
|
707
775
|
this.clearSelection();
|
|
708
776
|
this.app.activeCaseId = id;
|
|
709
777
|
}
|
|
@@ -739,6 +807,7 @@ export class Controller {
|
|
|
739
807
|
await this.hydrateFallback(this.app.removeCase(c.id));
|
|
740
808
|
};
|
|
741
809
|
activateLocal = (c) => {
|
|
810
|
+
this.app.studyView = null;
|
|
742
811
|
this.clearSelection();
|
|
743
812
|
// Mirror activateCase's reset: a local and a backend case are mutually
|
|
744
813
|
// exclusive, so drop the backend selection. Otherwise app.active (derived
|
|
@@ -747,8 +816,10 @@ export class Controller {
|
|
|
747
816
|
this.app.activeCaseId = null;
|
|
748
817
|
this.leaveMulti();
|
|
749
818
|
this.app.activeLocalId = c.id;
|
|
750
|
-
|
|
751
|
-
|
|
819
|
+
if (c.formulation === 'acpf' && this.app.displayMode === 'price')
|
|
820
|
+
this.app.displayMode = 'voltage';
|
|
821
|
+
this.app.placingLocalId = c.coordsKind === 'synthetic_pending' && !c.diagram ? c.id : null;
|
|
822
|
+
if (c.view || c.diagram || c.substations)
|
|
752
823
|
this.app.requestFrame(c.id);
|
|
753
824
|
this.maybeStartLocalSolve(c.id);
|
|
754
825
|
};
|
|
@@ -763,12 +834,19 @@ export class Controller {
|
|
|
763
834
|
this.disposeStudy(c);
|
|
764
835
|
await this.hydrateFallback(this.app.removeLocal(c.id));
|
|
765
836
|
};
|
|
766
|
-
|
|
837
|
+
/** Add a prepared local case, make it active, frame it (unless `frame` is
|
|
838
|
+
* false) and start its browser solve. */
|
|
839
|
+
addAndActivateLocal = (c, options = {}) => {
|
|
840
|
+
this.app.studyView = null;
|
|
767
841
|
this.clearSelection();
|
|
768
842
|
this.app.activeCaseId = null;
|
|
769
843
|
this.leaveMulti();
|
|
770
844
|
this.app.addLocal(c);
|
|
771
|
-
if (c.
|
|
845
|
+
if (c.formulation === 'acpf' && this.app.displayMode === 'price')
|
|
846
|
+
this.app.displayMode = 'voltage';
|
|
847
|
+
if (c.diagram)
|
|
848
|
+
this.app.placingLocalId = null;
|
|
849
|
+
if ((options.frame ?? true) && (c.view || c.diagram || c.substations))
|
|
772
850
|
this.app.requestFrame(c.id);
|
|
773
851
|
this.maybeStartLocalSolve(c.id);
|
|
774
852
|
};
|
|
@@ -790,18 +868,18 @@ export class Controller {
|
|
|
790
868
|
};
|
|
791
869
|
/** Write the case's on-screen layout into its network payload
|
|
792
870
|
* (`Bus.location` with `synthetic` provenance — the engine also accepts
|
|
793
|
-
* `manual`, which round trips through
|
|
871
|
+
* `manual`, which round trips through saved PowerIO modules), so saves,
|
|
794
872
|
* exports, and the `.geo.json` download carry it. Best effort: on an
|
|
795
873
|
* engine failure the view still renders, only the layout's persistence is
|
|
796
874
|
* lost. */
|
|
797
875
|
stampLayout = async (c) => {
|
|
798
|
-
if (!c.
|
|
876
|
+
if (!c.studyInputJson || !c.view)
|
|
799
877
|
return;
|
|
800
878
|
try {
|
|
801
879
|
const coords = Object.fromEntries(c.view.buses.map((b) => [b.id, [b.lon, b.lat]]));
|
|
802
|
-
const stamped = await applyLayout(c.
|
|
803
|
-
c.
|
|
804
|
-
await this.
|
|
880
|
+
const stamped = await applyLayout(c.studyInputJson, coords, 'synthetic');
|
|
881
|
+
c.studyInputJson = stamped.module_json;
|
|
882
|
+
await this.syncStudyGeo(c, [stamped.layer]);
|
|
805
883
|
}
|
|
806
884
|
catch {
|
|
807
885
|
// The on-screen view is unaffected; only persistence was lost.
|
|
@@ -811,10 +889,12 @@ export class Controller {
|
|
|
811
889
|
* GeoJSON with provenance stamped (`synthetic`/`manual` for layouts), the
|
|
812
890
|
* document powerio and tellegen both read back. */
|
|
813
891
|
downloadGeoLayer = async (c) => {
|
|
814
|
-
if (!c.
|
|
892
|
+
if (!c.studyInputJson)
|
|
815
893
|
return;
|
|
816
894
|
try {
|
|
817
|
-
downloadText(
|
|
895
|
+
downloadText(c.displayMode === 'diagram' && c.diagram
|
|
896
|
+
? c.diagram.layer
|
|
897
|
+
: await extractGeo(c.studyInputJson), `${this.caseFileStem(c)}.geo.json`, 'application/geo+json');
|
|
818
898
|
this.app.error = null;
|
|
819
899
|
}
|
|
820
900
|
catch (e) {
|
|
@@ -830,23 +910,46 @@ export class Controller {
|
|
|
830
910
|
* wins on shared elements). The engine owns matching and the network becomes
|
|
831
911
|
* the single owner of the coordinates: matched points land in
|
|
832
912
|
* `Bus.location`, routes in `Branch.route`, and the refreshed
|
|
833
|
-
* `
|
|
913
|
+
* `module_json` replaces the case IR — so a saved module or export
|
|
834
914
|
* carries exactly what is on screen. A live Study is synced the same way.
|
|
835
915
|
* Throws when a layer matches nothing. */
|
|
836
916
|
applyGeoLayers = async (c, layers) => {
|
|
837
|
-
if (!c.
|
|
917
|
+
if (!c.studyInputJson || layers.length === 0)
|
|
838
918
|
return;
|
|
839
919
|
let payload = null;
|
|
840
|
-
|
|
920
|
+
const geographic = [];
|
|
921
|
+
for (const entry of layers) {
|
|
922
|
+
const document = JSON.parse(entry.layer);
|
|
923
|
+
const space = document.powerio_geo?.space;
|
|
924
|
+
if (space === 'diagram' || space?.kind === 'diagram') {
|
|
925
|
+
const payload = await applyGeo(c.studyInputJson, entry.layer);
|
|
926
|
+
if (payload.view) {
|
|
927
|
+
c.diagram = {
|
|
928
|
+
view: payload.view,
|
|
929
|
+
layer: entry.layer,
|
|
930
|
+
name: entry.name,
|
|
931
|
+
warnings: payload.report.notes
|
|
932
|
+
};
|
|
933
|
+
if (!c.view)
|
|
934
|
+
c.displayMode = 'diagram';
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
else
|
|
938
|
+
geographic.push(entry);
|
|
939
|
+
}
|
|
940
|
+
layers = geographic;
|
|
941
|
+
if (!layers.length)
|
|
942
|
+
return;
|
|
943
|
+
let moduleJson = c.studyInputJson;
|
|
841
944
|
const warnings = [];
|
|
842
945
|
let routes = 0;
|
|
843
946
|
let unmatched = 0;
|
|
844
947
|
for (const l of layers) {
|
|
845
|
-
payload = await applyGeo(
|
|
846
|
-
|
|
948
|
+
payload = await applyGeo(moduleJson, l.layer);
|
|
949
|
+
moduleJson = payload.module_json;
|
|
847
950
|
routes += payload.report.matched_branches;
|
|
848
951
|
unmatched += payload.report.unmatched_features;
|
|
849
|
-
warnings.push(...l.
|
|
952
|
+
warnings.push(...l.diagnostics.map((d) => `${l.name}: ${formatPowerIoDiagnostic(d)}`), ...payload.report.notes.map((n) => `${l.name}: ${n}`));
|
|
850
953
|
}
|
|
851
954
|
if (!payload)
|
|
852
955
|
return;
|
|
@@ -856,15 +959,16 @@ export class Controller {
|
|
|
856
959
|
...(unmatched > 0 ? [`${unmatched} feature(s) matched no case element`] : []),
|
|
857
960
|
...warnings
|
|
858
961
|
]);
|
|
859
|
-
await this.
|
|
962
|
+
await this.syncStudyGeo(c, layers.map((l) => l.layer));
|
|
860
963
|
};
|
|
861
964
|
/** Adopt an engine geo apply onto the case: the refreshed payload becomes
|
|
862
965
|
* the case's network and view, provenance flips to the geographic source,
|
|
863
966
|
* and the warnings panel gets the placement summary. */
|
|
864
967
|
adoptGeoPayload(c, payload, sourceLabel, placedFrom, extraWarnings) {
|
|
865
|
-
c.
|
|
968
|
+
c.studyInputJson = payload.module_json;
|
|
866
969
|
c.view = payload.view;
|
|
867
970
|
c.coordsKind = 'geofile';
|
|
971
|
+
c.displayMode = payload.view?.coordinate_space === 'diagram' ? 'diagram' : 'geographic';
|
|
868
972
|
c.syntheticCenter = undefined;
|
|
869
973
|
c.geoSource = sourceLabel;
|
|
870
974
|
const placedCanonicalBuses = payload.view?.buses.filter((bus) => bus.editable !== false).length ?? 0;
|
|
@@ -874,39 +978,239 @@ export class Controller {
|
|
|
874
978
|
...payload.warnings
|
|
875
979
|
];
|
|
876
980
|
}
|
|
877
|
-
/**
|
|
878
|
-
|
|
879
|
-
* cache entry so the next solve reuses it instead of rebuilding. */
|
|
880
|
-
syncCachedStudy = async (c, networkJson, layers) => {
|
|
981
|
+
/** Update an existing numerical Study after the base input receives geographic data. */
|
|
982
|
+
syncStudyGeo = async (c, layers) => {
|
|
881
983
|
const cached = this.caseStudies.get(c);
|
|
882
984
|
if (!cached)
|
|
883
985
|
return;
|
|
884
|
-
cached.
|
|
885
|
-
|
|
886
|
-
|
|
986
|
+
if (cached.formulation !== c.formulation) {
|
|
987
|
+
this.disposeStudy(c);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
for (const layer of layers)
|
|
992
|
+
await cached.study.applyGeoLayer(layer);
|
|
993
|
+
cached.studyInputJson = c.studyInputJson;
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
this.disposeStudy(c);
|
|
997
|
+
throw error;
|
|
998
|
+
}
|
|
887
999
|
};
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
if (
|
|
893
|
-
|
|
894
|
-
|
|
1000
|
+
caseEditKey(c) {
|
|
1001
|
+
return JSON.stringify([c.formulation, this.caseDeltas(c), this.caseRatings(c)]);
|
|
1002
|
+
}
|
|
1003
|
+
async localSidecarCopy(source) {
|
|
1004
|
+
if (source.solving)
|
|
1005
|
+
throw new Error('Wait for the current solve before attaching coordinates');
|
|
1006
|
+
const revision = source.revisionGeneration;
|
|
1007
|
+
const captured = await this.captureSavedCase(source);
|
|
1008
|
+
const base = await this.ensureStudyInputJson(source);
|
|
1009
|
+
if (!base)
|
|
1010
|
+
throw new Error('The selected case input is unavailable');
|
|
1011
|
+
const parsed = await ingestJsonDrop(new TextEncoder().encode(base));
|
|
1012
|
+
if (!parsed.payload || !('topology' in parsed.payload))
|
|
1013
|
+
throw new Error('The selected case has no balanced network data');
|
|
1014
|
+
const copy = this.localFromBalancedPayload(`${source.name}.pio.json`, {
|
|
1015
|
+
...parsed.payload,
|
|
1016
|
+
name: `${source.name} (local copy)`
|
|
1017
|
+
});
|
|
1018
|
+
copy.formulation = source.formulation;
|
|
1019
|
+
copy.deltas = { ...source.deltas };
|
|
1020
|
+
copy.ratings = { ...source.ratings };
|
|
1021
|
+
copy.solution = source.solution;
|
|
1022
|
+
copy.baseSolution = source.baseSolution;
|
|
1023
|
+
copy.iterations = source.iterations;
|
|
1024
|
+
copy.solveMs = source.solveMs;
|
|
1025
|
+
copy.solveBackend = source.solveBackend;
|
|
1026
|
+
copy.network = source.network;
|
|
1027
|
+
if (source.network) {
|
|
1028
|
+
copy.view = {
|
|
1029
|
+
buses: source.network.buses,
|
|
1030
|
+
branches: source.network.branches,
|
|
1031
|
+
coordinate_space: source.network.coordinate_space
|
|
1032
|
+
};
|
|
1033
|
+
copy.coordsKind = source.network.synthetic_coords ? 'synthetic' : 'file';
|
|
1034
|
+
if (!source.network.synthetic_coords && source.network.coordinate_space !== 'diagram') {
|
|
1035
|
+
const layer = JSON.stringify({
|
|
1036
|
+
type: 'FeatureCollection',
|
|
1037
|
+
powerio_geo: { space: 'geographic' },
|
|
1038
|
+
features: [
|
|
1039
|
+
...source.network.buses.map((bus) => ({
|
|
1040
|
+
type: 'Feature',
|
|
1041
|
+
properties: { target: 'bus', id: String(bus.id) },
|
|
1042
|
+
geometry: { type: 'Point', coordinates: [bus.lon, bus.lat] }
|
|
1043
|
+
})),
|
|
1044
|
+
...source.network.branches
|
|
1045
|
+
.filter((branch) => branch.path.length > 1)
|
|
1046
|
+
.map((branch) => ({
|
|
1047
|
+
type: 'Feature',
|
|
1048
|
+
properties: {
|
|
1049
|
+
target: 'branch',
|
|
1050
|
+
branch_id: String(branch.id),
|
|
1051
|
+
from: String(branch.from),
|
|
1052
|
+
to: String(branch.to)
|
|
1053
|
+
},
|
|
1054
|
+
geometry: { type: 'LineString', coordinates: branch.path }
|
|
1055
|
+
}))
|
|
1056
|
+
]
|
|
1057
|
+
});
|
|
1058
|
+
const placed = await applyGeo(copy.studyInputJson, layer);
|
|
1059
|
+
copy.studyInputJson = placed.module_json;
|
|
1060
|
+
copy.view = placed.view;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (source.revisionGeneration !== revision || source.solving)
|
|
1064
|
+
throw new Error('The selected case changed while loading. Attach the coordinates again');
|
|
1065
|
+
this.detachedCaseCaptures.set(copy, {
|
|
1066
|
+
baseInput: copy.studyInputJson,
|
|
1067
|
+
edits: this.caseEditKey(copy),
|
|
1068
|
+
solution: copy.solution,
|
|
1069
|
+
captured
|
|
1070
|
+
});
|
|
1071
|
+
return copy;
|
|
1072
|
+
}
|
|
1073
|
+
stageLocalSidecars(source) {
|
|
1074
|
+
const copy = new LocalCase({
|
|
1075
|
+
id: source.id,
|
|
1076
|
+
label: source.label,
|
|
1077
|
+
fileName: source.fileName,
|
|
1078
|
+
summary: source.summary,
|
|
1079
|
+
studyInputJson: source.studyInputJson,
|
|
1080
|
+
topology: source.topology,
|
|
1081
|
+
coordsKind: source.coordsKind,
|
|
1082
|
+
view: source.view,
|
|
1083
|
+
substations: source.substations
|
|
1084
|
+
});
|
|
1085
|
+
for (const key of [
|
|
1086
|
+
'diagram',
|
|
1087
|
+
'displayMode',
|
|
1088
|
+
'geoSource',
|
|
1089
|
+
'geoWarnings',
|
|
1090
|
+
'syntheticCenter',
|
|
1091
|
+
'formulation',
|
|
1092
|
+
'deltas',
|
|
1093
|
+
'ratings',
|
|
1094
|
+
'solution',
|
|
1095
|
+
'baseSolution',
|
|
1096
|
+
'iterations',
|
|
1097
|
+
'solveMs',
|
|
1098
|
+
'solveBackend'
|
|
1099
|
+
])
|
|
1100
|
+
Object.assign(copy, { [key]: source[key] });
|
|
1101
|
+
const captured = this.detachedCaseCaptures.get(source);
|
|
1102
|
+
if (captured)
|
|
1103
|
+
this.detachedCaseCaptures.set(copy, { ...captured });
|
|
1104
|
+
return copy;
|
|
1105
|
+
}
|
|
1106
|
+
async attachSidecarsToSelected(layers, displays = [], expected) {
|
|
1107
|
+
if (this.app.studyView) {
|
|
1108
|
+
this.app.error = 'Return to the live case before attaching coordinates';
|
|
895
1109
|
return;
|
|
896
1110
|
}
|
|
1111
|
+
const source = this.app.activeMulti ?? this.app.activeLocal ?? this.app.active;
|
|
1112
|
+
if (expected !== undefined && source !== expected) {
|
|
1113
|
+
this.app.error = 'The selected case changed. Attach the coordinates again';
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (!source) {
|
|
1117
|
+
this.app.error = 'Select the matching case before attaching coordinates';
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
if (source instanceof MulticonductorCase) {
|
|
1121
|
+
try {
|
|
1122
|
+
await this.applyMultiGeoLayers(source, layers);
|
|
1123
|
+
}
|
|
1124
|
+
catch (error) {
|
|
1125
|
+
this.app.error = errorText(error);
|
|
1126
|
+
}
|
|
1127
|
+
if (displays.length)
|
|
1128
|
+
this.app.error = 'Attach a GeoJSON or CSV drawing to this multiconductor case';
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const selectedId = source.id;
|
|
1132
|
+
const revision = source.revisionGeneration;
|
|
1133
|
+
let target = null;
|
|
897
1134
|
try {
|
|
898
|
-
|
|
1135
|
+
target =
|
|
1136
|
+
source instanceof CaseState
|
|
1137
|
+
? await this.localSidecarCopy(source)
|
|
1138
|
+
: this.stageLocalSidecars(source);
|
|
1139
|
+
if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId || this.app.studyView)
|
|
1140
|
+
throw new Error('The selected case changed. Attach the coordinates again');
|
|
1141
|
+
const placement = await this.prepareDroppedLocal(target, layers, displays);
|
|
1142
|
+
const applied = placement.geoLayersConsumed || displays.some((display) => display.consumed);
|
|
1143
|
+
if (!applied)
|
|
1144
|
+
throw new Error(placement.error ?? 'No coordinates matched the selected case');
|
|
1145
|
+
if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId ||
|
|
1146
|
+
this.app.studyView ||
|
|
1147
|
+
source.revisionGeneration !== revision)
|
|
1148
|
+
throw new Error('The selected case changed. Attach the coordinates again');
|
|
1149
|
+
const saved = this.detachedCaseCaptures.get(target);
|
|
1150
|
+
if (saved)
|
|
1151
|
+
saved.baseInput = target.studyInputJson;
|
|
1152
|
+
if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId ||
|
|
1153
|
+
this.app.studyView ||
|
|
1154
|
+
source.revisionGeneration !== revision ||
|
|
1155
|
+
source.solving)
|
|
1156
|
+
throw new Error('The selected case changed. Attach the coordinates again');
|
|
1157
|
+
if (source instanceof LocalCase) {
|
|
1158
|
+
const cached = this.caseStudies.get(source);
|
|
1159
|
+
if (cached && target.studyInputJson && target.view) {
|
|
1160
|
+
const layer = await extractGeo(target.studyInputJson);
|
|
1161
|
+
try {
|
|
1162
|
+
await cached.study.applyGeoLayer(layer);
|
|
1163
|
+
}
|
|
1164
|
+
catch (error) {
|
|
1165
|
+
this.disposeStudy(source);
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
if ((this.app.activeLocal ?? this.app.active)?.id !== selectedId ||
|
|
1169
|
+
this.app.studyView ||
|
|
1170
|
+
source.revisionGeneration !== revision ||
|
|
1171
|
+
source.solving) {
|
|
1172
|
+
this.disposeStudy(source);
|
|
1173
|
+
throw new Error('The selected case changed. Attach the coordinates again');
|
|
1174
|
+
}
|
|
1175
|
+
cached.studyInputJson = target.studyInputJson;
|
|
1176
|
+
}
|
|
1177
|
+
for (const key of [
|
|
1178
|
+
'studyInputJson',
|
|
1179
|
+
'view',
|
|
1180
|
+
'diagram',
|
|
1181
|
+
'displayMode',
|
|
1182
|
+
'coordsKind',
|
|
1183
|
+
'geoSource',
|
|
1184
|
+
'geoWarnings',
|
|
1185
|
+
'syntheticCenter'
|
|
1186
|
+
])
|
|
1187
|
+
Object.assign(source, { [key]: target[key] });
|
|
1188
|
+
if (saved)
|
|
1189
|
+
this.detachedCaseCaptures.set(source, saved);
|
|
1190
|
+
target = source;
|
|
1191
|
+
}
|
|
1192
|
+
this.app.studyView = null;
|
|
1193
|
+
this.clearSelection();
|
|
899
1194
|
this.app.activeCaseId = null;
|
|
900
1195
|
this.leaveMulti();
|
|
1196
|
+
if (target !== source)
|
|
1197
|
+
this.app.addLocal(target);
|
|
901
1198
|
this.app.activeLocalId = target.id;
|
|
902
1199
|
this.app.placingLocalId = null;
|
|
1200
|
+
target.network = this.localNetwork(target);
|
|
1201
|
+
this.bumpRevision(target);
|
|
903
1202
|
this.app.requestFrame(target.id);
|
|
904
1203
|
this.maybeStartLocalSolve(target.id);
|
|
905
|
-
this.app.error =
|
|
1204
|
+
this.app.error = placement.error;
|
|
906
1205
|
}
|
|
907
|
-
catch (
|
|
908
|
-
|
|
1206
|
+
catch (error) {
|
|
1207
|
+
if (target && target !== source)
|
|
1208
|
+
this.disposeStudy(target);
|
|
1209
|
+
this.app.error = errorText(error);
|
|
909
1210
|
}
|
|
1211
|
+
}
|
|
1212
|
+
applyGeoLayersToExisting = async (layers) => {
|
|
1213
|
+
await this.attachSidecarsToSelected(layers);
|
|
910
1214
|
};
|
|
911
1215
|
/** Ask the map to frame the selected branch and wait for the camera to land,
|
|
912
1216
|
* capped by FOCUS_SETTLE_CAP_MS and cut short by the selection's abort. */
|
|
@@ -946,13 +1250,15 @@ export class Controller {
|
|
|
946
1250
|
if (ac.signal.aborted)
|
|
947
1251
|
return;
|
|
948
1252
|
}
|
|
1253
|
+
if (c.formulation === 'acpf')
|
|
1254
|
+
return;
|
|
949
1255
|
try {
|
|
950
1256
|
// The column from the browser Study (under the case's formulation). DC OPF
|
|
951
1257
|
// may reconcile a null column via the server; AC OPF / SOCWR are browser
|
|
952
1258
|
// only, so a null column there is terminal.
|
|
953
|
-
const
|
|
954
|
-
if (
|
|
955
|
-
const sensitivity = await this.browserSensitivity(c,
|
|
1259
|
+
const studyInputJson = await this.ensureStudyInputJson(c);
|
|
1260
|
+
if (studyInputJson) {
|
|
1261
|
+
const sensitivity = await this.browserSensitivity(c, studyInputJson, target);
|
|
956
1262
|
if (!ac.signal.aborted && sensitivity) {
|
|
957
1263
|
this.acceptSensitivity(c, sensitivity, target, sensitivitySeq);
|
|
958
1264
|
handled = true;
|
|
@@ -1005,7 +1311,7 @@ export class Controller {
|
|
|
1005
1311
|
computeOffNotice(c) {
|
|
1006
1312
|
return this.studyUnavailable.has(c) ? WASM_REQUIRED_NOTICE : COMPUTE_OFF_NOTICE;
|
|
1007
1313
|
}
|
|
1008
|
-
/** The one server
|
|
1314
|
+
/** The one server nodal-value/parameter request a selection may make: the
|
|
1009
1315
|
* reconciliation path when the browser Study produced no column for a DC OPF
|
|
1010
1316
|
* case. Never retries on its own: a second request inside the same rate limit
|
|
1011
1317
|
* window is a guaranteed rejection, and the retry button covers the user
|
|
@@ -1016,7 +1322,8 @@ export class Controller {
|
|
|
1016
1322
|
const retryOp = 'bus' in target
|
|
1017
1323
|
? () => this.selectBus(c.id, target.bus)
|
|
1018
1324
|
: () => this.selectBranch(c.id, target.branch);
|
|
1019
|
-
|
|
1325
|
+
const api = this.api;
|
|
1326
|
+
if (this.serverComputeOff || !api) {
|
|
1020
1327
|
this.fail(this.computeOffNotice(c), retryOp);
|
|
1021
1328
|
return;
|
|
1022
1329
|
}
|
|
@@ -1026,8 +1333,8 @@ export class Controller {
|
|
|
1026
1333
|
}
|
|
1027
1334
|
try {
|
|
1028
1335
|
const col = 'bus' in target
|
|
1029
|
-
? await
|
|
1030
|
-
: await
|
|
1336
|
+
? await api.getSensitivity(c.id, target.bus, c.deltas, ac.signal)
|
|
1337
|
+
: await api.getBranchSensitivity(c.id, target.branch, c.deltas, ac.signal);
|
|
1031
1338
|
if (!ac.signal.aborted) {
|
|
1032
1339
|
// The solution on screen came from the browser or the served base
|
|
1033
1340
|
// solve; the column came from the server. Label the mix.
|
|
@@ -1071,7 +1378,7 @@ export class Controller {
|
|
|
1071
1378
|
};
|
|
1072
1379
|
selectLocalBus = async (localId, busId) => {
|
|
1073
1380
|
const c = this.app.localCases.find((lc) => lc.id === localId);
|
|
1074
|
-
if (!c?.
|
|
1381
|
+
if (!c?.studyInputJson || !c.network)
|
|
1075
1382
|
return;
|
|
1076
1383
|
this.app.activeCaseId = null;
|
|
1077
1384
|
this.leaveMulti();
|
|
@@ -1079,16 +1386,20 @@ export class Controller {
|
|
|
1079
1386
|
this.app.placingLocalId = null;
|
|
1080
1387
|
const { ac, sensitivitySeq } = this.beginBusSelection(c, busId);
|
|
1081
1388
|
try {
|
|
1082
|
-
|
|
1389
|
+
if (c.formulation === 'acpf')
|
|
1390
|
+
return;
|
|
1391
|
+
const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
|
|
1083
1392
|
bus: busId
|
|
1084
1393
|
});
|
|
1085
1394
|
if (!ac.signal.aborted)
|
|
1086
1395
|
this.acceptSensitivity(c, sensitivity, { bus: busId }, sensitivitySeq);
|
|
1087
1396
|
if (!ac.signal.aborted && !sensitivity) {
|
|
1088
|
-
// A null column means the solve ran but produced no
|
|
1397
|
+
// A null column means the solve ran but produced no price/demand column for this bus (or the
|
|
1089
1398
|
// Study could not be built); local cases have no server fallback, so say so
|
|
1090
|
-
// instead of leaving the panel in
|
|
1091
|
-
this.app.error = `${c.label}: ${formulationLabel(c.formulation)} sensitivity unavailable in the browser${c.solveFallbackReason
|
|
1399
|
+
// instead of leaving the panel in nodal value view with no explanation.
|
|
1400
|
+
this.app.error = `${c.label}: ${formulationLabel(c.formulation)} sensitivity unavailable in the browser${c.solveFallbackReason
|
|
1401
|
+
? `: ${c.solveFallbackReason}`
|
|
1402
|
+
: ' (no price sensitivity column for this bus)'}`;
|
|
1092
1403
|
}
|
|
1093
1404
|
}
|
|
1094
1405
|
catch (e) {
|
|
@@ -1112,7 +1423,7 @@ export class Controller {
|
|
|
1112
1423
|
failColumnUnavailable(c, target, retry) {
|
|
1113
1424
|
const what = 'bus' in target
|
|
1114
1425
|
? `${formulationLabel(c.formulation)} sensitivity`
|
|
1115
|
-
: '
|
|
1426
|
+
: 'price/rating sensitivity';
|
|
1116
1427
|
this.fail(`${this.caseName(c)}: ${what} unavailable${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`, retry);
|
|
1117
1428
|
}
|
|
1118
1429
|
/** `focus` frames the selected line on the map first; the sensitivity solve
|
|
@@ -1120,7 +1431,7 @@ export class Controller {
|
|
|
1120
1431
|
selectBranch = (caseId, branchId, opts = {}) => this.selectTarget(caseId, { branch: branchId }, opts.focus ?? false);
|
|
1121
1432
|
selectLocalBranch = async (localId, branchId, opts = {}) => {
|
|
1122
1433
|
const c = this.app.localCases.find((lc) => lc.id === localId);
|
|
1123
|
-
if (!c?.
|
|
1434
|
+
if (!c?.studyInputJson || !c.network)
|
|
1124
1435
|
return;
|
|
1125
1436
|
this.app.activeCaseId = null;
|
|
1126
1437
|
this.leaveMulti();
|
|
@@ -1134,7 +1445,9 @@ export class Controller {
|
|
|
1134
1445
|
if (ac.signal.aborted)
|
|
1135
1446
|
return;
|
|
1136
1447
|
}
|
|
1137
|
-
|
|
1448
|
+
if (c.formulation === 'acpf')
|
|
1449
|
+
return;
|
|
1450
|
+
const sensitivity = await this.browserSensitivity(c, c.studyInputJson, {
|
|
1138
1451
|
branch: branchId
|
|
1139
1452
|
});
|
|
1140
1453
|
if (ac.signal.aborted)
|
|
@@ -1160,11 +1473,13 @@ export class Controller {
|
|
|
1160
1473
|
this.abort?.abort();
|
|
1161
1474
|
const c = this.app.active;
|
|
1162
1475
|
if (c) {
|
|
1476
|
+
this.bumpRevision(c);
|
|
1163
1477
|
c.sensitivitySeq++;
|
|
1164
1478
|
c.sensitivity = null;
|
|
1165
1479
|
}
|
|
1166
1480
|
const lc = this.app.activeLocal;
|
|
1167
1481
|
if (lc) {
|
|
1482
|
+
this.bumpRevision(lc);
|
|
1168
1483
|
lc.sensitivitySeq = (lc.sensitivitySeq ?? 0) + 1;
|
|
1169
1484
|
lc.sensitivity = null;
|
|
1170
1485
|
}
|
|
@@ -1180,7 +1495,7 @@ export class Controller {
|
|
|
1180
1495
|
this.app.previewDeltaMw = null;
|
|
1181
1496
|
this.app.previewRatingMw = null;
|
|
1182
1497
|
this.app.previewActive = false;
|
|
1183
|
-
this.app.
|
|
1498
|
+
this.app.previewPrices = null;
|
|
1184
1499
|
this.previewObjective = null;
|
|
1185
1500
|
this.ratingSlope = null;
|
|
1186
1501
|
this.app.demandRangeMode = 'local';
|
|
@@ -1188,12 +1503,14 @@ export class Controller {
|
|
|
1188
1503
|
this.app.sensitivityLoading = false;
|
|
1189
1504
|
};
|
|
1190
1505
|
// Exact solve in the browser (wasm). The build-once Study commits the new
|
|
1191
|
-
// operating point without re-parsing, returning the
|
|
1192
|
-
// the selection target in the same solve; on a Study failure or missing
|
|
1193
|
-
//
|
|
1506
|
+
// operating point without re-parsing, returning the nodal-value/parameter column for
|
|
1507
|
+
// the selection target in the same solve; on a Study failure or missing PowerIO
|
|
1508
|
+
// module it reconciles via the server stream (backend cases). Rating edits never
|
|
1194
1509
|
// fall back: the server solves at base ratings, so a Study failure there is
|
|
1195
1510
|
// terminal.
|
|
1196
1511
|
runSolve = (c, target) => {
|
|
1512
|
+
if (c.formulation === 'acpf')
|
|
1513
|
+
target = null;
|
|
1197
1514
|
// Cancel this case's own previous server stream, if any (backend only).
|
|
1198
1515
|
if (this.isBackendCase(c)) {
|
|
1199
1516
|
c.closeStream?.();
|
|
@@ -1207,11 +1524,18 @@ export class Controller {
|
|
|
1207
1524
|
c.solveFallbackReason = null;
|
|
1208
1525
|
c.iterations = [];
|
|
1209
1526
|
c.solveMs = null;
|
|
1210
|
-
this.
|
|
1527
|
+
this.ensureStudyInputJson(c).then(async (studyInputJson) => {
|
|
1211
1528
|
if (seq !== (c.solveSeq ?? 0))
|
|
1212
1529
|
return;
|
|
1213
|
-
if (!
|
|
1214
|
-
c.solveFallbackReason ??= '
|
|
1530
|
+
if (!studyInputJson) {
|
|
1531
|
+
c.solveFallbackReason ??= 'PowerIO module unavailable';
|
|
1532
|
+
// The server fallback only implements DC OPF. Preserve the requested
|
|
1533
|
+
// calculation when its browser input cannot be loaded.
|
|
1534
|
+
if (c.formulation !== 'dcopf') {
|
|
1535
|
+
c.solving = false;
|
|
1536
|
+
this.app.error = `${this.caseName(c)}: ${formulationLabel(c.formulation)} requires its PowerIO module; ${c.solveFallbackReason}`;
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1215
1539
|
if (this.hasRatingEdits(c)) {
|
|
1216
1540
|
c.solving = false;
|
|
1217
1541
|
this.app.error = this.ratingEditsFallbackError(c);
|
|
@@ -1220,15 +1544,15 @@ export class Controller {
|
|
|
1220
1544
|
if (this.isBackendCase(c))
|
|
1221
1545
|
return this.serverSolve(c, target, seq);
|
|
1222
1546
|
c.solving = false;
|
|
1223
|
-
this.app.error = `${c.label}: local case has no
|
|
1547
|
+
this.app.error = `${c.label}: local case has no retained PowerIO module`;
|
|
1224
1548
|
return;
|
|
1225
1549
|
}
|
|
1226
1550
|
const t0 = performance.now();
|
|
1227
1551
|
c.solveBackend = 'clarabel-wasm';
|
|
1228
1552
|
// Build-once Study path: commit the new operating point (no re-parse). The
|
|
1229
|
-
//
|
|
1553
|
+
// selected nodal value sensitivity column is computed in the same
|
|
1230
1554
|
// solve and comes back with it, so there is no second solve to reconcile it.
|
|
1231
|
-
const study = await this.getStudy(c,
|
|
1555
|
+
const study = await this.getStudy(c, studyInputJson);
|
|
1232
1556
|
if (seq !== (c.solveSeq ?? 0))
|
|
1233
1557
|
return;
|
|
1234
1558
|
if (study) {
|
|
@@ -1303,13 +1627,17 @@ export class Controller {
|
|
|
1303
1627
|
return;
|
|
1304
1628
|
}
|
|
1305
1629
|
c.solving = false;
|
|
1306
|
-
|
|
1630
|
+
const failure = browserSolveFailure(c.label, c.solveFallbackReason);
|
|
1631
|
+
this.app.error = failure.message;
|
|
1632
|
+
this.app.errorDetail = failure.detail;
|
|
1307
1633
|
});
|
|
1308
1634
|
};
|
|
1309
1635
|
serverSolve = (c, target, seq = c.solveSeq) => {
|
|
1310
|
-
// Known-disabled server compute: show the notice
|
|
1311
|
-
// stream that answers 403 without a readable body
|
|
1312
|
-
|
|
1636
|
+
// Known-disabled server compute (or no backend at all): show the notice
|
|
1637
|
+
// instead of opening a stream that answers 403 without a readable body
|
|
1638
|
+
// (EventSource hides it).
|
|
1639
|
+
const api = this.api;
|
|
1640
|
+
if (this.serverComputeOff || !api) {
|
|
1313
1641
|
if (seq !== c.solveSeq)
|
|
1314
1642
|
return;
|
|
1315
1643
|
c.solving = false;
|
|
@@ -1325,7 +1653,7 @@ export class Controller {
|
|
|
1325
1653
|
c.solveFallbackReason ??= 'browser solve unavailable';
|
|
1326
1654
|
// The server computes bus columns only; a branch target streams no sensitivity.
|
|
1327
1655
|
const sensBus = target && 'bus' in target ? target.bus : null;
|
|
1328
|
-
c.closeStream =
|
|
1656
|
+
c.closeStream = api.openSolveStream(c.id, c.deltas, sensBus, {
|
|
1329
1657
|
onsolution: (sol) => {
|
|
1330
1658
|
if (seq !== c.solveSeq)
|
|
1331
1659
|
return;
|
|
@@ -1366,6 +1694,7 @@ export class Controller {
|
|
|
1366
1694
|
this.runPreview(c, bus, value);
|
|
1367
1695
|
c.predictedObjective = this.predictedDeltaObj;
|
|
1368
1696
|
c.deltas = this.previewDeltas(c, bus, value);
|
|
1697
|
+
this.bumpRevision(c);
|
|
1369
1698
|
this.app.previewDeltaMw = value;
|
|
1370
1699
|
this.app.previewActive = true;
|
|
1371
1700
|
this.runSolve(c, { bus });
|
|
@@ -1373,7 +1702,7 @@ export class Controller {
|
|
|
1373
1702
|
finishDemandInput = (value) => {
|
|
1374
1703
|
if (this.sliderDisabled)
|
|
1375
1704
|
return;
|
|
1376
|
-
if (Math.abs(value - this.committedDelta) <
|
|
1705
|
+
if (Math.abs(value - this.committedDelta) < DEAD_ZONE_MW) {
|
|
1377
1706
|
if (!this.activeSolvable?.solving) {
|
|
1378
1707
|
this.app.previewActive = false;
|
|
1379
1708
|
this.app.previewDeltaMw = null;
|
|
@@ -1385,8 +1714,9 @@ export class Controller {
|
|
|
1385
1714
|
resetCase = (c) => {
|
|
1386
1715
|
c.deltas = {};
|
|
1387
1716
|
c.ratings = {};
|
|
1717
|
+
this.bumpRevision(c);
|
|
1388
1718
|
c.predictedObjective = null;
|
|
1389
|
-
this.app.
|
|
1719
|
+
this.app.previewPrices = null;
|
|
1390
1720
|
this.previewObjective = null;
|
|
1391
1721
|
this.ratingSlope = null;
|
|
1392
1722
|
this.app.previewDeltaMw = this.app.selectedBus === null ? null : 0;
|
|
@@ -1399,19 +1729,19 @@ export class Controller {
|
|
|
1399
1729
|
c.solution = c.baseSolution;
|
|
1400
1730
|
this.runSolve(c, this.selectionTarget);
|
|
1401
1731
|
};
|
|
1402
|
-
|
|
1403
|
-
// entirely in the browser via the Study (nothing is routed to the server), so this
|
|
1404
|
-
// disposes the old Study — `getStudy` rebuilds it for the new formulation, re-parsing
|
|
1405
|
-
// and re-solving the base — then re-solves at the committed demand. The base solution
|
|
1406
|
-
// is dropped so it is recaptured under the new formulation (a DC and an AC objective
|
|
1407
|
-
// are not comparable). A no-op when the choice is unchanged.
|
|
1732
|
+
/** Rebuild the numerical Study for a selected calculation at the current demand. */
|
|
1408
1733
|
changeFormulation = (c, next) => {
|
|
1409
1734
|
if (c.formulation === next)
|
|
1410
1735
|
return;
|
|
1411
|
-
|
|
1736
|
+
if (c instanceof LocalCase && c.declaredFormulation && c.declaredFormulation !== next) {
|
|
1737
|
+
this.fail('This PowerIO instance requires its declared calculation.');
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1412
1740
|
if (FORMULATIONS.find((f) => f.id === next)?.disabled)
|
|
1413
1741
|
return;
|
|
1414
1742
|
c.formulation = next;
|
|
1743
|
+
this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
|
|
1744
|
+
this.bumpRevision(c);
|
|
1415
1745
|
// The committed point carries over (same demand), but the model and its solution do
|
|
1416
1746
|
// not; drop the Study and the cached solutions so they rebuild under `next`.
|
|
1417
1747
|
this.disposeStudy(c);
|
|
@@ -1420,7 +1750,7 @@ export class Controller {
|
|
|
1420
1750
|
c.iterations = [];
|
|
1421
1751
|
c.solveMs = null;
|
|
1422
1752
|
c.predictedObjective = null;
|
|
1423
|
-
this.app.
|
|
1753
|
+
this.app.previewPrices = null;
|
|
1424
1754
|
this.previewObjective = null;
|
|
1425
1755
|
this.ratingSlope = null;
|
|
1426
1756
|
this.app.error = null;
|
|
@@ -1460,10 +1790,8 @@ export class Controller {
|
|
|
1460
1790
|
}
|
|
1461
1791
|
};
|
|
1462
1792
|
ingestFileBatch = async (list) => {
|
|
1793
|
+
const sidecarTarget = this.app.activeMulti ?? this.app.activeLocal ?? this.app.active;
|
|
1463
1794
|
let parsedCaseCount = 0;
|
|
1464
|
-
// True once a dropped balanced case took the geographic sidecars. When no
|
|
1465
|
-
// case parses, the selected existing case may take them at the end.
|
|
1466
|
-
let geoLayersConsumed = false;
|
|
1467
1795
|
// Routable `.json` content (saved packages, model JSON, and transmission or
|
|
1468
1796
|
// distribution documents) shares the extension with geographic files. Route
|
|
1469
1797
|
// it up front, but stage recognized payloads until the geographic/display
|
|
@@ -1475,9 +1803,7 @@ export class Controller {
|
|
|
1475
1803
|
for (const file of list) {
|
|
1476
1804
|
if (file.name.toLowerCase().endsWith('.json')) {
|
|
1477
1805
|
const route = await this.routeJsonDrop(file);
|
|
1478
|
-
if (route.outcome === 'balanced' ||
|
|
1479
|
-
route.outcome === 'restored' ||
|
|
1480
|
-
route.outcome === 'multiconductor') {
|
|
1806
|
+
if (route.outcome === 'balanced' || route.outcome === 'multiconductor') {
|
|
1481
1807
|
parsedCaseCount++;
|
|
1482
1808
|
routedJson.push({ file, route });
|
|
1483
1809
|
continue;
|
|
@@ -1499,7 +1825,7 @@ export class Controller {
|
|
|
1499
1825
|
geoLayers.push({
|
|
1500
1826
|
name: file.name,
|
|
1501
1827
|
layer: parsed.layer,
|
|
1502
|
-
|
|
1828
|
+
diagnostics: parsed.diagnostics
|
|
1503
1829
|
});
|
|
1504
1830
|
this.app.error = null;
|
|
1505
1831
|
}
|
|
@@ -1543,16 +1869,21 @@ export class Controller {
|
|
|
1543
1869
|
try {
|
|
1544
1870
|
for (const { file, route } of routedJson) {
|
|
1545
1871
|
if (route.outcome === 'multiconductor') {
|
|
1546
|
-
this.addMultiCase(file.name, route.payload);
|
|
1872
|
+
const multi = this.addMultiCase(file.name, route.payload);
|
|
1873
|
+
if (geoLayers.length) {
|
|
1874
|
+
try {
|
|
1875
|
+
await this.applyMultiGeoLayers(multi, geoLayers);
|
|
1876
|
+
}
|
|
1877
|
+
catch (error) {
|
|
1878
|
+
this.app.error = errorText(error);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1547
1881
|
continue;
|
|
1548
1882
|
}
|
|
1549
|
-
if (route.outcome !== 'balanced'
|
|
1883
|
+
if (route.outcome !== 'balanced')
|
|
1550
1884
|
continue;
|
|
1551
|
-
const local = route.
|
|
1552
|
-
? this.localFromPackage(file.name, route.payload)
|
|
1553
|
-
: this.localFromBalancedPayload(file.name, route.payload);
|
|
1885
|
+
const local = this.localFromBalancedPayload(file.name, route.payload);
|
|
1554
1886
|
const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
|
|
1555
|
-
geoLayersConsumed ||= placement.geoLayersConsumed;
|
|
1556
1887
|
this.addAndActivateLocal(local);
|
|
1557
1888
|
this.app.error = placement.error;
|
|
1558
1889
|
}
|
|
@@ -1564,8 +1895,17 @@ export class Controller {
|
|
|
1564
1895
|
// OpenDSS routes by extension into the multiconductor viewing path.
|
|
1565
1896
|
const distFormat = distExtensionFormat(file.name);
|
|
1566
1897
|
if (distFormat) {
|
|
1567
|
-
if (await this.ingestDistFile(file, distFormat))
|
|
1898
|
+
if (await this.ingestDistFile(file, distFormat)) {
|
|
1568
1899
|
parsedCaseCount++;
|
|
1900
|
+
if (geoLayers.length && this.app.activeMulti) {
|
|
1901
|
+
try {
|
|
1902
|
+
await this.applyMultiGeoLayers(this.app.activeMulti, geoLayers);
|
|
1903
|
+
}
|
|
1904
|
+
catch (error) {
|
|
1905
|
+
this.app.error = errorText(error);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1569
1909
|
continue;
|
|
1570
1910
|
}
|
|
1571
1911
|
const format = formatOf(file.name);
|
|
@@ -1576,7 +1916,7 @@ export class Controller {
|
|
|
1576
1916
|
this.app.parsingFile = true;
|
|
1577
1917
|
try {
|
|
1578
1918
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
1579
|
-
const {
|
|
1919
|
+
const { module_json, topology, view, ...summary } = await ingestCase(bytes, format);
|
|
1580
1920
|
if (format === 'aux' && (summary.n_branch === 0 || summary.n_gen === 0)) {
|
|
1581
1921
|
this.app.error = `${file.name}: aux parsed, but no complete network; drop the matching .m or .raw case file`;
|
|
1582
1922
|
continue;
|
|
@@ -1590,13 +1930,12 @@ export class Controller {
|
|
|
1590
1930
|
label,
|
|
1591
1931
|
fileName: file.name,
|
|
1592
1932
|
summary,
|
|
1593
|
-
|
|
1933
|
+
studyInputJson: module_json,
|
|
1594
1934
|
topology,
|
|
1595
1935
|
coordsKind: summary.coords_kind,
|
|
1596
1936
|
view
|
|
1597
1937
|
});
|
|
1598
1938
|
const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
|
|
1599
|
-
geoLayersConsumed ||= placement.geoLayersConsumed;
|
|
1600
1939
|
this.addAndActivateLocal(local);
|
|
1601
1940
|
parsedCaseCount++;
|
|
1602
1941
|
this.app.error = placement.error; // a successful parse clears a prior file's error
|
|
@@ -1608,28 +1947,13 @@ export class Controller {
|
|
|
1608
1947
|
this.app.parsingFile = false;
|
|
1609
1948
|
}
|
|
1610
1949
|
}
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
lat: s.lat
|
|
1619
|
-
}));
|
|
1620
|
-
this.addAndActivateLocal(new LocalCase({
|
|
1621
|
-
id: `local-${++this.localSeq}`,
|
|
1622
|
-
label: d.file.name.replace(/\.[^.]+$/, ''),
|
|
1623
|
-
fileName: d.file.name,
|
|
1624
|
-
summary: null,
|
|
1625
|
-
view: null,
|
|
1626
|
-
substations: { points, approximate: true }
|
|
1627
|
-
}));
|
|
1628
|
-
}
|
|
1629
|
-
// When the drop carried only sidecars, apply them to the selected existing
|
|
1630
|
-
// case. Every balanced case parsed in this batch already took them above.
|
|
1631
|
-
if (geoLayers.length > 0 && !geoLayersConsumed && parsedCaseCount === 0) {
|
|
1632
|
-
await this.applyGeoLayersToExisting(geoLayers);
|
|
1950
|
+
if (parsedCaseCount === 0 && (geoLayers.length > 0 || displays.length > 0)) {
|
|
1951
|
+
await this.attachSidecarsToSelected(geoLayers, displays, sidecarTarget);
|
|
1952
|
+
}
|
|
1953
|
+
else {
|
|
1954
|
+
const unmatchedDisplays = displays.filter((display) => !display.consumed);
|
|
1955
|
+
if (unmatchedDisplays.length)
|
|
1956
|
+
this.app.error = `${unmatchedDisplays.map((display) => display.file.name).join(' + ')}: no drawing objects matched the dropped case`;
|
|
1633
1957
|
}
|
|
1634
1958
|
};
|
|
1635
1959
|
/** Apply co-dropped geographic data before a local case becomes active and
|
|
@@ -1637,43 +1961,96 @@ export class Controller {
|
|
|
1637
1961
|
* the first matching PowerWorld display. A failed geographic join keeps the
|
|
1638
1962
|
* parsed case and returns the user-facing placement error. */
|
|
1639
1963
|
prepareDroppedLocal = async (local, geoLayers, displays) => {
|
|
1640
|
-
|
|
1964
|
+
let geoLayersConsumed = false;
|
|
1965
|
+
let error = null;
|
|
1966
|
+
if (geoLayers.length > 0 && local.studyInputJson) {
|
|
1641
1967
|
try {
|
|
1642
1968
|
await this.applyGeoLayers(local, geoLayers);
|
|
1643
|
-
|
|
1969
|
+
geoLayersConsumed = true;
|
|
1644
1970
|
}
|
|
1645
1971
|
catch (e) {
|
|
1646
|
-
|
|
1647
|
-
geoLayersConsumed: false,
|
|
1648
|
-
error: `${geoLayers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`
|
|
1649
|
-
};
|
|
1972
|
+
error = `${geoLayers.map((layer) => layer.name).join(' + ')}: ${errorText(e)}`;
|
|
1650
1973
|
}
|
|
1651
1974
|
}
|
|
1652
|
-
if (local.
|
|
1975
|
+
if (local.studyInputJson && displays.length > 0) {
|
|
1653
1976
|
await this.fillFromDisplaySibling(local, displays);
|
|
1654
1977
|
}
|
|
1655
|
-
|
|
1978
|
+
this.placeTopologyAsDiagram(local);
|
|
1979
|
+
return { geoLayersConsumed, error };
|
|
1656
1980
|
};
|
|
1657
|
-
/**
|
|
1658
|
-
*
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1981
|
+
/** A case that still has no geometry gets a connectivity layout as its
|
|
1982
|
+
* diagram, so it can render and solve without a placement step. */
|
|
1983
|
+
placeTopologyAsDiagram(local) {
|
|
1984
|
+
if (local.view || local.diagram || !local.topology)
|
|
1985
|
+
return;
|
|
1986
|
+
const view = {
|
|
1987
|
+
...placeSyntheticTopology(local.topology, { lon: 0, lat: 0 }),
|
|
1988
|
+
coordinate_space: 'diagram'
|
|
1989
|
+
};
|
|
1990
|
+
const layer = JSON.stringify({
|
|
1991
|
+
type: 'FeatureCollection',
|
|
1992
|
+
powerio_geo: { space: 'diagram', kind: 'synthetic' },
|
|
1993
|
+
features: [
|
|
1994
|
+
...view.buses.map((b) => ({
|
|
1995
|
+
type: 'Feature',
|
|
1996
|
+
properties: { target: 'bus', id: String(b.id) },
|
|
1997
|
+
geometry: { type: 'Point', coordinates: [b.lon, b.lat] }
|
|
1998
|
+
})),
|
|
1999
|
+
...view.branches.map((b) => ({
|
|
2000
|
+
type: 'Feature',
|
|
2001
|
+
properties: {
|
|
2002
|
+
target: 'branch',
|
|
2003
|
+
branch_id: String(b.id),
|
|
2004
|
+
from: String(b.from),
|
|
2005
|
+
to: String(b.to)
|
|
2006
|
+
},
|
|
2007
|
+
geometry: { type: 'LineString', coordinates: b.path }
|
|
2008
|
+
}))
|
|
2009
|
+
]
|
|
2010
|
+
});
|
|
2011
|
+
local.diagram = {
|
|
2012
|
+
view,
|
|
2013
|
+
layer,
|
|
2014
|
+
name: 'Network diagram',
|
|
2015
|
+
warnings: ['No geographic coordinates; positions follow network connectivity.']
|
|
2016
|
+
};
|
|
2017
|
+
local.displayMode = 'diagram';
|
|
2018
|
+
}
|
|
2019
|
+
/** Retain diagram geometry separately from the electrical case's geographic data. */
|
|
1662
2020
|
fillFromDisplaySibling = async (c, displays) => {
|
|
1663
|
-
if (!c.
|
|
2021
|
+
if (!c.studyInputJson)
|
|
1664
2022
|
return;
|
|
1665
2023
|
for (const d of displays.filter((entry) => !entry.consumed)) {
|
|
1666
2024
|
try {
|
|
1667
|
-
const payload = await applyDisplayGeo(c.
|
|
1668
|
-
|
|
2025
|
+
const payload = await applyDisplayGeo(c.studyInputJson, d.bytes);
|
|
2026
|
+
if (!payload.view)
|
|
2027
|
+
continue;
|
|
2028
|
+
c.diagram = {
|
|
2029
|
+
view: payload.view,
|
|
2030
|
+
layer: d.preview.layer,
|
|
2031
|
+
name: d.file.name,
|
|
2032
|
+
warnings: [
|
|
2033
|
+
`${payload.report.matched_buses} buses, ${payload.report.matched_branches} routes matched; ${payload.report.unmatched_features} unmatched objects`,
|
|
2034
|
+
...payload.report.notes
|
|
2035
|
+
]
|
|
2036
|
+
};
|
|
2037
|
+
if (!c.view)
|
|
2038
|
+
c.displayMode = 'diagram';
|
|
2039
|
+
c.geoWarnings = [...(c.geoWarnings ?? []), ...c.diagram.warnings];
|
|
1669
2040
|
d.consumed = true;
|
|
1670
|
-
return;
|
|
1671
2041
|
}
|
|
1672
|
-
catch {
|
|
1673
|
-
|
|
2042
|
+
catch (error) {
|
|
2043
|
+
c.geoWarnings = [errorText(error)];
|
|
1674
2044
|
}
|
|
1675
2045
|
}
|
|
1676
2046
|
};
|
|
2047
|
+
setCaseDisplayMode = (c, mode) => {
|
|
2048
|
+
if (mode === 'diagram' ? !c.diagram : !c.view)
|
|
2049
|
+
return;
|
|
2050
|
+
c.displayMode = mode;
|
|
2051
|
+
c.network = this.localNetwork(c);
|
|
2052
|
+
this.app.requestFrame(c.id);
|
|
2053
|
+
};
|
|
1677
2054
|
/** Route a dropped `.json` by its content. Recognized payloads return without
|
|
1678
2055
|
* mutating case state so co-dropped sidecars can be applied first; unrouted
|
|
1679
2056
|
* content returns the bytes already read for the geographic fallback. */
|
|
@@ -1694,15 +2071,20 @@ export class Controller {
|
|
|
1694
2071
|
this.app.error = `${file.name}: JSON markers name both transmission and distribution formats`;
|
|
1695
2072
|
return { outcome: 'failed' };
|
|
1696
2073
|
}
|
|
1697
|
-
if (result.kind === '
|
|
2074
|
+
if (result.kind === 'transmission') {
|
|
1698
2075
|
this.app.error = null;
|
|
1699
|
-
return { outcome: '
|
|
2076
|
+
return { outcome: 'balanced', payload: result.payload };
|
|
1700
2077
|
}
|
|
1701
|
-
if (result.kind === '
|
|
2078
|
+
if (result.kind === 'module') {
|
|
2079
|
+
// A stored module carries either network family; the payload's own
|
|
2080
|
+
// marker decides the route, mirroring the engine's value kind.
|
|
1702
2081
|
this.app.error = null;
|
|
1703
|
-
|
|
2082
|
+
const payload = result.payload;
|
|
2083
|
+
if ('model' in payload)
|
|
2084
|
+
return { outcome: 'multiconductor', payload };
|
|
2085
|
+
return { outcome: 'balanced', payload };
|
|
1704
2086
|
}
|
|
1705
|
-
if (result.kind === '
|
|
2087
|
+
if (result.kind === 'distribution') {
|
|
1706
2088
|
this.app.error = null;
|
|
1707
2089
|
return { outcome: 'multiconductor', payload: result.payload };
|
|
1708
2090
|
}
|
|
@@ -1734,14 +2116,14 @@ export class Controller {
|
|
|
1734
2116
|
this.app.parsingFile = false;
|
|
1735
2117
|
}
|
|
1736
2118
|
};
|
|
1737
|
-
/**
|
|
1738
|
-
* Geographic cases place immediately; planar/synthetic cases enter placement
|
|
1739
|
-
* (a map click or "place on map") so the user picks their center. */
|
|
2119
|
+
/** Retain parsed electrical inputs and draw each declared coordinate space directly. */
|
|
1740
2120
|
addMultiCase(fileName, payload) {
|
|
1741
2121
|
const { graph, ...summary } = payload;
|
|
1742
2122
|
const coordsKind = payload.coords_kind;
|
|
1743
2123
|
const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
|
|
1744
|
-
const view = coordsKind === 'geographic'
|
|
2124
|
+
const view = coordsKind === 'geographic'
|
|
2125
|
+
? buildGeographicView(graph, payload.geo_layer)
|
|
2126
|
+
: buildDiagramView(graph, payload.geo_layer);
|
|
1745
2127
|
const c = new MulticonductorCase({
|
|
1746
2128
|
id: `dist-${++this.localSeq}`,
|
|
1747
2129
|
label,
|
|
@@ -1751,12 +2133,352 @@ export class Controller {
|
|
|
1751
2133
|
coordsKind,
|
|
1752
2134
|
view
|
|
1753
2135
|
});
|
|
2136
|
+
this.app.studyView = null;
|
|
2137
|
+
this.clearSelection();
|
|
1754
2138
|
this.app.addMulti(c);
|
|
1755
2139
|
if (c.placed)
|
|
1756
2140
|
this.app.requestFrame(c.id);
|
|
2141
|
+
return c;
|
|
1757
2142
|
}
|
|
2143
|
+
/** Compute terminal quantities only for the retained input and current case revision. */
|
|
2144
|
+
solveMultiCase = async (c, options = {}, signal) => {
|
|
2145
|
+
if (signal?.aborted)
|
|
2146
|
+
throw new DOMException('Calculation cancelled', 'AbortError');
|
|
2147
|
+
if (c.solving)
|
|
2148
|
+
throw new Error('A calculation is already running for this case');
|
|
2149
|
+
const unavailable = c.mcPfReason;
|
|
2150
|
+
if (unavailable) {
|
|
2151
|
+
this.app.error = unavailable;
|
|
2152
|
+
throw new Error(unavailable);
|
|
2153
|
+
}
|
|
2154
|
+
let input = c.moduleJson;
|
|
2155
|
+
const retainedAtStart = c.mcSession;
|
|
2156
|
+
const revision = c.revisionGeneration;
|
|
2157
|
+
const seq = ++c.solveSeq;
|
|
2158
|
+
const abort = new AbortController();
|
|
2159
|
+
const cancel = () => abort.abort();
|
|
2160
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
2161
|
+
c.solveAbort = abort;
|
|
2162
|
+
c.solving = true;
|
|
2163
|
+
this.app.error = null;
|
|
2164
|
+
const started = performance.now();
|
|
2165
|
+
let nextSession = null;
|
|
2166
|
+
try {
|
|
2167
|
+
if (retainedAtStart)
|
|
2168
|
+
input = await retainedAtStart.inputModule();
|
|
2169
|
+
let snapshot = null;
|
|
2170
|
+
let result;
|
|
2171
|
+
let fullResult = null;
|
|
2172
|
+
let loadBranches = c.mcLoadBranches;
|
|
2173
|
+
if (this.mcTransport.createMcPfSession) {
|
|
2174
|
+
// The session answers with its summary; terminal and equipment
|
|
2175
|
+
// detail is fetched only when a view asks for it.
|
|
2176
|
+
nextSession = await this.mcTransport.createMcPfSession(input, options, abort.signal);
|
|
2177
|
+
result = nextSession.initialSummary;
|
|
2178
|
+
loadBranches = await nextSession.loadBranches();
|
|
2179
|
+
}
|
|
2180
|
+
else {
|
|
2181
|
+
snapshot = this.mcTransport.solveMcStudy
|
|
2182
|
+
? await this.mcTransport.solveMcStudy(input, crypto.randomUUID(), c.label, options, abort.signal)
|
|
2183
|
+
: null;
|
|
2184
|
+
fullResult =
|
|
2185
|
+
snapshot?.result ?? (await this.mcTransport.solveMcModule(input, options, abort.signal));
|
|
2186
|
+
result = summarizeMcPfResult(fullResult);
|
|
2187
|
+
}
|
|
2188
|
+
if (abort.signal.aborted)
|
|
2189
|
+
throw new DOMException('Calculation cancelled', 'AbortError');
|
|
2190
|
+
if (seq !== c.solveSeq ||
|
|
2191
|
+
(retainedAtStart ? retainedAtStart !== c.mcSession : input !== c.moduleJson) ||
|
|
2192
|
+
revision !== c.revisionGeneration ||
|
|
2193
|
+
!this.app.multiCases.includes(c))
|
|
2194
|
+
throw new Error('The case changed while calculating. Run AC power flow again');
|
|
2195
|
+
if (!result.converged)
|
|
2196
|
+
throw new Error('AC power flow did not converge');
|
|
2197
|
+
// A load edit staged against the previous session has nothing to
|
|
2198
|
+
// apply to the fresh operating point; drop its pending flush.
|
|
2199
|
+
if (c.mcEditTimer !== null) {
|
|
2200
|
+
clearTimeout(c.mcEditTimer);
|
|
2201
|
+
c.mcEditTimer = null;
|
|
2202
|
+
}
|
|
2203
|
+
c.mcSession?.free();
|
|
2204
|
+
c.mcSession = nextSession;
|
|
2205
|
+
nextSession = null;
|
|
2206
|
+
c.mcLoadBranches = loadBranches;
|
|
2207
|
+
c.mcLoadEdits = [];
|
|
2208
|
+
c.moduleJson = input;
|
|
2209
|
+
c.result = result;
|
|
2210
|
+
c.mcFullResult = fullResult;
|
|
2211
|
+
c.mcTiming = null;
|
|
2212
|
+
c.mcSnapshot = snapshot;
|
|
2213
|
+
c.mcSavedAt = null;
|
|
2214
|
+
c.solveMs = performance.now() - started;
|
|
2215
|
+
c.revisionGeneration++;
|
|
2216
|
+
return result;
|
|
2217
|
+
}
|
|
2218
|
+
catch (error) {
|
|
2219
|
+
nextSession?.free();
|
|
2220
|
+
if (!abort.signal.aborted &&
|
|
2221
|
+
!(error instanceof DOMException && error.name === 'AbortError')) {
|
|
2222
|
+
this.app.error = errorText(error);
|
|
2223
|
+
this.app.errorRetry = () => {
|
|
2224
|
+
void this.solveMultiCase(c, options).catch(() => { });
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
throw error;
|
|
2228
|
+
}
|
|
2229
|
+
finally {
|
|
2230
|
+
signal?.removeEventListener('abort', cancel);
|
|
2231
|
+
if (c.solveSeq === seq) {
|
|
2232
|
+
c.solving = false;
|
|
2233
|
+
c.solveAbort = null;
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
/** Materialize the current retained operating point only when a portable
|
|
2238
|
+
* snapshot is actually needed (save/export/geography). */
|
|
2239
|
+
snapshotMultiCase = async (c) => {
|
|
2240
|
+
if (c.mcSnapshot)
|
|
2241
|
+
return c.mcSnapshot;
|
|
2242
|
+
const session = c.mcSession;
|
|
2243
|
+
if (!session || !c.result)
|
|
2244
|
+
throw new Error('Run AC power flow before saving its result');
|
|
2245
|
+
if (c.solving)
|
|
2246
|
+
throw new Error('Wait for the calculation before saving its result');
|
|
2247
|
+
const revision = c.revisionGeneration;
|
|
2248
|
+
const snapshot = await session.snapshot(crypto.randomUUID(), c.label);
|
|
2249
|
+
if (session !== c.mcSession ||
|
|
2250
|
+
revision !== c.revisionGeneration ||
|
|
2251
|
+
!this.app.multiCases.includes(c))
|
|
2252
|
+
throw new Error('The case changed while preparing its result');
|
|
2253
|
+
c.mcSnapshot = snapshot;
|
|
2254
|
+
return snapshot;
|
|
2255
|
+
};
|
|
2256
|
+
/** One bus's terminals and a page of equipment ports for the displayed
|
|
2257
|
+
* operating point, from the live session or the stored full result. Null
|
|
2258
|
+
* when the operating point changed before the page arrived. */
|
|
2259
|
+
multiResultDetail = async (c, query) => {
|
|
2260
|
+
const summary = c.result;
|
|
2261
|
+
if (!summary)
|
|
2262
|
+
return null;
|
|
2263
|
+
const session = c.mcSession;
|
|
2264
|
+
if (session && !c.mcFullResult) {
|
|
2265
|
+
const detail = await session.detail(query);
|
|
2266
|
+
return session === c.mcSession && c.result?.solve_count === detail.solve_count
|
|
2267
|
+
? detail
|
|
2268
|
+
: null;
|
|
2269
|
+
}
|
|
2270
|
+
const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
|
|
2271
|
+
return full ? mcPfResultDetail(full, query, summary.solve_count) : null;
|
|
2272
|
+
};
|
|
2273
|
+
/** Every terminal's identity, voltage, and injected current, as numeric
|
|
2274
|
+
* arrays of interleaved `[re, im]` values. */
|
|
2275
|
+
multiTerminalTable = async (c) => {
|
|
2276
|
+
if (!c.result)
|
|
2277
|
+
return null;
|
|
2278
|
+
const session = c.mcSession;
|
|
2279
|
+
if (session && !c.mcFullResult) {
|
|
2280
|
+
// Arrays carry no solve count, and a running flush may discard its solve.
|
|
2281
|
+
if (c.mcEditRunning)
|
|
2282
|
+
return null;
|
|
2283
|
+
const solve = c.result.solve_count;
|
|
2284
|
+
const [ids, voltages, currents] = await Promise.all([
|
|
2285
|
+
session.terminalIds(),
|
|
2286
|
+
session.terminalVoltages(),
|
|
2287
|
+
session.terminalCurrents()
|
|
2288
|
+
]);
|
|
2289
|
+
return session === c.mcSession && c.result?.solve_count === solve
|
|
2290
|
+
? { ids, voltages, currents }
|
|
2291
|
+
: null;
|
|
2292
|
+
}
|
|
2293
|
+
const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
|
|
2294
|
+
if (!full)
|
|
2295
|
+
return null;
|
|
2296
|
+
const voltages = new Float64Array(2 * full.terminals.length);
|
|
2297
|
+
const currents = new Float64Array(2 * full.terminals.length);
|
|
2298
|
+
full.terminals.forEach((terminal, k) => {
|
|
2299
|
+
voltages[2 * k] = terminal.voltage.re;
|
|
2300
|
+
voltages[2 * k + 1] = terminal.voltage.im;
|
|
2301
|
+
currents[2 * k] = terminal.current_into_network.re;
|
|
2302
|
+
currents[2 * k + 1] = terminal.current_into_network.im;
|
|
2303
|
+
});
|
|
2304
|
+
return {
|
|
2305
|
+
ids: full.terminals.map((terminal) => [terminal.bus, terminal.terminal]),
|
|
2306
|
+
voltages,
|
|
2307
|
+
currents
|
|
2308
|
+
};
|
|
2309
|
+
};
|
|
2310
|
+
/** Apply coordinates atomically to the explicitly selected conductor-resolved case. */
|
|
2311
|
+
applyMultiGeoLayers = async (c, layers) => {
|
|
2312
|
+
if (!layers.length)
|
|
2313
|
+
return;
|
|
2314
|
+
if (this.app.studyView || this.app.activeMulti !== c)
|
|
2315
|
+
throw new Error('Select the matching case before attaching coordinates');
|
|
2316
|
+
if (c.solving)
|
|
2317
|
+
throw new Error('Wait for the calculation before attaching coordinates');
|
|
2318
|
+
if (!c.moduleJson || !this.mcTransport.applyMcGeo)
|
|
2319
|
+
throw new Error('Coordinate attachment is unavailable for this case');
|
|
2320
|
+
const baseInput = c.moduleJson;
|
|
2321
|
+
const retainedAtStart = c.mcSession;
|
|
2322
|
+
let snapshot = c.mcSnapshot;
|
|
2323
|
+
if (!snapshot && c.mcSession && c.result)
|
|
2324
|
+
snapshot = await this.snapshotMultiCase(c);
|
|
2325
|
+
const input = snapshot?.input_module ?? c.moduleJson;
|
|
2326
|
+
const revision = c.revisionGeneration;
|
|
2327
|
+
let next = input;
|
|
2328
|
+
let payload;
|
|
2329
|
+
const notes = [];
|
|
2330
|
+
let matched = 0;
|
|
2331
|
+
for (const layer of layers) {
|
|
2332
|
+
payload = await this.mcTransport.applyMcGeo(next, layer.layer);
|
|
2333
|
+
if (snapshot) {
|
|
2334
|
+
if (!this.mcTransport.applyMcStudyGeo)
|
|
2335
|
+
throw new Error('Saved result coordinates are unavailable in this build');
|
|
2336
|
+
snapshot = await this.mcTransport.applyMcStudyGeo(snapshot, layer.layer);
|
|
2337
|
+
}
|
|
2338
|
+
if (!payload.module_json)
|
|
2339
|
+
throw new Error('Coordinate attachment returned no electrical input');
|
|
2340
|
+
next = payload.module_json;
|
|
2341
|
+
matched += payload.report.matched_buses + payload.report.matched_branches;
|
|
2342
|
+
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));
|
|
2343
|
+
}
|
|
2344
|
+
if (!payload || !matched)
|
|
2345
|
+
throw new Error('No coordinates matched the selected case');
|
|
2346
|
+
if (this.app.studyView ||
|
|
2347
|
+
this.app.activeMulti !== c ||
|
|
2348
|
+
c.moduleJson !== baseInput ||
|
|
2349
|
+
c.mcSession !== retainedAtStart ||
|
|
2350
|
+
c.revisionGeneration !== revision ||
|
|
2351
|
+
c.solving)
|
|
2352
|
+
throw new Error('The selected case changed. Attach the coordinates again');
|
|
2353
|
+
const { graph, ...summary } = payload;
|
|
2354
|
+
if (c.mcEditTimer !== null) {
|
|
2355
|
+
clearTimeout(c.mcEditTimer);
|
|
2356
|
+
c.mcEditTimer = null;
|
|
2357
|
+
}
|
|
2358
|
+
c.mcSession?.free();
|
|
2359
|
+
c.mcSession = null;
|
|
2360
|
+
// Without a session, result detail comes from the saved result.
|
|
2361
|
+
c.mcFullResult = snapshot?.result ?? c.mcFullResult;
|
|
2362
|
+
c.mcLoadBranches = [];
|
|
2363
|
+
c.mcLoadEdits = [];
|
|
2364
|
+
c.moduleJson = next;
|
|
2365
|
+
c.mcSnapshot = snapshot ? { ...snapshot, id: crypto.randomUUID() } : null;
|
|
2366
|
+
c.mcSavedAt = null;
|
|
2367
|
+
c.geoLayer = payload.geo_layer ?? null;
|
|
2368
|
+
c.graph = graph;
|
|
2369
|
+
c.summary = summary;
|
|
2370
|
+
c.coordsKind = payload.coords_kind;
|
|
2371
|
+
c.view =
|
|
2372
|
+
payload.coords_kind === 'geographic'
|
|
2373
|
+
? buildGeographicView(graph, payload.geo_layer)
|
|
2374
|
+
: buildDiagramView(graph, payload.geo_layer);
|
|
2375
|
+
c.geoWarnings = notes;
|
|
2376
|
+
c.revisionGeneration++;
|
|
2377
|
+
this.app.placingMultiId = null;
|
|
2378
|
+
this.app.error = null;
|
|
2379
|
+
this.app.requestFrame(c.id);
|
|
2380
|
+
};
|
|
2381
|
+
/** Stage an absolute load-branch P/Q edit and coalesce rapid UI changes into
|
|
2382
|
+
* one retained-factor warm solve. */
|
|
2383
|
+
queueMultiLoadPower = (c, load, branch, p_w, q_var) => {
|
|
2384
|
+
if (!c.mcSession) {
|
|
2385
|
+
this.app.error = 'Run AC power flow once before editing loads';
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
if (!Number.isFinite(p_w) || !Number.isFinite(q_var)) {
|
|
2389
|
+
this.app.error = 'Load P and Q must be finite numbers';
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
const row = c.mcLoadBranches.find((entry) => entry.load === load && entry.branch === branch);
|
|
2393
|
+
if (!row) {
|
|
2394
|
+
this.app.error = `Load ${load} branch ${branch} is unavailable`;
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
c.mcLoadBranches = c.mcLoadBranches.map((entry) => entry.load === load && entry.branch === branch ? { ...entry, p_w, q_var } : entry);
|
|
2398
|
+
const next = { load, branch, p_w, q_var };
|
|
2399
|
+
const without = c.mcLoadEdits.filter((edit) => edit.load !== load || edit.branch !== branch);
|
|
2400
|
+
c.mcLoadEdits = p_w === row.base_p_w && q_var === row.base_q_var ? without : [...without, next];
|
|
2401
|
+
c.mcEditRevision++;
|
|
2402
|
+
c.mcSnapshot = null;
|
|
2403
|
+
c.mcSavedAt = null;
|
|
2404
|
+
c.solving = true;
|
|
2405
|
+
if (c.mcEditTimer !== null)
|
|
2406
|
+
clearTimeout(c.mcEditTimer);
|
|
2407
|
+
c.mcEditTimer = setTimeout(() => {
|
|
2408
|
+
c.mcEditTimer = null;
|
|
2409
|
+
void this.flushMultiLoadPowers(c);
|
|
2410
|
+
}, 180);
|
|
2411
|
+
};
|
|
2412
|
+
flushMultiLoadPowers = async (c) => {
|
|
2413
|
+
// A running flush picks the new revision up in its own loop.
|
|
2414
|
+
if (c.mcEditRunning)
|
|
2415
|
+
return;
|
|
2416
|
+
const session = c.mcSession;
|
|
2417
|
+
if (!session || !this.app.multiCases.includes(c)) {
|
|
2418
|
+
// `queueMultiLoadPower` marked the case as solving on behalf of
|
|
2419
|
+
// this flush; release it, or Save and Export stay disabled.
|
|
2420
|
+
c.solving = false;
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
c.mcEditRunning = true;
|
|
2424
|
+
c.solving = true;
|
|
2425
|
+
this.app.error = null;
|
|
2426
|
+
try {
|
|
2427
|
+
for (;;) {
|
|
2428
|
+
const revision = c.mcEditRevision;
|
|
2429
|
+
const edits = c.mcLoadEdits.map((edit) => ({ ...edit }));
|
|
2430
|
+
const started = performance.now();
|
|
2431
|
+
const summary = await session.replaceLoadPowers(edits);
|
|
2432
|
+
if (session !== c.mcSession || !this.app.multiCases.includes(c))
|
|
2433
|
+
return;
|
|
2434
|
+
if (revision !== c.mcEditRevision)
|
|
2435
|
+
continue;
|
|
2436
|
+
// One round trip: the queued edit already set the branch state
|
|
2437
|
+
// this successful solve confirms. Only a failure refetches it.
|
|
2438
|
+
c.result = summary;
|
|
2439
|
+
c.mcFullResult = null;
|
|
2440
|
+
c.mcSnapshot = null;
|
|
2441
|
+
c.mcSavedAt = null;
|
|
2442
|
+
const total = performance.now() - started;
|
|
2443
|
+
c.solveMs = total;
|
|
2444
|
+
c.mcTiming = session.lastTiming ? { ...session.lastTiming, total_ms: total } : null;
|
|
2445
|
+
c.revisionGeneration++;
|
|
2446
|
+
break;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
catch (error) {
|
|
2450
|
+
if (session === c.mcSession) {
|
|
2451
|
+
try {
|
|
2452
|
+
c.mcLoadBranches = await session.loadBranches();
|
|
2453
|
+
}
|
|
2454
|
+
catch {
|
|
2455
|
+
// The session itself no longer answers (the worker trapped
|
|
2456
|
+
// or the handle is gone). Retrying against it would fail
|
|
2457
|
+
// forever, so drop it and let the next run rebuild the
|
|
2458
|
+
// session against a fresh worker.
|
|
2459
|
+
session.free();
|
|
2460
|
+
c.mcSession = null;
|
|
2461
|
+
c.mcLoadEdits = [];
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
this.app.error = errorText(error);
|
|
2465
|
+
this.app.errorRetry = c.mcSession
|
|
2466
|
+
? () => void this.flushMultiLoadPowers(c)
|
|
2467
|
+
: () => void this.solveMultiCase(c).catch(() => { });
|
|
2468
|
+
}
|
|
2469
|
+
finally {
|
|
2470
|
+
// The running flag belongs to this call regardless of whether the
|
|
2471
|
+
// session was replaced meanwhile; leaving it set would silently
|
|
2472
|
+
// ignore every later edit. The solving flag is shared with an
|
|
2473
|
+
// explicit solve, which clears it itself when it owns it.
|
|
2474
|
+
c.mcEditRunning = false;
|
|
2475
|
+
if (session === c.mcSession || c.mcSession === null)
|
|
2476
|
+
c.solving = false;
|
|
2477
|
+
}
|
|
2478
|
+
};
|
|
1758
2479
|
/** Make a multiconductor case active, framing it when it is placed. */
|
|
1759
2480
|
activateMulti = (c) => {
|
|
2481
|
+
this.app.studyView = null;
|
|
1760
2482
|
this.clearSelection();
|
|
1761
2483
|
this.app.activeCaseId = null;
|
|
1762
2484
|
this.app.activeLocalId = null;
|
|
@@ -1814,6 +2536,11 @@ export class Controller {
|
|
|
1814
2536
|
};
|
|
1815
2537
|
removeMultiCase = async (c, event) => {
|
|
1816
2538
|
event?.stopPropagation();
|
|
2539
|
+
c.solveAbort?.abort();
|
|
2540
|
+
if (c.mcEditTimer !== null)
|
|
2541
|
+
clearTimeout(c.mcEditTimer);
|
|
2542
|
+
c.mcSession?.free();
|
|
2543
|
+
c.mcSession = null;
|
|
1817
2544
|
if (this.app.activeMultiId === c.id) {
|
|
1818
2545
|
c.selectedBusId = null;
|
|
1819
2546
|
c.selectedEdgeId = null;
|
|
@@ -1828,53 +2555,164 @@ export class Controller {
|
|
|
1828
2555
|
else
|
|
1829
2556
|
this.placeLocalCase(lon, lat);
|
|
1830
2557
|
};
|
|
1831
|
-
|
|
2558
|
+
async projectStudyInput(input, layer) {
|
|
2559
|
+
const parsed = layer
|
|
2560
|
+
? { payload: await applyGeo(input, layer) }
|
|
2561
|
+
: await ingestJsonDrop(new TextEncoder().encode(input));
|
|
2562
|
+
if (!parsed.payload || !('topology' in parsed.payload))
|
|
2563
|
+
throw new Error('Study requires a balanced network');
|
|
2564
|
+
const c = parsed.payload;
|
|
2565
|
+
const view = c.view ?? {
|
|
2566
|
+
...placeSyntheticTopology(c.topology, { lon: 0, lat: 0 }),
|
|
2567
|
+
coordinate_space: 'diagram'
|
|
2568
|
+
};
|
|
2569
|
+
return { id: 'study', name: c.name, base_mva: c.base_mva, synthetic_coords: !c.view, ...view };
|
|
2570
|
+
}
|
|
2571
|
+
async caseGeographyLayers(c) {
|
|
2572
|
+
if (!c.studyInputJson || !c.view)
|
|
2573
|
+
return [];
|
|
2574
|
+
return [await extractGeo(c.studyInputJson)];
|
|
2575
|
+
}
|
|
2576
|
+
/** Capture a matching cached result without constructing or solving a model. */
|
|
2577
|
+
async captureSavedCase(c) {
|
|
2578
|
+
const base = await this.ensureStudyInputJson(c);
|
|
2579
|
+
if (!base)
|
|
2580
|
+
throw new Error('The case input is unavailable');
|
|
2581
|
+
const detached = c instanceof LocalCase ? this.detachedCaseCaptures.get(c) : undefined;
|
|
2582
|
+
if (detached?.baseInput === base &&
|
|
2583
|
+
detached.edits === this.caseEditKey(c) &&
|
|
2584
|
+
detached.solution === c.solution)
|
|
2585
|
+
return detached.captured;
|
|
2586
|
+
const cached = this.caseStudies.get(c);
|
|
2587
|
+
if (c.solution && cached?.studyInputJson === base && cached.formulation === c.formulation) {
|
|
2588
|
+
const current = await cached.study.currentSolution();
|
|
2589
|
+
if (JSON.stringify(current) === JSON.stringify(c.solution)) {
|
|
2590
|
+
return {
|
|
2591
|
+
input: await cached.study.saveInstanceModule(),
|
|
2592
|
+
solution: await cached.study.saveSolutionModule(),
|
|
2593
|
+
view: await cached.study.currentView()
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
if (Object.values(this.caseDeltas(c)).some((v) => v !== 0) ||
|
|
2598
|
+
Object.values(this.caseRatings(c)).some((v) => v !== 0)) {
|
|
2599
|
+
throw new Error('The displayed edits have no matching saved result. Finish solving before saving them.');
|
|
2600
|
+
}
|
|
2601
|
+
const api = this.api;
|
|
2602
|
+
if (c instanceof CaseState && c.solution && c.formulation === 'dcopf' && api?.getSavedCase) {
|
|
2603
|
+
const snapshot = await api.getSavedCase(c.id);
|
|
2604
|
+
if (snapshot.base_input !== base ||
|
|
2605
|
+
JSON.stringify(snapshot.display_solution) !== JSON.stringify(c.solution))
|
|
2606
|
+
throw new Error('The server case changed. Reload it before saving.');
|
|
2607
|
+
return { input: snapshot.input, solution: snapshot.solution, view: snapshot.view };
|
|
2608
|
+
}
|
|
2609
|
+
return { input: base };
|
|
2610
|
+
}
|
|
2611
|
+
/** Build a local case from a balanced PowerIO ingest payload without activating
|
|
1832
2612
|
* it yet; co-dropped placement data is applied before the first solve. */
|
|
1833
2613
|
localFromBalancedPayload = (fileName, payload) => {
|
|
1834
|
-
const {
|
|
2614
|
+
const { module_json, topology, view, formulation, ...summary } = payload;
|
|
1835
2615
|
const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
|
|
1836
2616
|
return new LocalCase({
|
|
1837
2617
|
id: `local-${++this.localSeq}`,
|
|
1838
2618
|
label,
|
|
1839
2619
|
fileName,
|
|
2620
|
+
formulation,
|
|
2621
|
+
declaredFormulation: formulation,
|
|
1840
2622
|
summary,
|
|
1841
|
-
|
|
2623
|
+
studyInputJson: module_json,
|
|
1842
2624
|
topology,
|
|
1843
2625
|
coordsKind: summary.coords_kind,
|
|
1844
2626
|
view
|
|
1845
2627
|
});
|
|
1846
2628
|
};
|
|
1847
|
-
/**
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
const
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2629
|
+
/** Ingest module text through the engine and require a balanced network. */
|
|
2630
|
+
async ingestBalancedModule(name, moduleJson) {
|
|
2631
|
+
if (moduleJson === undefined)
|
|
2632
|
+
throw new Error(`${name}: openBalancedModule needs moduleJson or payload`);
|
|
2633
|
+
const result = await ingestJsonDrop(new TextEncoder().encode(moduleJson));
|
|
2634
|
+
if (result.kind === 'transmission')
|
|
2635
|
+
return result.payload;
|
|
2636
|
+
if (result.kind === 'module' && !('model' in result.payload))
|
|
2637
|
+
return result.payload;
|
|
2638
|
+
throw new Error(`${name}: not a balanced network module (${result.kind})`);
|
|
2639
|
+
}
|
|
2640
|
+
/** Open a balanced powerio module from memory, the programmatic twin of
|
|
2641
|
+
* dropping a `.pio.json` file: ingest (unless `payload` is given), apply an
|
|
2642
|
+
* optional geographic layer, lay out a module without coordinates as a
|
|
2643
|
+
* diagram, then add and activate it. Resolves to the new case; throws when
|
|
2644
|
+
* the module is not a balanced network or the geographic layer matches
|
|
2645
|
+
* nothing. */
|
|
2646
|
+
openBalancedModule = async (input) => {
|
|
2647
|
+
this.app.parsingFile = true;
|
|
2648
|
+
try {
|
|
2649
|
+
const payload = input.payload ?? (await this.ingestBalancedModule(input.name, input.moduleJson));
|
|
2650
|
+
const local = this.localFromBalancedPayload(`${input.name}.pio.json`, {
|
|
2651
|
+
...payload,
|
|
2652
|
+
name: input.name
|
|
2653
|
+
});
|
|
2654
|
+
if (input.formulation)
|
|
2655
|
+
local.formulation = input.formulation;
|
|
2656
|
+
if (input.geoLayer) {
|
|
2657
|
+
await this.applyGeoLayers(local, [
|
|
2658
|
+
{ name: `${input.name}.geo.json`, layer: input.geoLayer, diagnostics: [] }
|
|
2659
|
+
]);
|
|
2660
|
+
}
|
|
2661
|
+
this.placeTopologyAsDiagram(local);
|
|
2662
|
+
if (input.activate !== false)
|
|
2663
|
+
this.addAndActivateLocal(local, { frame: input.frame });
|
|
2664
|
+
return local;
|
|
2665
|
+
}
|
|
2666
|
+
finally {
|
|
2667
|
+
this.app.parsingFile = false;
|
|
2668
|
+
}
|
|
2669
|
+
};
|
|
2670
|
+
/** Swap a local case for a new module in place: same slot in the case list,
|
|
2671
|
+
* same formulation unless `input.formulation` says otherwise, same display
|
|
2672
|
+
* mode when the new case supports it, and the selected bus kept when the
|
|
2673
|
+
* new case has it. The previous case's Study is freed. */
|
|
2674
|
+
replaceLocalCase = async (previous, input) => {
|
|
2675
|
+
const wasActive = this.app.activeLocalId === previous.id;
|
|
2676
|
+
const selectedBus = wasActive ? this.app.selectedBus : null;
|
|
2677
|
+
const displayMode = previous.displayMode;
|
|
2678
|
+
const next = await this.openBalancedModule({
|
|
2679
|
+
...input,
|
|
2680
|
+
formulation: input.formulation ?? previous.formulation,
|
|
2681
|
+
activate: false
|
|
1862
2682
|
});
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
2683
|
+
if (displayMode === 'diagram' ? next.diagram : next.view)
|
|
2684
|
+
next.displayMode = displayMode;
|
|
2685
|
+
const slot = this.app.localCases.findIndex((c) => c.id === previous.id);
|
|
2686
|
+
// A seq bump invalidates any in-flight browser solve of the old case.
|
|
2687
|
+
previous.solveSeq = (previous.solveSeq ?? 0) + 1;
|
|
2688
|
+
this.disposeStudy(previous);
|
|
2689
|
+
if (wasActive) {
|
|
2690
|
+
this.addAndActivateLocal(next, { frame: input.frame });
|
|
2691
|
+
}
|
|
2692
|
+
else {
|
|
2693
|
+
this.app.localCases = [...this.app.localCases, next];
|
|
2694
|
+
this.maybeStartLocalSolve(next.id);
|
|
2695
|
+
}
|
|
2696
|
+
const cases = this.app.localCases.filter((c) => c.id !== previous.id && c.id !== next.id);
|
|
2697
|
+
cases.splice(slot < 0 ? cases.length : slot, 0, next);
|
|
2698
|
+
this.app.localCases = cases;
|
|
2699
|
+
if (this.app.placingLocalId === previous.id)
|
|
2700
|
+
this.app.placingLocalId = null;
|
|
2701
|
+
if (selectedBus !== null && localHasBus(next, selectedBus)) {
|
|
2702
|
+
void this.selectLocalBus(next.id, selectedBus);
|
|
2703
|
+
}
|
|
2704
|
+
return next;
|
|
1867
2705
|
};
|
|
1868
2706
|
/** Build (or reuse) the case's Study and commit it at the current edit state, so a
|
|
1869
2707
|
* save or export captures exactly what is on screen. Null (with an error set) when no
|
|
1870
|
-
*
|
|
2708
|
+
* PowerIO module or Study is available. */
|
|
1871
2709
|
syncedStudy = async (c) => {
|
|
1872
|
-
const
|
|
1873
|
-
if (!
|
|
1874
|
-
this.app.error = `${this.caseName(c)}: no
|
|
2710
|
+
const studyInputJson = await this.ensureStudyInputJson(c);
|
|
2711
|
+
if (!studyInputJson) {
|
|
2712
|
+
this.app.error = `${this.caseName(c)}: no PowerIO module available to save`;
|
|
1875
2713
|
return null;
|
|
1876
2714
|
}
|
|
1877
|
-
const study = await this.getStudy(c,
|
|
2715
|
+
const study = await this.getStudy(c, studyInputJson);
|
|
1878
2716
|
if (!study) {
|
|
1879
2717
|
this.app.error = `${this.caseName(c)}: the browser study is unavailable`;
|
|
1880
2718
|
return null;
|
|
@@ -1882,31 +2720,47 @@ export class Controller {
|
|
|
1882
2720
|
await study.commit(c.id, this.caseDeltas(c), this.caseRatings(c), null);
|
|
1883
2721
|
return study;
|
|
1884
2722
|
};
|
|
1885
|
-
/** Save the
|
|
1886
|
-
|
|
2723
|
+
/** Save the materialized case as a retained PowerIO module. */
|
|
2724
|
+
saveCaseModule = async (c) => {
|
|
2725
|
+
const study = await this.syncedStudy(c);
|
|
2726
|
+
if (!study)
|
|
2727
|
+
return;
|
|
2728
|
+
try {
|
|
2729
|
+
downloadText(await study.saveModule(), `${this.caseFileStem(c)}.pio.json`, 'application/json');
|
|
2730
|
+
this.app.error = null;
|
|
2731
|
+
}
|
|
2732
|
+
catch (e) {
|
|
2733
|
+
this.app.error = `${this.caseName(c)}: could not save PowerIO module: ${errorText(e)}`;
|
|
2734
|
+
}
|
|
2735
|
+
};
|
|
2736
|
+
/** Save the exact DC OPF result with the materialized problem instance it solved. */
|
|
2737
|
+
saveSolutionModule = async (c) => {
|
|
2738
|
+
if (c.formulation !== 'dcopf') {
|
|
2739
|
+
this.app.error = `${this.caseName(c)}: exact solution modules require the DC OPF formulation`;
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
1887
2742
|
const study = await this.syncedStudy(c);
|
|
1888
2743
|
if (!study)
|
|
1889
2744
|
return;
|
|
1890
2745
|
try {
|
|
1891
|
-
downloadText(await study.
|
|
2746
|
+
downloadText(await study.saveSolutionModule(), `${this.caseFileStem(c)}.solution.pio.json`, 'application/json');
|
|
1892
2747
|
this.app.error = null;
|
|
1893
2748
|
}
|
|
1894
2749
|
catch (e) {
|
|
1895
|
-
this.app.error = `${this.caseName(c)}: could not save
|
|
2750
|
+
this.app.error = `${this.caseName(c)}: could not save exact solution: ${errorText(e)}`;
|
|
1896
2751
|
}
|
|
1897
2752
|
};
|
|
1898
|
-
/** Export the active study's committed state to `format` and download it.
|
|
1899
|
-
*
|
|
1900
|
-
|
|
2753
|
+
/** Export the active study's committed state to `format` and download it.
|
|
2754
|
+
* Returns this output operation's structured diagnostics. */
|
|
2755
|
+
exportCaseAs = async (c, format) => {
|
|
1901
2756
|
const study = await this.syncedStudy(c);
|
|
1902
2757
|
if (!study)
|
|
1903
2758
|
return [];
|
|
1904
2759
|
try {
|
|
1905
|
-
const
|
|
1906
|
-
const exported = await exportStudy(packageJson, studyCommitIndex(packageJson), format);
|
|
2760
|
+
const exported = await study.export(format);
|
|
1907
2761
|
downloadText(exported.text, `${this.caseFileStem(c)}.${exported.extension}`, 'text/plain');
|
|
1908
2762
|
this.app.error = null;
|
|
1909
|
-
return exported.
|
|
2763
|
+
return exported.diagnostics;
|
|
1910
2764
|
}
|
|
1911
2765
|
catch (e) {
|
|
1912
2766
|
this.app.error = `${this.caseName(c)}: could not export study: ${errorText(e)}`;
|
|
@@ -1916,7 +2770,7 @@ export class Controller {
|
|
|
1916
2770
|
/** A filesystem-safe base name for a downloaded case file. */
|
|
1917
2771
|
caseFileStem(c) {
|
|
1918
2772
|
const base = this.isBackendCase(c) ? c.name : c.label || c.fileName.replace(/\.[^.]+$/, '');
|
|
1919
|
-
return base.replace(/[^\w.-]+/g, '_') || '
|
|
2773
|
+
return base.replace(/[^\w.-]+/g, '_') || 'case';
|
|
1920
2774
|
}
|
|
1921
2775
|
setDemandRangeMode = (mode) => {
|
|
1922
2776
|
this.app.demandRangeMode = mode;
|
|
@@ -1931,11 +2785,13 @@ export class Controller {
|
|
|
1931
2785
|
};
|
|
1932
2786
|
// First-order engine preview for the live drag. Uses the case's already-built
|
|
1933
2787
|
// Study (no re-parse, no re-solve) to paint predicted per-bus
|
|
1934
|
-
//
|
|
2788
|
+
// nodal value changes and the predicted objective change. A no-op when the Study isn't built yet or
|
|
1935
2789
|
// can't preview (browser fallback path, server-only cases): the map then
|
|
1936
2790
|
// falls back to the JS sensitivity-times-step preview.
|
|
1937
2791
|
runPreview = (c, bus, value) => {
|
|
1938
|
-
|
|
2792
|
+
if (c.formulation === 'acpf')
|
|
2793
|
+
return;
|
|
2794
|
+
// Fast path: the committed price/demand column (already solved at the committed point),
|
|
1939
2795
|
// scaled by the demand step, is the same first-order linearization the engine preview
|
|
1940
2796
|
// returns — without rebuilding the differentiable KKT every drag frame. That engine
|
|
1941
2797
|
// preview is ~80 ms/frame on the largest case (CATS, ~8870 buses), which blocked the
|
|
@@ -1943,16 +2799,25 @@ export class Controller {
|
|
|
1943
2799
|
const col = c.sensitivity;
|
|
1944
2800
|
if (col && col.bus === bus) {
|
|
1945
2801
|
const committedAtBus = this.caseDeltas(c)[bus] ?? 0;
|
|
1946
|
-
const step = (Math.abs(value) <
|
|
2802
|
+
const step = (Math.abs(value) < DEAD_ZONE_MW ? 0 : value) - committedAtBus;
|
|
1947
2803
|
const delta = new Map();
|
|
1948
2804
|
for (const v of col.values)
|
|
1949
2805
|
delta.set(v.bus, v.value * step);
|
|
1950
|
-
this.app.
|
|
1951
|
-
|
|
2806
|
+
this.app.previewPrices = {
|
|
2807
|
+
caseId: c.id,
|
|
2808
|
+
target: { bus },
|
|
2809
|
+
delta,
|
|
2810
|
+
units: previewUnits(col.units)
|
|
2811
|
+
};
|
|
2812
|
+
const priceAtBus = c.solution?.prices.find((price) => price.bus === bus)?.value ?? null;
|
|
1952
2813
|
this.previewObjective =
|
|
1953
|
-
|
|
2814
|
+
priceAtBus === null
|
|
1954
2815
|
? null
|
|
1955
|
-
: {
|
|
2816
|
+
: {
|
|
2817
|
+
caseId: c.id,
|
|
2818
|
+
target: { bus },
|
|
2819
|
+
objectiveDelta: priceAtBus * step
|
|
2820
|
+
};
|
|
1956
2821
|
return;
|
|
1957
2822
|
}
|
|
1958
2823
|
// Fallback (no committed column yet): the engine's first-order preview, which
|
|
@@ -1983,21 +2848,26 @@ export class Controller {
|
|
|
1983
2848
|
if (!study)
|
|
1984
2849
|
continue;
|
|
1985
2850
|
try {
|
|
1986
|
-
const {
|
|
2851
|
+
const { prices, objectiveDelta, units } = await study.preview(this.previewDeltas(c, bus, value), this.caseRatings(c));
|
|
1987
2852
|
// Apply only while this point is still the live drag target: a
|
|
1988
2853
|
// preview that resolves after a re-selection or a commit is stale.
|
|
1989
2854
|
if (!this.app.previewActive || this.activeSolvable !== c || this.app.selectedBus !== bus)
|
|
1990
2855
|
continue;
|
|
1991
2856
|
const delta = new Map();
|
|
1992
|
-
for (const e of
|
|
1993
|
-
delta.set(e.bus, e.
|
|
1994
|
-
this.app.
|
|
2857
|
+
for (const e of prices)
|
|
2858
|
+
delta.set(e.bus, e.value);
|
|
2859
|
+
this.app.previewPrices = {
|
|
2860
|
+
caseId: c.id,
|
|
2861
|
+
target: { bus },
|
|
2862
|
+
delta,
|
|
2863
|
+
units: units?.replaceAll('objective_unit', 'objective units') ?? 'objective units/MW'
|
|
2864
|
+
};
|
|
1995
2865
|
this.previewObjective =
|
|
1996
2866
|
objectiveDelta === null ? null : { caseId: c.id, target: { bus }, objectiveDelta };
|
|
1997
2867
|
}
|
|
1998
2868
|
catch {
|
|
1999
2869
|
if (this.activeSolvable === c && this.app.selectedBus === bus) {
|
|
2000
|
-
this.app.
|
|
2870
|
+
this.app.previewPrices = null;
|
|
2001
2871
|
this.previewObjective = null;
|
|
2002
2872
|
}
|
|
2003
2873
|
}
|
|
@@ -2021,27 +2891,34 @@ export class Controller {
|
|
|
2021
2891
|
// tiny nudge reads as "no change at this line".
|
|
2022
2892
|
previewRatings(c, branch, value) {
|
|
2023
2893
|
const ratings = { ...this.caseRatings(c) };
|
|
2024
|
-
if (Math.abs(value) <
|
|
2894
|
+
if (Math.abs(value) < DEAD_ZONE_MW)
|
|
2025
2895
|
delete ratings[branch];
|
|
2026
2896
|
else
|
|
2027
2897
|
ratings[branch] = value;
|
|
2028
2898
|
return ratings;
|
|
2029
2899
|
}
|
|
2030
2900
|
// First-order engine preview for the live rating drag, mirroring runPreview: the
|
|
2031
|
-
// committed
|
|
2032
|
-
//
|
|
2901
|
+
// committed price/rating column scaled by the rating step paints predicted per-bus
|
|
2902
|
+
// nodal value changes without touching the engine per frame. The predicted objective change comes from
|
|
2033
2903
|
// a per-MW slope taken once from Study.preview at a +1 MW step off the committed
|
|
2034
2904
|
// point (preview is replacement-absolute, so the absolute ratings map is built),
|
|
2035
2905
|
// then scaled by the live step; null when no Study slope is available.
|
|
2036
2906
|
runRatingPreview = (c, branch, value) => {
|
|
2907
|
+
if (c.formulation === 'acpf')
|
|
2908
|
+
return;
|
|
2037
2909
|
const committedAtBranch = this.caseRatings(c)[branch] ?? 0;
|
|
2038
|
-
const step = (Math.abs(value) <
|
|
2910
|
+
const step = (Math.abs(value) < DEAD_ZONE_MW ? 0 : value) - committedAtBranch;
|
|
2039
2911
|
const col = c.sensitivity;
|
|
2040
2912
|
if (col && col.branch === branch) {
|
|
2041
2913
|
const delta = new Map();
|
|
2042
2914
|
for (const v of col.values)
|
|
2043
2915
|
delta.set(v.bus, v.value * step);
|
|
2044
|
-
this.app.
|
|
2916
|
+
this.app.previewPrices = {
|
|
2917
|
+
caseId: c.id,
|
|
2918
|
+
target: { branch },
|
|
2919
|
+
delta,
|
|
2920
|
+
units: previewUnits(col.units)
|
|
2921
|
+
};
|
|
2045
2922
|
}
|
|
2046
2923
|
let cached = this.ratingSlope;
|
|
2047
2924
|
if (cached?.caseId !== c.id || cached.branch !== branch) {
|
|
@@ -2126,6 +3003,7 @@ export class Controller {
|
|
|
2126
3003
|
}
|
|
2127
3004
|
c.predictedObjective = this.predictedDeltaObj;
|
|
2128
3005
|
c.ratings = this.previewRatings(c, branch, value);
|
|
3006
|
+
this.bumpRevision(c);
|
|
2129
3007
|
// The slope was taken at the old committed point; the next drag re-derives it.
|
|
2130
3008
|
this.ratingSlope = null;
|
|
2131
3009
|
this.app.previewRatingMw = value;
|
|
@@ -2133,7 +3011,7 @@ export class Controller {
|
|
|
2133
3011
|
this.runSolve(c, this.selectionTarget);
|
|
2134
3012
|
};
|
|
2135
3013
|
finishRatingInput = (value) => {
|
|
2136
|
-
if (Math.abs(value - this.committedRating) <
|
|
3014
|
+
if (Math.abs(value - this.committedRating) < DEAD_ZONE_MW) {
|
|
2137
3015
|
if (!this.activeSolvable?.solving) {
|
|
2138
3016
|
this.app.previewActive = false;
|
|
2139
3017
|
this.app.previewRatingMw = null;
|
|
@@ -2143,6 +3021,8 @@ export class Controller {
|
|
|
2143
3021
|
this.commitRating(value);
|
|
2144
3022
|
};
|
|
2145
3023
|
restoreDefaultCases = () => {
|
|
3024
|
+
if (!this.loadDefaultCases)
|
|
3025
|
+
return;
|
|
2146
3026
|
try {
|
|
2147
3027
|
if (typeof localStorage !== 'undefined')
|
|
2148
3028
|
localStorage.removeItem(HIDDEN_DEFAULT_CASES_KEY);
|