@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.
Files changed (71) hide show
  1. package/README.md +233 -0
  2. package/dist/Sparkline.svelte +2 -2
  3. package/dist/TellegenMap.svelte +239 -66
  4. package/dist/TellegenMap.svelte.d.ts +45 -0
  5. package/dist/TellegenProvider.svelte +36 -4
  6. package/dist/TellegenShell.svelte +14 -42
  7. package/dist/TellegenViewer.svelte +12 -2
  8. package/dist/api.d.ts +10 -2
  9. package/dist/api.js +2 -0
  10. package/dist/basemap.d.ts +192 -0
  11. package/dist/basemap.js +259 -0
  12. package/dist/colors.d.ts +55 -1
  13. package/dist/colors.js +89 -0
  14. package/dist/components/AppFooter.svelte +3 -2
  15. package/dist/components/AppHeader.svelte +21 -8
  16. package/dist/components/BindingLines.svelte +1 -1
  17. package/dist/components/BusPicker.svelte +3 -3
  18. package/dist/components/ControlPanel.svelte +4 -2
  19. package/dist/components/DemandSlider.svelte +16 -7
  20. package/dist/components/DiagramCanvas.svelte +17 -5
  21. package/dist/components/DisplayControls.svelte +5 -1
  22. package/dist/components/DropZone.svelte +1 -1
  23. package/dist/components/FormulationSelector.svelte +1 -1
  24. package/dist/components/LocalCaseDetails.svelte +6 -3
  25. package/dist/components/McResults.svelte +98 -35
  26. package/dist/components/McResults.svelte.d.ts +8 -2
  27. package/dist/components/ModelDetails.svelte +5 -5
  28. package/dist/components/MulticonductorDetails.svelte +9 -9
  29. package/dist/components/NotificationCenter.svelte +4 -2
  30. package/dist/components/PanelCard.svelte +72 -7
  31. package/dist/components/PanelHost.svelte +59 -34
  32. package/dist/components/PanelHost.svelte.d.ts +4 -1
  33. package/dist/components/PlacementCue.svelte +1 -1
  34. package/dist/components/PowerFlowDetails.svelte +1 -1
  35. package/dist/components/RatingSlider.svelte +14 -5
  36. package/dist/components/RestoreDefaultsButton.svelte +1 -1
  37. package/dist/components/SavedNetworkDetails.svelte +3 -3
  38. package/dist/components/SensitivityReadout.svelte +2 -2
  39. package/dist/components/ShellEnvironment.svelte +51 -0
  40. package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
  41. package/dist/components/SizeLegend.svelte +2 -2
  42. package/dist/components/SolveCard.svelte +3 -3
  43. package/dist/components/TopMovers.svelte +1 -1
  44. package/dist/components.d.ts +3 -0
  45. package/dist/components.js +3 -0
  46. package/dist/context.svelte.d.ts +8 -1
  47. package/dist/context.svelte.js +8 -3
  48. package/dist/controller.svelte.d.ts +65 -5
  49. package/dist/controller.svelte.js +266 -78
  50. package/dist/display.d.ts +3 -2
  51. package/dist/display.js +22 -4
  52. package/dist/format.d.ts +9 -0
  53. package/dist/format.js +13 -1
  54. package/dist/index.d.ts +6 -2
  55. package/dist/index.js +2 -0
  56. package/dist/map.d.ts +4 -2
  57. package/dist/map.js +2 -1
  58. package/dist/maplibre-worker.d.ts +5 -0
  59. package/dist/maplibre-worker.dev.d.ts +11 -0
  60. package/dist/maplibre-worker.dev.js +15 -0
  61. package/dist/maplibre-worker.js +8 -0
  62. package/dist/notices.svelte.js +5 -1
  63. package/dist/panels.svelte.d.ts +6 -0
  64. package/dist/panels.svelte.js +31 -1
  65. package/dist/slider.d.ts +12 -0
  66. package/dist/slider.js +31 -0
  67. package/dist/state.svelte.d.ts +25 -3
  68. package/dist/state.svelte.js +13 -0
  69. package/dist/styles.css +59 -6
  70. package/dist/types.d.ts +1 -1
  71. 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 this.api.getCaseModuleJson(c.id);
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) < 0.25)
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) >= 0.25) ||
639
+ Math.abs(this.sliderValue - this.committedDelta) >= DEAD_ZONE_MW) ||
625
640
  (this.app.previewRatingMw !== null &&
626
- Math.abs(this.ratingSliderValue - this.committedRating) >= 0.25)));
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 this.api
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 this.api.getCases();
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) : this.api.getNetwork(c.id),
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' ? this.api.getSolution(c.id) : Promise.resolve(null)
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
- addAndActivateLocal = (c) => {
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
- if (this.serverComputeOff) {
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 this.api.getSensitivity(c.id, target.bus, c.deltas, ac.signal)
1305
- : await this.api.getBranchSensitivity(c.id, target.branch, c.deltas, ac.signal);
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
- this.app.error = `${c.label}: browser solve unavailable${c.solveFallbackReason ? `: ${c.solveFallbackReason}` : ''}`;
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 instead of opening a
1596
- // stream that answers 403 without a readable body (EventSource hides it).
1597
- if (this.serverComputeOff) {
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 = this.api.openSolveStream(c.id, c.deltas, sensBus, {
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) < 0.25) {
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
- this.app.displayMode = next === 'acpf' ? 'voltage' : 'price';
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
- 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
- }
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
- [result, loadBranches] = await Promise.all([
2127
- nextSession.result(),
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
- result =
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 result = await session.replaceLoadPowers(edits);
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
- const loadBranches = await session.loadBranches();
2329
- if (revision !== c.mcEditRevision)
2330
- continue;
2331
- c.mcLoadBranches = loadBranches;
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
- c.solveMs = performance.now() - started;
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
- if (c instanceof CaseState &&
2493
- c.solution &&
2494
- c.formulation === 'dcopf' &&
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) < 0.25 ? 0 : value) - committedAtBus;
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) < 0.25)
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) < 0.25 ? 0 : value) - committedAtBranch;
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) < 0.25) {
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, or voltage magnitudes in per unit.
16
- * SOCWR supplies squared voltages, which are converted to magnitudes here. */
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, or voltage magnitudes in per unit.
41
- * SOCWR supplies squared voltages, which are converted to magnitudes here. */
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