@tellegen/svelte 0.3.0 → 0.4.1
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 +233 -0
- package/dist/Sparkline.svelte +2 -2
- package/dist/TellegenMap.svelte +239 -66
- package/dist/TellegenMap.svelte.d.ts +45 -0
- package/dist/TellegenProvider.svelte +36 -4
- package/dist/TellegenShell.svelte +14 -42
- package/dist/TellegenViewer.svelte +12 -2
- package/dist/api.d.ts +10 -2
- package/dist/api.js +2 -0
- package/dist/basemap.d.ts +192 -0
- package/dist/basemap.js +259 -0
- package/dist/colors.d.ts +55 -1
- package/dist/colors.js +89 -0
- package/dist/components/AppFooter.svelte +3 -2
- package/dist/components/AppHeader.svelte +21 -8
- package/dist/components/BindingLines.svelte +1 -1
- package/dist/components/BusPicker.svelte +3 -3
- package/dist/components/ControlPanel.svelte +4 -2
- package/dist/components/DemandSlider.svelte +16 -7
- package/dist/components/DiagramCanvas.svelte +17 -5
- package/dist/components/DisplayControls.svelte +5 -1
- package/dist/components/DropZone.svelte +1 -1
- package/dist/components/FormulationSelector.svelte +1 -1
- package/dist/components/LocalCaseDetails.svelte +6 -3
- package/dist/components/McResults.svelte +98 -35
- package/dist/components/McResults.svelte.d.ts +8 -2
- package/dist/components/ModelDetails.svelte +5 -5
- package/dist/components/MulticonductorDetails.svelte +9 -9
- package/dist/components/NotificationCenter.svelte +4 -2
- package/dist/components/PanelCard.svelte +72 -7
- package/dist/components/PanelHost.svelte +59 -34
- package/dist/components/PanelHost.svelte.d.ts +4 -1
- package/dist/components/PlacementCue.svelte +1 -1
- package/dist/components/PowerFlowDetails.svelte +1 -1
- package/dist/components/RatingSlider.svelte +14 -5
- package/dist/components/RestoreDefaultsButton.svelte +1 -1
- package/dist/components/SavedNetworkDetails.svelte +3 -3
- package/dist/components/SensitivityReadout.svelte +2 -2
- package/dist/components/ShellEnvironment.svelte +51 -0
- package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
- package/dist/components/SizeLegend.svelte +2 -2
- package/dist/components/SolveCard.svelte +3 -3
- package/dist/components/TopMovers.svelte +1 -1
- package/dist/components.d.ts +3 -0
- package/dist/components.js +3 -0
- package/dist/context.svelte.d.ts +8 -1
- package/dist/context.svelte.js +8 -3
- package/dist/controller.svelte.d.ts +65 -5
- package/dist/controller.svelte.js +266 -78
- package/dist/display.d.ts +3 -2
- package/dist/display.js +22 -4
- package/dist/format.d.ts +9 -0
- package/dist/format.js +13 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +2 -0
- package/dist/map.d.ts +4 -2
- package/dist/map.js +2 -1
- package/dist/maplibre-worker.d.ts +5 -0
- package/dist/maplibre-worker.dev.d.ts +11 -0
- package/dist/maplibre-worker.dev.js +15 -0
- package/dist/maplibre-worker.js +8 -0
- package/dist/notices.svelte.js +5 -1
- package/dist/panels.svelte.d.ts +6 -0
- package/dist/panels.svelte.js +31 -1
- package/dist/slider.d.ts +12 -0
- package/dist/slider.js +31 -0
- package/dist/state.svelte.d.ts +25 -3
- package/dist/state.svelte.js +13 -0
- package/dist/styles.css +59 -6
- package/dist/types.d.ts +1 -1
- package/package.json +13 -8
|
@@ -6,11 +6,17 @@ import { placeSyntheticTopology } from './synthetic-layout.js';
|
|
|
6
6
|
import { distExtensionFormat, isGeoFileName } from './drop-classify.js';
|
|
7
7
|
import { DropBatchGate, readDropFileBytes, validateDropBatch } from './drop-limits.js';
|
|
8
8
|
import { buildGeographicView, buildDiagramView, placeMultiView } from './multiconductor.js';
|
|
9
|
-
import { browserWasmTransport, applyDisplayGeo, applyGeo, applyLayout, createStudy, extractGeo, FORMULATIONS, formatPowerIoDiagnostic, formatOf, ingestCase, ingestDistCaseBytes, ingestJsonDrop, isDisplayFile, isPermanentEngineFailure, parseDisplay, parseGeo } from '@tellegen/engine';
|
|
10
|
-
import { errorText, extent, formulationLabel, rgbaCss } from './format.js';
|
|
9
|
+
import { browserWasmTransport, applyDisplayGeo, applyGeo, applyLayout, createStudy, extractGeo, FORMULATIONS, formatPowerIoDiagnostic, formatOf, ingestCase, ingestDistCaseBytes, ingestJsonDrop, isDisplayFile, isPermanentEngineFailure, parseDisplay, parseGeo, mcPfResultDetail, summarizeMcPfResult } from '@tellegen/engine';
|
|
10
|
+
import { browserSolveFailure, errorText, extent, formulationLabel, rgbaCss } from './format.js';
|
|
11
|
+
import { DEAD_ZONE_MW } from './slider.js';
|
|
11
12
|
const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
|
|
12
13
|
// Open on South Carolina by default: it has the most interesting price action.
|
|
13
14
|
const DEFAULT_CASE_ID = 'case500';
|
|
15
|
+
/** Whether a local case renders or will render a bus with this id. */
|
|
16
|
+
function localHasBus(c, busId) {
|
|
17
|
+
const buses = c.view?.buses ?? c.diagram?.view.buses ?? c.topology?.buses ?? [];
|
|
18
|
+
return buses.some((b) => b.id === busId);
|
|
19
|
+
}
|
|
14
20
|
/** Terminal copy when nothing can solve. Two facts decide the wording: whether
|
|
15
21
|
* this browser's engine failure is known permanent, and that the server
|
|
16
22
|
* declines compute. Server compute exists in the codebase but ships disabled;
|
|
@@ -64,7 +70,10 @@ function downloadText(text, filename, mime) {
|
|
|
64
70
|
}
|
|
65
71
|
export class Controller {
|
|
66
72
|
app;
|
|
73
|
+
/** `null` when the controller was created with `apiBase: null`; every
|
|
74
|
+
* server path then reports "compute off" instead of fetching. */
|
|
67
75
|
api;
|
|
76
|
+
loadDefaultCases;
|
|
68
77
|
mcTransport;
|
|
69
78
|
abort = null;
|
|
70
79
|
// While set (epoch ms), the server sensitivity fallback is rate limited: skip
|
|
@@ -119,6 +128,7 @@ export class Controller {
|
|
|
119
128
|
constructor(app, options = {}) {
|
|
120
129
|
this.app = app;
|
|
121
130
|
this.api = options.api ?? createApiClient({ apiBase: options.apiBase });
|
|
131
|
+
this.loadDefaultCases = options.loadDefaultCases ?? true;
|
|
122
132
|
this.mcTransport = options.mcTransport ?? browserWasmTransport;
|
|
123
133
|
}
|
|
124
134
|
// ===== helpers =====
|
|
@@ -388,8 +398,13 @@ export class Controller {
|
|
|
388
398
|
return c.studyInputJson;
|
|
389
399
|
if (!this.isBackendCase(c))
|
|
390
400
|
return null;
|
|
401
|
+
const api = this.api;
|
|
402
|
+
if (!api) {
|
|
403
|
+
c.solveFallbackReason = 'no API configured';
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
391
406
|
try {
|
|
392
|
-
const moduleJson = await
|
|
407
|
+
const moduleJson = await api.getCaseModuleJson(c.id);
|
|
393
408
|
c.studyInputJson = moduleJson;
|
|
394
409
|
return moduleJson;
|
|
395
410
|
}
|
|
@@ -403,7 +418,7 @@ export class Controller {
|
|
|
403
418
|
// reads as "no change at this bus".
|
|
404
419
|
previewDeltas(c, bus, value) {
|
|
405
420
|
const deltas = { ...this.caseDeltas(c) };
|
|
406
|
-
if (Math.abs(value) <
|
|
421
|
+
if (Math.abs(value) < DEAD_ZONE_MW)
|
|
407
422
|
delete deltas[bus];
|
|
408
423
|
else
|
|
409
424
|
deltas[bus] = value;
|
|
@@ -472,7 +487,7 @@ export class Controller {
|
|
|
472
487
|
return null;
|
|
473
488
|
const domain = scalarDomain(this.activeDisplay.mode, values);
|
|
474
489
|
const { min, max } = extent(values);
|
|
475
|
-
const flatThreshold = this.activeDisplay.mode === 'price' ? 1 : 1e-5;
|
|
490
|
+
const flatThreshold = this.activeDisplay.mode === 'price' || this.activeDisplay.mode === 'demand' ? 1 : 1e-5;
|
|
476
491
|
return {
|
|
477
492
|
lo: { value: domain.lo, clamped: min < domain.lo - flatThreshold / 20 },
|
|
478
493
|
hi: { value: domain.hi, clamped: max > domain.hi + flatThreshold / 20 },
|
|
@@ -621,11 +636,20 @@ export class Controller {
|
|
|
621
636
|
previewing = $derived.by(() => Boolean(this.activeSolvable?.solving ||
|
|
622
637
|
this.app.previewActive ||
|
|
623
638
|
(this.app.previewDeltaMw !== null &&
|
|
624
|
-
Math.abs(this.sliderValue - this.committedDelta) >=
|
|
639
|
+
Math.abs(this.sliderValue - this.committedDelta) >= DEAD_ZONE_MW) ||
|
|
625
640
|
(this.app.previewRatingMw !== null &&
|
|
626
|
-
Math.abs(this.ratingSliderValue - this.committedRating) >=
|
|
641
|
+
Math.abs(this.ratingSliderValue - this.committedRating) >= DEAD_ZONE_MW)));
|
|
627
642
|
// ===== actions =====
|
|
628
643
|
load = () => {
|
|
644
|
+
// Default cases off, or no backend at all: nothing to fetch. The shell
|
|
645
|
+
// still needs its loaded state and a framed map, and a host that opens
|
|
646
|
+
// its own cases must never see a request go out.
|
|
647
|
+
const api = this.api;
|
|
648
|
+
if (!this.loadDefaultCases || api === null) {
|
|
649
|
+
this.casesLoaded = true;
|
|
650
|
+
void this.app.requestFrame('all');
|
|
651
|
+
return Promise.resolve();
|
|
652
|
+
}
|
|
629
653
|
// A remount can call load() again before the first getCases() resolves; dedupe
|
|
630
654
|
// so two concurrent loads can't double-fetch the case list and double-fit the map.
|
|
631
655
|
if (this.loading)
|
|
@@ -635,14 +659,14 @@ export class Controller {
|
|
|
635
659
|
// copy and skip doomed requests (the SSE stream cannot see a 403). On
|
|
636
660
|
// failure assume enabled; the 403 latch in fetchServerColumn still
|
|
637
661
|
// catches a disabled deploy.
|
|
638
|
-
void
|
|
662
|
+
void api
|
|
639
663
|
.getComputeStatus()
|
|
640
664
|
.then((s) => {
|
|
641
665
|
this.serverComputeOff = !s.enabled;
|
|
642
666
|
})
|
|
643
667
|
.catch(() => { });
|
|
644
668
|
try {
|
|
645
|
-
const summaries = await
|
|
669
|
+
const summaries = await api.getCases();
|
|
646
670
|
const hidden = this.readHiddenDefaultCases();
|
|
647
671
|
this.hiddenDefaults = new Set(hidden);
|
|
648
672
|
// Tear down the cases being replaced so their wasm Studies are freed and any
|
|
@@ -689,15 +713,20 @@ export class Controller {
|
|
|
689
713
|
this.app.requestFrame(c.id);
|
|
690
714
|
return;
|
|
691
715
|
}
|
|
716
|
+
const api = this.api;
|
|
717
|
+
if (!api) {
|
|
718
|
+
this.fail(`${c.name}: no API configured`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
692
721
|
this.loadingBackendCase = c.id;
|
|
693
722
|
const requestedFormulation = c.formulation;
|
|
694
723
|
try {
|
|
695
724
|
const [network, dcBaseSolution] = await Promise.all([
|
|
696
|
-
c.network ? Promise.resolve(c.network) :
|
|
725
|
+
c.network ? Promise.resolve(c.network) : api.getNetwork(c.id),
|
|
697
726
|
// The server caches only the DC OPF base solution. Other formulations
|
|
698
727
|
// must hydrate from the browser Study so their objective, nodal values, flows, and
|
|
699
728
|
// voltage fields all come from the selected formulation.
|
|
700
|
-
requestedFormulation === 'dcopf' ?
|
|
729
|
+
requestedFormulation === 'dcopf' ? api.getSolution(c.id) : Promise.resolve(null)
|
|
701
730
|
]);
|
|
702
731
|
if (!this.app.byId(c.id))
|
|
703
732
|
return;
|
|
@@ -805,7 +834,9 @@ export class Controller {
|
|
|
805
834
|
this.disposeStudy(c);
|
|
806
835
|
await this.hydrateFallback(this.app.removeLocal(c.id));
|
|
807
836
|
};
|
|
808
|
-
|
|
837
|
+
/** Add a prepared local case, make it active, frame it (unless `frame` is
|
|
838
|
+
* false) and start its browser solve. */
|
|
839
|
+
addAndActivateLocal = (c, options = {}) => {
|
|
809
840
|
this.app.studyView = null;
|
|
810
841
|
this.clearSelection();
|
|
811
842
|
this.app.activeCaseId = null;
|
|
@@ -815,7 +846,7 @@ export class Controller {
|
|
|
815
846
|
this.app.displayMode = 'voltage';
|
|
816
847
|
if (c.diagram)
|
|
817
848
|
this.app.placingLocalId = null;
|
|
818
|
-
if (c.view || c.diagram || c.substations)
|
|
849
|
+
if ((options.frame ?? true) && (c.view || c.diagram || c.substations))
|
|
819
850
|
this.app.requestFrame(c.id);
|
|
820
851
|
this.maybeStartLocalSolve(c.id);
|
|
821
852
|
};
|
|
@@ -1291,7 +1322,8 @@ export class Controller {
|
|
|
1291
1322
|
const retryOp = 'bus' in target
|
|
1292
1323
|
? () => this.selectBus(c.id, target.bus)
|
|
1293
1324
|
: () => this.selectBranch(c.id, target.branch);
|
|
1294
|
-
|
|
1325
|
+
const api = this.api;
|
|
1326
|
+
if (this.serverComputeOff || !api) {
|
|
1295
1327
|
this.fail(this.computeOffNotice(c), retryOp);
|
|
1296
1328
|
return;
|
|
1297
1329
|
}
|
|
@@ -1301,8 +1333,8 @@ export class Controller {
|
|
|
1301
1333
|
}
|
|
1302
1334
|
try {
|
|
1303
1335
|
const col = 'bus' in target
|
|
1304
|
-
? await
|
|
1305
|
-
: await
|
|
1336
|
+
? await api.getSensitivity(c.id, target.bus, c.deltas, ac.signal)
|
|
1337
|
+
: await api.getBranchSensitivity(c.id, target.branch, c.deltas, ac.signal);
|
|
1306
1338
|
if (!ac.signal.aborted) {
|
|
1307
1339
|
// The solution on screen came from the browser or the served base
|
|
1308
1340
|
// solve; the column came from the server. Label the mix.
|
|
@@ -1497,6 +1529,13 @@ export class Controller {
|
|
|
1497
1529
|
return;
|
|
1498
1530
|
if (!studyInputJson) {
|
|
1499
1531
|
c.solveFallbackReason ??= 'PowerIO module unavailable';
|
|
1532
|
+
// The server fallback only implements DC OPF. Preserve the requested
|
|
1533
|
+
// calculation when its browser input cannot be loaded.
|
|
1534
|
+
if (c.formulation !== 'dcopf') {
|
|
1535
|
+
c.solving = false;
|
|
1536
|
+
this.app.error = `${this.caseName(c)}: ${formulationLabel(c.formulation)} requires its PowerIO module; ${c.solveFallbackReason}`;
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1500
1539
|
if (this.hasRatingEdits(c)) {
|
|
1501
1540
|
c.solving = false;
|
|
1502
1541
|
this.app.error = this.ratingEditsFallbackError(c);
|
|
@@ -1588,13 +1627,17 @@ export class Controller {
|
|
|
1588
1627
|
return;
|
|
1589
1628
|
}
|
|
1590
1629
|
c.solving = false;
|
|
1591
|
-
|
|
1630
|
+
const failure = browserSolveFailure(c.label, c.solveFallbackReason);
|
|
1631
|
+
this.app.error = failure.message;
|
|
1632
|
+
this.app.errorDetail = failure.detail;
|
|
1592
1633
|
});
|
|
1593
1634
|
};
|
|
1594
1635
|
serverSolve = (c, target, seq = c.solveSeq) => {
|
|
1595
|
-
// Known-disabled server compute: show the notice
|
|
1596
|
-
// stream that answers 403 without a readable body
|
|
1597
|
-
|
|
1636
|
+
// Known-disabled server compute (or no backend at all): show the notice
|
|
1637
|
+
// instead of opening a stream that answers 403 without a readable body
|
|
1638
|
+
// (EventSource hides it).
|
|
1639
|
+
const api = this.api;
|
|
1640
|
+
if (this.serverComputeOff || !api) {
|
|
1598
1641
|
if (seq !== c.solveSeq)
|
|
1599
1642
|
return;
|
|
1600
1643
|
c.solving = false;
|
|
@@ -1610,7 +1653,7 @@ export class Controller {
|
|
|
1610
1653
|
c.solveFallbackReason ??= 'browser solve unavailable';
|
|
1611
1654
|
// The server computes bus columns only; a branch target streams no sensitivity.
|
|
1612
1655
|
const sensBus = target && 'bus' in target ? target.bus : null;
|
|
1613
|
-
c.closeStream =
|
|
1656
|
+
c.closeStream = api.openSolveStream(c.id, c.deltas, sensBus, {
|
|
1614
1657
|
onsolution: (sol) => {
|
|
1615
1658
|
if (seq !== c.solveSeq)
|
|
1616
1659
|
return;
|
|
@@ -1659,7 +1702,7 @@ export class Controller {
|
|
|
1659
1702
|
finishDemandInput = (value) => {
|
|
1660
1703
|
if (this.sliderDisabled)
|
|
1661
1704
|
return;
|
|
1662
|
-
if (Math.abs(value - this.committedDelta) <
|
|
1705
|
+
if (Math.abs(value - this.committedDelta) < DEAD_ZONE_MW) {
|
|
1663
1706
|
if (!this.activeSolvable?.solving) {
|
|
1664
1707
|
this.app.previewActive = false;
|
|
1665
1708
|
this.app.previewDeltaMw = null;
|
|
@@ -1697,7 +1740,9 @@ export class Controller {
|
|
|
1697
1740
|
if (FORMULATIONS.find((f) => f.id === next)?.disabled)
|
|
1698
1741
|
return;
|
|
1699
1742
|
c.formulation = next;
|
|
1700
|
-
|
|
1743
|
+
// Demand does not depend on the calculation, so a demand colouring stays.
|
|
1744
|
+
if (this.app.displayMode !== 'demand')
|
|
1745
|
+
this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
|
|
1701
1746
|
this.bumpRevision(c);
|
|
1702
1747
|
// The committed point carries over (same demand), but the model and its solution do
|
|
1703
1748
|
// not; drop the Study and the cached solutions so they rebuild under `next`.
|
|
@@ -1932,42 +1977,47 @@ export class Controller {
|
|
|
1932
1977
|
if (local.studyInputJson && displays.length > 0) {
|
|
1933
1978
|
await this.fillFromDisplaySibling(local, displays);
|
|
1934
1979
|
}
|
|
1935
|
-
|
|
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
|
-
}
|
|
1980
|
+
this.placeTopologyAsDiagram(local);
|
|
1969
1981
|
return { geoLayersConsumed, error };
|
|
1970
1982
|
};
|
|
1983
|
+
/** A case that still has no geometry gets a connectivity layout as its
|
|
1984
|
+
* diagram, so it can render and solve without a placement step. */
|
|
1985
|
+
placeTopologyAsDiagram(local) {
|
|
1986
|
+
if (local.view || local.diagram || !local.topology)
|
|
1987
|
+
return;
|
|
1988
|
+
const view = {
|
|
1989
|
+
...placeSyntheticTopology(local.topology, { lon: 0, lat: 0 }),
|
|
1990
|
+
coordinate_space: 'diagram'
|
|
1991
|
+
};
|
|
1992
|
+
const layer = JSON.stringify({
|
|
1993
|
+
type: 'FeatureCollection',
|
|
1994
|
+
powerio_geo: { space: 'diagram', kind: 'synthetic' },
|
|
1995
|
+
features: [
|
|
1996
|
+
...view.buses.map((b) => ({
|
|
1997
|
+
type: 'Feature',
|
|
1998
|
+
properties: { target: 'bus', id: String(b.id) },
|
|
1999
|
+
geometry: { type: 'Point', coordinates: [b.lon, b.lat] }
|
|
2000
|
+
})),
|
|
2001
|
+
...view.branches.map((b) => ({
|
|
2002
|
+
type: 'Feature',
|
|
2003
|
+
properties: {
|
|
2004
|
+
target: 'branch',
|
|
2005
|
+
branch_id: String(b.id),
|
|
2006
|
+
from: String(b.from),
|
|
2007
|
+
to: String(b.to)
|
|
2008
|
+
},
|
|
2009
|
+
geometry: { type: 'LineString', coordinates: b.path }
|
|
2010
|
+
}))
|
|
2011
|
+
]
|
|
2012
|
+
});
|
|
2013
|
+
local.diagram = {
|
|
2014
|
+
view,
|
|
2015
|
+
layer,
|
|
2016
|
+
name: 'Network diagram',
|
|
2017
|
+
warnings: ['No geographic coordinates; positions follow network connectivity.']
|
|
2018
|
+
};
|
|
2019
|
+
local.displayMode = 'diagram';
|
|
2020
|
+
}
|
|
1971
2021
|
/** Retain diagram geometry separately from the electrical case's geographic data. */
|
|
1972
2022
|
fillFromDisplaySibling = async (c, displays) => {
|
|
1973
2023
|
if (!c.studyInputJson)
|
|
@@ -2120,20 +2170,22 @@ export class Controller {
|
|
|
2120
2170
|
input = await retainedAtStart.inputModule();
|
|
2121
2171
|
let snapshot = null;
|
|
2122
2172
|
let result;
|
|
2173
|
+
let fullResult = null;
|
|
2123
2174
|
let loadBranches = c.mcLoadBranches;
|
|
2124
2175
|
if (this.mcTransport.createMcPfSession) {
|
|
2176
|
+
// The session answers with its summary; terminal and equipment
|
|
2177
|
+
// detail is fetched only when a view asks for it.
|
|
2125
2178
|
nextSession = await this.mcTransport.createMcPfSession(input, options, abort.signal);
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
nextSession.loadBranches()
|
|
2129
|
-
]);
|
|
2179
|
+
result = nextSession.initialSummary;
|
|
2180
|
+
loadBranches = await nextSession.loadBranches();
|
|
2130
2181
|
}
|
|
2131
2182
|
else {
|
|
2132
2183
|
snapshot = this.mcTransport.solveMcStudy
|
|
2133
2184
|
? await this.mcTransport.solveMcStudy(input, crypto.randomUUID(), c.label, options, abort.signal)
|
|
2134
2185
|
: null;
|
|
2135
|
-
|
|
2186
|
+
fullResult =
|
|
2136
2187
|
snapshot?.result ?? (await this.mcTransport.solveMcModule(input, options, abort.signal));
|
|
2188
|
+
result = summarizeMcPfResult(fullResult);
|
|
2137
2189
|
}
|
|
2138
2190
|
if (abort.signal.aborted)
|
|
2139
2191
|
throw new DOMException('Calculation cancelled', 'AbortError');
|
|
@@ -2157,6 +2209,8 @@ export class Controller {
|
|
|
2157
2209
|
c.mcLoadEdits = [];
|
|
2158
2210
|
c.moduleJson = input;
|
|
2159
2211
|
c.result = result;
|
|
2212
|
+
c.mcFullResult = fullResult;
|
|
2213
|
+
c.mcTiming = null;
|
|
2160
2214
|
c.mcSnapshot = snapshot;
|
|
2161
2215
|
c.mcSavedAt = null;
|
|
2162
2216
|
c.solveMs = performance.now() - started;
|
|
@@ -2201,6 +2255,60 @@ export class Controller {
|
|
|
2201
2255
|
c.mcSnapshot = snapshot;
|
|
2202
2256
|
return snapshot;
|
|
2203
2257
|
};
|
|
2258
|
+
/** One bus's terminals and a page of equipment ports for the displayed
|
|
2259
|
+
* operating point, from the live session or the stored full result. Null
|
|
2260
|
+
* when the operating point changed before the page arrived. */
|
|
2261
|
+
multiResultDetail = async (c, query) => {
|
|
2262
|
+
const summary = c.result;
|
|
2263
|
+
if (!summary)
|
|
2264
|
+
return null;
|
|
2265
|
+
const session = c.mcSession;
|
|
2266
|
+
if (session && !c.mcFullResult) {
|
|
2267
|
+
const detail = await session.detail(query);
|
|
2268
|
+
return session === c.mcSession && c.result?.solve_count === detail.solve_count
|
|
2269
|
+
? detail
|
|
2270
|
+
: null;
|
|
2271
|
+
}
|
|
2272
|
+
const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
|
|
2273
|
+
return full ? mcPfResultDetail(full, query, summary.solve_count) : null;
|
|
2274
|
+
};
|
|
2275
|
+
/** Every terminal's identity, voltage, and injected current, as numeric
|
|
2276
|
+
* arrays of interleaved `[re, im]` values. */
|
|
2277
|
+
multiTerminalTable = async (c) => {
|
|
2278
|
+
if (!c.result)
|
|
2279
|
+
return null;
|
|
2280
|
+
const session = c.mcSession;
|
|
2281
|
+
if (session && !c.mcFullResult) {
|
|
2282
|
+
// Arrays carry no solve count, and a running flush may discard its solve.
|
|
2283
|
+
if (c.mcEditRunning)
|
|
2284
|
+
return null;
|
|
2285
|
+
const solve = c.result.solve_count;
|
|
2286
|
+
const [ids, voltages, currents] = await Promise.all([
|
|
2287
|
+
session.terminalIds(),
|
|
2288
|
+
session.terminalVoltages(),
|
|
2289
|
+
session.terminalCurrents()
|
|
2290
|
+
]);
|
|
2291
|
+
return session === c.mcSession && c.result?.solve_count === solve
|
|
2292
|
+
? { ids, voltages, currents }
|
|
2293
|
+
: null;
|
|
2294
|
+
}
|
|
2295
|
+
const full = c.mcFullResult ?? c.mcSnapshot?.result ?? null;
|
|
2296
|
+
if (!full)
|
|
2297
|
+
return null;
|
|
2298
|
+
const voltages = new Float64Array(2 * full.terminals.length);
|
|
2299
|
+
const currents = new Float64Array(2 * full.terminals.length);
|
|
2300
|
+
full.terminals.forEach((terminal, k) => {
|
|
2301
|
+
voltages[2 * k] = terminal.voltage.re;
|
|
2302
|
+
voltages[2 * k + 1] = terminal.voltage.im;
|
|
2303
|
+
currents[2 * k] = terminal.current_into_network.re;
|
|
2304
|
+
currents[2 * k + 1] = terminal.current_into_network.im;
|
|
2305
|
+
});
|
|
2306
|
+
return {
|
|
2307
|
+
ids: full.terminals.map((terminal) => [terminal.bus, terminal.terminal]),
|
|
2308
|
+
voltages,
|
|
2309
|
+
currents
|
|
2310
|
+
};
|
|
2311
|
+
};
|
|
2204
2312
|
/** Apply coordinates atomically to the explicitly selected conductor-resolved case. */
|
|
2205
2313
|
applyMultiGeoLayers = async (c, layers) => {
|
|
2206
2314
|
if (!layers.length)
|
|
@@ -2251,6 +2359,8 @@ export class Controller {
|
|
|
2251
2359
|
}
|
|
2252
2360
|
c.mcSession?.free();
|
|
2253
2361
|
c.mcSession = null;
|
|
2362
|
+
// Without a session, result detail comes from the saved result.
|
|
2363
|
+
c.mcFullResult = snapshot?.result ?? c.mcFullResult;
|
|
2254
2364
|
c.mcLoadBranches = [];
|
|
2255
2365
|
c.mcLoadEdits = [];
|
|
2256
2366
|
c.moduleJson = next;
|
|
@@ -2320,19 +2430,20 @@ export class Controller {
|
|
|
2320
2430
|
const revision = c.mcEditRevision;
|
|
2321
2431
|
const edits = c.mcLoadEdits.map((edit) => ({ ...edit }));
|
|
2322
2432
|
const started = performance.now();
|
|
2323
|
-
const
|
|
2433
|
+
const summary = await session.replaceLoadPowers(edits);
|
|
2324
2434
|
if (session !== c.mcSession || !this.app.multiCases.includes(c))
|
|
2325
2435
|
return;
|
|
2326
2436
|
if (revision !== c.mcEditRevision)
|
|
2327
2437
|
continue;
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
c.
|
|
2332
|
-
c.result = result;
|
|
2438
|
+
// One round trip: the queued edit already set the branch state
|
|
2439
|
+
// this successful solve confirms. Only a failure refetches it.
|
|
2440
|
+
c.result = summary;
|
|
2441
|
+
c.mcFullResult = null;
|
|
2333
2442
|
c.mcSnapshot = null;
|
|
2334
2443
|
c.mcSavedAt = null;
|
|
2335
|
-
|
|
2444
|
+
const total = performance.now() - started;
|
|
2445
|
+
c.solveMs = total;
|
|
2446
|
+
c.mcTiming = session.lastTiming ? { ...session.lastTiming, total_ms: total } : null;
|
|
2336
2447
|
c.revisionGeneration++;
|
|
2337
2448
|
break;
|
|
2338
2449
|
}
|
|
@@ -2489,11 +2600,9 @@ export class Controller {
|
|
|
2489
2600
|
Object.values(this.caseRatings(c)).some((v) => v !== 0)) {
|
|
2490
2601
|
throw new Error('The displayed edits have no matching saved result. Finish solving before saving them.');
|
|
2491
2602
|
}
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
this.api.getSavedCase) {
|
|
2496
|
-
const snapshot = await this.api.getSavedCase(c.id);
|
|
2603
|
+
const api = this.api;
|
|
2604
|
+
if (c instanceof CaseState && c.solution && c.formulation === 'dcopf' && api?.getSavedCase) {
|
|
2605
|
+
const snapshot = await api.getSavedCase(c.id);
|
|
2497
2606
|
if (snapshot.base_input !== base ||
|
|
2498
2607
|
JSON.stringify(snapshot.display_solution) !== JSON.stringify(c.solution))
|
|
2499
2608
|
throw new Error('The server case changed. Reload it before saving.');
|
|
@@ -2519,6 +2628,83 @@ export class Controller {
|
|
|
2519
2628
|
view
|
|
2520
2629
|
});
|
|
2521
2630
|
};
|
|
2631
|
+
/** Ingest module text through the engine and require a balanced network. */
|
|
2632
|
+
async ingestBalancedModule(name, moduleJson) {
|
|
2633
|
+
if (moduleJson === undefined)
|
|
2634
|
+
throw new Error(`${name}: openBalancedModule needs moduleJson or payload`);
|
|
2635
|
+
const result = await ingestJsonDrop(new TextEncoder().encode(moduleJson));
|
|
2636
|
+
if (result.kind === 'transmission')
|
|
2637
|
+
return result.payload;
|
|
2638
|
+
if (result.kind === 'module' && !('model' in result.payload))
|
|
2639
|
+
return result.payload;
|
|
2640
|
+
throw new Error(`${name}: not a balanced network module (${result.kind})`);
|
|
2641
|
+
}
|
|
2642
|
+
/** Open a balanced powerio module from memory, the programmatic twin of
|
|
2643
|
+
* dropping a `.pio.json` file: ingest (unless `payload` is given), apply an
|
|
2644
|
+
* optional geographic layer, lay out a module without coordinates as a
|
|
2645
|
+
* diagram, then add and activate it. Resolves to the new case; throws when
|
|
2646
|
+
* the module is not a balanced network or the geographic layer matches
|
|
2647
|
+
* nothing. */
|
|
2648
|
+
openBalancedModule = async (input) => {
|
|
2649
|
+
this.app.parsingFile = true;
|
|
2650
|
+
try {
|
|
2651
|
+
const payload = input.payload ?? (await this.ingestBalancedModule(input.name, input.moduleJson));
|
|
2652
|
+
const local = this.localFromBalancedPayload(`${input.name}.pio.json`, {
|
|
2653
|
+
...payload,
|
|
2654
|
+
name: input.name
|
|
2655
|
+
});
|
|
2656
|
+
if (input.formulation)
|
|
2657
|
+
local.formulation = input.formulation;
|
|
2658
|
+
if (input.geoLayer) {
|
|
2659
|
+
await this.applyGeoLayers(local, [
|
|
2660
|
+
{ name: `${input.name}.geo.json`, layer: input.geoLayer, diagnostics: [] }
|
|
2661
|
+
]);
|
|
2662
|
+
}
|
|
2663
|
+
this.placeTopologyAsDiagram(local);
|
|
2664
|
+
if (input.activate !== false)
|
|
2665
|
+
this.addAndActivateLocal(local, { frame: input.frame });
|
|
2666
|
+
return local;
|
|
2667
|
+
}
|
|
2668
|
+
finally {
|
|
2669
|
+
this.app.parsingFile = false;
|
|
2670
|
+
}
|
|
2671
|
+
};
|
|
2672
|
+
/** Swap a local case for a new module in place: same slot in the case list,
|
|
2673
|
+
* same formulation unless `input.formulation` says otherwise, same display
|
|
2674
|
+
* mode when the new case supports it, and the selected bus kept when the
|
|
2675
|
+
* new case has it. The previous case's Study is freed. */
|
|
2676
|
+
replaceLocalCase = async (previous, input) => {
|
|
2677
|
+
const wasActive = this.app.activeLocalId === previous.id;
|
|
2678
|
+
const selectedBus = wasActive ? this.app.selectedBus : null;
|
|
2679
|
+
const displayMode = previous.displayMode;
|
|
2680
|
+
const next = await this.openBalancedModule({
|
|
2681
|
+
...input,
|
|
2682
|
+
formulation: input.formulation ?? previous.formulation,
|
|
2683
|
+
activate: false
|
|
2684
|
+
});
|
|
2685
|
+
if (displayMode === 'diagram' ? next.diagram : next.view)
|
|
2686
|
+
next.displayMode = displayMode;
|
|
2687
|
+
const slot = this.app.localCases.findIndex((c) => c.id === previous.id);
|
|
2688
|
+
// A seq bump invalidates any in-flight browser solve of the old case.
|
|
2689
|
+
previous.solveSeq = (previous.solveSeq ?? 0) + 1;
|
|
2690
|
+
this.disposeStudy(previous);
|
|
2691
|
+
if (wasActive) {
|
|
2692
|
+
this.addAndActivateLocal(next, { frame: input.frame });
|
|
2693
|
+
}
|
|
2694
|
+
else {
|
|
2695
|
+
this.app.localCases = [...this.app.localCases, next];
|
|
2696
|
+
this.maybeStartLocalSolve(next.id);
|
|
2697
|
+
}
|
|
2698
|
+
const cases = this.app.localCases.filter((c) => c.id !== previous.id && c.id !== next.id);
|
|
2699
|
+
cases.splice(slot < 0 ? cases.length : slot, 0, next);
|
|
2700
|
+
this.app.localCases = cases;
|
|
2701
|
+
if (this.app.placingLocalId === previous.id)
|
|
2702
|
+
this.app.placingLocalId = null;
|
|
2703
|
+
if (selectedBus !== null && localHasBus(next, selectedBus)) {
|
|
2704
|
+
void this.selectLocalBus(next.id, selectedBus);
|
|
2705
|
+
}
|
|
2706
|
+
return next;
|
|
2707
|
+
};
|
|
2522
2708
|
/** Build (or reuse) the case's Study and commit it at the current edit state, so a
|
|
2523
2709
|
* save or export captures exactly what is on screen. Null (with an error set) when no
|
|
2524
2710
|
* PowerIO module or Study is available. */
|
|
@@ -2615,7 +2801,7 @@ export class Controller {
|
|
|
2615
2801
|
const col = c.sensitivity;
|
|
2616
2802
|
if (col && col.bus === bus) {
|
|
2617
2803
|
const committedAtBus = this.caseDeltas(c)[bus] ?? 0;
|
|
2618
|
-
const step = (Math.abs(value) <
|
|
2804
|
+
const step = (Math.abs(value) < DEAD_ZONE_MW ? 0 : value) - committedAtBus;
|
|
2619
2805
|
const delta = new Map();
|
|
2620
2806
|
for (const v of col.values)
|
|
2621
2807
|
delta.set(v.bus, v.value * step);
|
|
@@ -2707,7 +2893,7 @@ export class Controller {
|
|
|
2707
2893
|
// tiny nudge reads as "no change at this line".
|
|
2708
2894
|
previewRatings(c, branch, value) {
|
|
2709
2895
|
const ratings = { ...this.caseRatings(c) };
|
|
2710
|
-
if (Math.abs(value) <
|
|
2896
|
+
if (Math.abs(value) < DEAD_ZONE_MW)
|
|
2711
2897
|
delete ratings[branch];
|
|
2712
2898
|
else
|
|
2713
2899
|
ratings[branch] = value;
|
|
@@ -2723,7 +2909,7 @@ export class Controller {
|
|
|
2723
2909
|
if (c.formulation === 'acpf')
|
|
2724
2910
|
return;
|
|
2725
2911
|
const committedAtBranch = this.caseRatings(c)[branch] ?? 0;
|
|
2726
|
-
const step = (Math.abs(value) <
|
|
2912
|
+
const step = (Math.abs(value) < DEAD_ZONE_MW ? 0 : value) - committedAtBranch;
|
|
2727
2913
|
const col = c.sensitivity;
|
|
2728
2914
|
if (col && col.branch === branch) {
|
|
2729
2915
|
const delta = new Map();
|
|
@@ -2827,7 +3013,7 @@ export class Controller {
|
|
|
2827
3013
|
this.runSolve(c, this.selectionTarget);
|
|
2828
3014
|
};
|
|
2829
3015
|
finishRatingInput = (value) => {
|
|
2830
|
-
if (Math.abs(value - this.committedRating) <
|
|
3016
|
+
if (Math.abs(value - this.committedRating) < DEAD_ZONE_MW) {
|
|
2831
3017
|
if (!this.activeSolvable?.solving) {
|
|
2832
3018
|
this.app.previewActive = false;
|
|
2833
3019
|
this.app.previewRatingMw = null;
|
|
@@ -2837,6 +3023,8 @@ export class Controller {
|
|
|
2837
3023
|
this.commitRating(value);
|
|
2838
3024
|
};
|
|
2839
3025
|
restoreDefaultCases = () => {
|
|
3026
|
+
if (!this.loadDefaultCases)
|
|
3027
|
+
return;
|
|
2840
3028
|
try {
|
|
2841
3029
|
if (typeof localStorage !== 'undefined')
|
|
2842
3030
|
localStorage.removeItem(HIDDEN_DEFAULT_CASES_KEY);
|
package/dist/display.d.ts
CHANGED
|
@@ -12,8 +12,9 @@ export type DisplayOption = {
|
|
|
12
12
|
};
|
|
13
13
|
/** Variables present in the current result. Power flow has no LMP values. */
|
|
14
14
|
export declare function displayMetaFor(c: SolvableCase | null): DisplayOption[];
|
|
15
|
-
/** Bus LMPs, voltage angles in radians,
|
|
16
|
-
*
|
|
15
|
+
/** Bus LMPs, voltage angles in radians, voltage magnitudes in per unit, or
|
|
16
|
+
* demand in MW with the case's committed changes. SOCWR supplies squared
|
|
17
|
+
* voltages, which are converted to magnitudes here. */
|
|
17
18
|
export declare function displaySeriesFor(c: SolvableCase | null, mode: DisplayMode): {
|
|
18
19
|
bus: number;
|
|
19
20
|
value: number;
|
package/dist/display.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { priceGradient } from './colors.js';
|
|
1
|
+
import { demandGradient, priceGradient } from './colors.js';
|
|
2
2
|
import { priceCopy } from './format.js';
|
|
3
3
|
import { CaseState } from './state.svelte.js';
|
|
4
4
|
/** Variables present in the current result. Power flow has no LMP values. */
|
|
@@ -35,14 +35,32 @@ export function displayMetaFor(c) {
|
|
|
35
35
|
gradient: priceGradient
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
|
+
// Last, so a mode the result cannot show still falls back to a result variable.
|
|
39
|
+
if (c.network?.buses.length) {
|
|
40
|
+
options.push({
|
|
41
|
+
mode: 'demand',
|
|
42
|
+
label: 'demand',
|
|
43
|
+
unit: 'MW',
|
|
44
|
+
copy: 'Bus demand, including committed changes, on a square-root scale.',
|
|
45
|
+
gradient: demandGradient
|
|
46
|
+
});
|
|
47
|
+
}
|
|
38
48
|
return options;
|
|
39
49
|
}
|
|
40
|
-
/** Bus LMPs, voltage angles in radians,
|
|
41
|
-
*
|
|
50
|
+
/** Bus LMPs, voltage angles in radians, voltage magnitudes in per unit, or
|
|
51
|
+
* demand in MW with the case's committed changes. SOCWR supplies squared
|
|
52
|
+
* voltages, which are converted to magnitudes here. */
|
|
42
53
|
export function displaySeriesFor(c, mode) {
|
|
43
54
|
const sol = c?.solution;
|
|
44
|
-
if (!sol)
|
|
55
|
+
if (!c || !sol)
|
|
45
56
|
return [];
|
|
57
|
+
if (mode === 'demand') {
|
|
58
|
+
const deltas = caseDeltas(c);
|
|
59
|
+
return (c.network?.buses ?? []).map((b) => ({
|
|
60
|
+
bus: b.id,
|
|
61
|
+
value: b.demand_mw + (deltas[b.id] ?? 0)
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
46
64
|
if (mode === 'angle')
|
|
47
65
|
return sol.va;
|
|
48
66
|
if (mode === 'voltage') {
|
package/dist/format.d.ts
CHANGED
|
@@ -14,6 +14,15 @@ type SolveMeta = {
|
|
|
14
14
|
solveBackend: SolveBackend | null;
|
|
15
15
|
};
|
|
16
16
|
export declare function solveMetaLabel(c: SolveMeta): string;
|
|
17
|
+
export declare const INFEASIBLE_DISPATCH = "No feasible dispatch: demand cannot be served within line and generator limits";
|
|
18
|
+
/** The message for a local case whose browser solve failed, and the
|
|
19
|
+
* technical detail to disclose behind it. An infeasible DC OPF means the
|
|
20
|
+
* network cannot carry the demand, which deserves plain words. The engine
|
|
21
|
+
* spells it `DC OPF solve infeasible`, and an unbounded one as `unbounded`. */
|
|
22
|
+
export declare function browserSolveFailure(label: string, reason: string | null): {
|
|
23
|
+
message: string;
|
|
24
|
+
detail: string | null;
|
|
25
|
+
};
|
|
17
26
|
export declare function rgbaCss([r, g, b, a]: RGBA): string;
|
|
18
27
|
/** Min and max of a numeric array in one pass. Used instead of
|
|
19
28
|
* `Math.min(...values)` / `Math.max(...values)`, which throw a RangeError once the
|