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